如果你做过几年 Python 后端早晚会碰到一个场景程序里得连一次 MySQL读点数据再写回去。我这些年写业务接口、做数据分析脚本、处理自动化任务最常用的方案就是一估pymysql起步。它不是什么新东西却是 Python 生态里最“顺手”的 MySQL 客户端驱动之一纯 Python 实现pip install pymysql就完事不用编译 C 扩展测试环境、生产环境都少踩一堆依赖坑。这篇文章我就从实际使用的角度把 Python PyMySQL 操作 MySQL 的完整链路拆一遍从环境准备、连接参数、增删改查到事务、批量写入、连接池和常见报错排障做一个能直接参考的操作指南。适合谁来读一种是刚学会 Python 基础、想连数据库练手的新人另一种是在公司里写内部工具、不想为了一个 CRUD 就引入重型 ORM 的老手。看完这套内容你对“Python 连接 MySQL 时到底发生了什么”会有一个比较完整的认识之后再去用 SQLAlchemy、aiomysql 之类的东西也会轻松很多。1. PyMySQL 选型逻辑为什么不用 MySQLdb 和 SQLAlchemy1.1 纯 Python 驱动到底好在哪先聊最基础的问题为什么选 PyMySQL而不是其他方案。MySQL 的 Python 客户端驱动有好几个最传统的是 MySQLdb也就是mysqldb。MySQLdb 是老项目里常见的东西性能其实不错但它是 C 扩展实现的安装前往往要编译一堆库尤其在 Windows 上装旧版本那个“缺libmysqlclient”的报错能卡住不少人。Python 3 的生态里MySQLdb 的维护也一直不太活跃所以新项目再倾向于选它就把自己丢进了兼容性的坑里。PyMySQL 就不一样。它直接用纯 Python 实现了 MySQL 客户端协议所以只要你机器上已经有 Pythonpip install pymysql装完就能跑跨平台、免编译这在 Linux 服务器、Windows 开发机、内网离线环境里都特别友好。它的 API 也刻意设计得和 MySQLdb 很像很多老代码从 MySQLdb 迁到 PyMySQL基本就是改个 import 的事游标、连接、事务这套概念都能无缝迁移。提示PyMySQL 底层依赖 cryptography 库来支持某些 MySQL 8 的认证插件。如果后续在连接 MySQL 8 时遇到“caching_sha2_password”相关报错优先把pymysql和cryptography都升到新版。1.2 几个主流方案的横向对比我整理了一张表把常见的几个方案放在一起看能更直观理解各自的定位方案实现方式优势短板PyMySQL纯 Python安装简单、跨平台、API 顺手、社区活跃没有官方背景超大结果集场景偏弱mysql-connector-python官方驱动Oracle 官方维护、功能全面、文档规范包相对重接口风格和 PyMySQL 略有差异MySQLdbC 扩展性能好、老项目存量多编译麻烦Python 3 支持不友好SQLAlchemyORM 框架高级查询、模型映射、多数据库适配强引入成本高初学者容易不知道怎么调试平时大家最爱讨论的一句话是“PyMySQL 和 SQLAlchemy 选哪个”。这其实不是同一个维度SQLAlchemy 是 ORM它可以把表结构映射成 Python 对象写起来很像在操作普通类而 PyMySQL 是驱动负责底层传输 SQL。SQLAlchemy 这样的 ORM 在底层也会用到某个驱动通过mysqlpymysql://这种连接串就能让 SQLAlchemy 借用 PyMySQL 当传输层。所以更准确的说法是直接用 PyMySQL 是“自己写 SQL亲手掌控一切”用 SQLAlchemy 是“让框架帮你拼 SQL你只管操作对象”。前者适合逻辑清晰、性能要求直接的场景后者适合项目里表多、关系复杂、追求开发效率的场景。1.3 什么时候选 PyMySQL什么时候直接上 ORM我个人的判断标准很朴素如果项目里的数据库操作只是简单增删改查而且团队都熟悉 SQL那就用 PyMySQL 直接写少一层抽象就少一层“黑盒”。如果是几百张表的大业务系统或者团队主要靠对象模型思考数据那用 SQLAlchemy 确实能省很多样板代码。但不管最后选不选 ORM我都建议先会了 PyMySQL 再去碰 ORM。因为 ORM 再怎么包装也会暴露 SQL 执行时的连接管理、事务边界、批量提交这些概念。你要是没见过cursor、commit、rowcount这些底层词遇到 ORM 的“诡异行为”只会一头雾水。把 PyMySQL 搞明白相当于拿到了所有上层封装的地基。2. 环境准备装好 Python、MySQL 和 PyMySQL2.1 先确认 Python 环境大多数场景下我们直接用系统里或官网下载的 Python 就行。在命令行输入python --version确保看到 3.8 以上的版本PyMySQL 对 Python 3 的支持很成熟老版本的 Python 2 就别再折腾了。为了不让项目依赖互相污染我习惯给每个项目单独建虚拟环境python -m venv venvWindows 下激活是venv\Scripts\activateLinux/macOS 下激活是source venv/bin/activate激活后pip装的包就都落到这个虚拟环境里不会影响其他项目。这一步看着多实际上能省掉后面用依赖冲突排查的力气。2.2 安装 PyMySQL顺便解决 MySQL 8 认证插件安装本身一行命令pip install pymysql有些网络环境下载慢可以加国内镜像源比如清华、阿里云的 pip 源pip install pymysql -i https://pypi.tuna.tsinghua.edu.cn/simple如果你是用 requirements.txt 管理依赖就把版本锁一下我用的是pymysql1.1.1 cryptography42.0.5这里专门提一下cryptography。MySQL 8 默认的用户认证插件是caching_sha2_password老版本的 PyMySQL 在握手阶段可能不认识这个插件然后抛Authentication plugin caching_sha2_password cannot be loaded。解决方式有两种一是升级 PyMySQL 到较新版本同时装上cryptography二是给 MySQL 侧新建一个使用旧认证的账号比如CREATE USER app% IDENTIFIED WITH mysql_native_password BY 你的密码; GRANT ALL PRIVILEGES ON test.* TO app%;新项目更推荐前面那种别为了省事把账号认证降到旧的mysql_native_password不利于长期安全维护。2.3 快速准备一个测试用 MySQLDocker 一把梭很多新手卡在“我已经在写 Python 了但本地没有 MySQL”。你要是只是为了测试 PyMySQL没必要手动去官网装完整版 MySQL用 Docker 起一个临时的更干净。前提是机器上已经装了 Docker然后执行docker run -d --name mysql-test \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD123456 \ -e MYSQL_DATABASEtest \ mysql:8.0这条命令做的事拉取 MySQL 8 镜像把容器里的 3306 端口映射到宿主机 3306设置 root 密码为123456并自动创建一个test库。MySQL 容器首次启动要初始化数据大概等 30 到 60 秒再用客户端检查。注意如果你是 Windows 上跑 Docker3306 端口可能被本机的 MySQL 服务占用。真遇到端口冲突把映射改成-p 3307:3306后面 Python 连接时对应把 port 改成 3307 就行。2.4 用一段代码验证连接是否通畅环境准备好以后先用最小代码验证一下“能不能连上”。新建一个check_conn.pyimport pymysql conn pymysql.connect( host127.0.0.1, port3306, userroot, password123456, databasetest, charsetutf8mb4, ) with conn.cursor() as cursor: cursor.execute(SELECT VERSION() AS version) print(cursor.fetchone()) conn.close()如果你用默认游标fetchone()返回的是一个元组比如(8.0.32,)。能看到版本号就说明 Python 到 MySQL 这条链路已经通了可以进行接下来的实战操作。3. 核心 API 拆解连接、游标、增删改查3.1 connect() 参数逐个说charset 为什么推荐 utf8mb4PyMySQL 的连接入口是pymysql.connect()最常用的参数也就是这几个参数作用常用取值hostMySQL 主机地址127.0.0.1 或内网 IPportMySQL 服务端口3306user、password账号信息按实际配置database默认连接的库testcharset客户端字符集utf8mb4autocommit是否自动提交事务False / Truecursorclass返回游标类型DictCursorconnect_timeout连接超时秒数5 或 10这里最常被忽略的是charset。MySQL 的utf8在很早的版本里其实只是utf8mb3并不完整支持四字节字符比如 emoji 和一些生僻字就存不进去。所以我一直建议连接参数里统一写utf8mb4conn pymysql.connect( ..., charsetutf8mb4, )utf8mb4是 MySQL 里更完整的 UTF-8 实现和“调用端用什么编码”是两个维度。即使 Python 字符串本身是 Unicode如果连接层字符集不对写入时照样可能报Incorrect string value。3.2 一张用户表的增删改查完整示例我先建一张简单的用户表后面所有示例都围绕它展开CREATE TABLE IF NOT EXISTS users ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;接下来是标准的增删改查。这里我用的是默认游标先让你看到最原生的执行过程import pymysql conn pymysql.connect( host127.0.0.1, port3306, userroot, password123456, databasetest, charsetutf8mb4, ) # 插入 with conn.cursor() as cursor: sql INSERT INTO users (name, age) VALUES (%s, %s) cursor.execute(sql, (张三, 25)) conn.commit() # 查询带排序 with conn.cursor() as cursor: cursor.execute(SELECT id, name, age FROM users ORDER BY id DESC) rows cursor.fetchall() for row in rows: print(row) # 更新 with conn.cursor() as cursor: sql UPDATE users SET age %s WHERE name %s cursor.execute(sql, (26, 张三)) conn.commit() # 删除 with conn.cursor() as cursor: sql DELETE FROM users WHERE name %s cursor.execute(sql, (张三,)) conn.commit() conn.close()注意几个细节每个with conn.cursor()只保证游标被关闭并不代表事务自动提交所以我在每个写操作后面都手动conn.commit()。这是新手最容易掉的坑后面还会展开。参数传递用%s占位符即使只有一个参数也要写成(张三,)这种元组形式括号里那个逗号不能省。查询里我顺手加了ORDER BY id DESC这是非常常见的 MySQL 排序需求。如果你在真实项目里查排行榜、查最新记录十次有八次都会用到排序不要动不动把整个表拉到 Python 里再排序。3.3 参数化查询别再用 f-string 拼 SQL再强调为什么上面的 SQL 里都用%s而不是用 f-string 直接拼值。看下面这段危险代码cursor.execute(fSELECT * FROM users WHERE name {name})如果name是用户输入比如传入; DROP TABLE users; --拼出来的 SQL 就会变成恶意语句。哪怕反向防御你还得处理各种转义、引号、特殊字符很容易漏。更别提 f-string 拼出来的 SQLMySQL 每一条看起来都是新语句执行计划缓存基本失效。所以老老实实用参数化cursor.execute(SELECT * FROM users WHERE name %s, (name,))PyMySQL 拿到参数之后会把它们按类型安全地传给服务端你完全不需要关心转义。有一点要提醒%s只能占位“值”不能占位“表名”和“列名”。如果你要动态切换表名或排序字段只能自己先把标识符拼进 SQL但必须用白名单校验不能直接信任外部输入。4. 事务、提交回滚与锁等待的问题4.1 手动提交和自动提交该怎么选MySQL 默认的会话行为里事务提交有两种模式自动提交和手动提交。PyMySQL 默认是没有开启自动提交的也就是autocommitFalse所以每次执行了INSERT/UPDATE/DELETE之后都需要手动调conn.commit()。如果你希望每条 SQL 都立即生效可以在连接时打开conn pymysql.connect( ..., autocommitTrue, )开启之后就不用每次写commit()了。看起来省事但如果你需要“多个步骤必须同时成功或同时失败”的操作自动提交会让中间状态瞬间暴露回滚也来不及。所以我的实践是日常简单实验可以开自动提交真正写业务逻辑时全部用手动事务。手动事务的标准结构是这样try: with conn.cursor() as cursor: cursor.execute(UPDATE account SET balance balance - %s WHERE id %s, (100, 1)) cursor.execute(UPDATE account SET balance balance %s WHERE id %s, (100, 2)) conn.commit() except Exception: conn.rollback() raise finally: conn.close()这里两个账户的金额变动是绑在一起的一笔转账。如果第一个UPDATE成功、第二个因为某种原因报错程序会进入except分支执行rollback()把第一个UPDATE也撤销掉不会出现“钱扣了但没到账”的尴尬。4.2 一条 SQL 不生效的罪魁祸首忘了 commit我接过的很多“为什么数据没写进去”的问题最后都指向同一个原因with conn.cursor()退出之后写操作没有提交。哪怕cursor.execute()执行成功事务还没提交其他连接是看不到这次修改的。你把表查一遍结果里就是没有那行新数据但代码也没报错非常迷惑。尤其是这段看起来“很美国队长”的写法with conn.cursor() as cursor: cursor.execute(INSERT ...) # 没有 conn.commit()PyMySQL 里with conn.cursor()只负责游标的关闭不会替你提交事务。如果你看到谁把commit()写进“成功执行完 execute 之后同一行”大概率是没理解事务边界。正确做法是在with块结束后、且没有异常的前提下显式conn.commit()一旦有异常conn.rollback()。4.3 锁分类、FOR UPDATE 与 Lock wait timeout聊到事务必然绕不开 MySQL 的锁机制。Python 程序发了多条 SQL数据库端为了保证数据一致性会用锁来协调不同事务。MySQL 的锁从粒度上分常见的就三种行锁、间隙锁、表锁。InnoDB 引擎默认在大多数场景下使用行锁性能和并发度都更好但如果 SQL 的条件没走索引MySQL 就可能退化成表锁比如在超大表上执行UPDATE users SET age age 1 WHERE status 1而status没有索引那整张表都被锁住并发写全卡住。有一种常见的“悲观锁”写法就是在事务里with conn.cursor() as cursor: cursor.execute(SELECT * FROM inventory WHERE id %s FOR UPDATE, (10,)) # 对库存做判断、扣减SELECT ... FOR UPDATE会锁定命中行其他事务想再锁这行就必须等当前事务提交或回滚。如果这个锁等了太久MySQL 会抛出Lock wait timeout exceeded错误。很多新手看到这个报错以为数据库坏了其实只是另一个事务还没结束。排查时可以先看两个变量SHOW VARIABLES LIKE innodb_lock_wait_timeout; SHOW VARIABLES LIKE transaction_isolation;innodb_lock_wait_timeout默认是 50 秒也就是说一个事务锁住一行后撑到 50 秒后面等锁的事务就会超时报错。我会把业务里所有事务控制在“短、快、小”尽量不把外部 HTTP 调用、文件读写夹在事务中间因为那会无限拉长持有锁的时间。另外还要注意死锁比如两个事务各自更新了对方的行MySQL 检测到死锁后会直接回滚其中一方程序里要做重试逻辑不能傻乎乎地只报一次错就跑路。5. 数据读取与游标类型DictCursor、fetch 和自增 ID5.1 元组游标 vs DictCursor前面所有示例里默认游标返回的都是元组。比如查询SELECT id, name, age拿到的是(1, 张三, 25)。元组的问题在于你必须记得字段顺序写row[1]或row[2]的时候时间久了根本分不清哪个是哪个。我强烈建议在连接时把游标换成DictCursorimport pymysql from pymysql.cursors import DictCursor conn pymysql.connect( ..., cursorclassDictCursor, )这样查询结果就变成字典{id: 1, name: 张三, age: 25}读取时写row[name]清晰直观也不怕字段顺序调整。尤其当你的 SQL 是多个表JOIN出来的结果字段一多字典方式的收益就更明显。这个改动成本几乎为零纯收益我遇到的所有可维护性项目统一都用DictCursor。5.2 fetchone、fetchmany、fetchall 与流式游标游标执行完execute()之后结果集不会自动出现在变量里要用fetch系列方法取出来。fetchone()取一行结果用完了返回None。fetchmany(size)一次性取若干行适合分页或控制内存。fetchall()一次取完所有行最省事但最费内存。如果是几十万行的大结果集fetchall()会一次性把数据全部塞进 Python 内存很容易让进程涨到几百 MB、甚至直接卡死。这种场景可以用流式游标SSCursorfrom pymysql.cursors import SSCursor conn pymysql.connect( ..., cursorclassSSCursor, ) with conn.cursor() as cursor: cursor.execute(SELECT id, name FROM users WHERE age %s, (18,)) for row in cursor: print(row)SSCursor不会在客户端缓存所有行而是边从 MySQL 服务端取数据边遍历内存友好。但要特别注意用了SSCursor后结果集没遍历完之前同一个连接上不能再执行其他 SQL否则会报Packet sequence wrong。所以大查询尽量单独分配连接宁可多建一个连接也不要把流式游标和后续操作塞在一起。5.3 获取刚插入的自增主键和受影响行数插入数据后经常需要知道刚生成的自增id比如创建了一个用户后马上要拿着user_id去写权限表。PyMySQL 的做法是读取游标的lastrowidwith conn.cursor() as cursor: cursor.execute(INSERT INTO users (name, age) VALUES (%s, %s), (李四, 30)) new_id cursor.lastrowid print(新用户 id:, new_id) conn.commit()lastrowid拿到的是当前连接里最近一次INSERT产生的自增主键同一个连接上被其他语句覆盖之前你都能取到。游标还有一个rowcount属性表示execute()影响的行数。查询时它就是返回行数更新删除时它表示被修改的行数。这里有个很坑的点MySQL 默认显示的是“实际被修改的行数”不是“条件匹配的行数”。比如你把某行年龄从 25 改到 25虽然 SQL 匹配到了 1 行但实际没改动rowcount可能就是 0。如果业务上需要精确判断“这条 UPDATE 是否影响了一行”不仅得看rowcount还要确认两边的数据值是不是真的发生了变化。6. 批量写入与性能优化从 executemany 到连接池6.1 executemany 批量插入为什么要分片提交如果说你一次要录入 1000 条用户记录用execute()循环 1000 次就等于和 MySQL 做了 1000 次网络往返。虽然每次往返很快但在高并发场景下这种写法会白白增加延迟和数据库压力。PyMySQL 提供了executemany()可以把多条数据合并在一条批量操作里执行data [ (tony, 27), (amy, 31), (jake, 24), ] with conn.cursor() as cursor: sql INSERT INTO users (name, age) VALUES (%s, %s) cursor.executemany(sql, data) conn.commit()executemany内部并不会真的把 1000 条合并成一条超大 SQL而是用更高效的方式分批发给服务端但即便如此也不要一次塞几十万条。我实测下来建议按 500 到 1000 条为一批处理batch_size 500 data [...] # 很长很长的列表 with conn.cursor() as cursor: for i in range(0, len(data), batch_size): chunk data[i:i batch_size] cursor.executemany(INSERT INTO users (name, age) VALUES (%s, %s), chunk) conn.commit()分片提交有几个好处第一单条事务长度可控哪怕中间出错回滚范围也能接受第二避免单包过大触发 MySQL 的max_allowed_packet上限第三避免一次写入过多数据导致锁持有时间过长拖累其他读写。6.2 结合 MySQL 自身做一些调优代码写好后性能瓶颈往往不在 PyMySQL而在 SQL 和表结构本身。我见过不少项目Python 代码已经很精简了数据库却慢得吓人最后用EXPLAIN一看全是全表扫描。在 PyMySQL 里执行EXPLAIN跟在客户端里执行是一样的with conn.cursor() as cursor: cursor.execute(EXPLAIN SELECT * FROM users WHERE age %s, (18,)) explain cursor.fetchall() print(explain)重点看type字段如果是ALL说明 SQL 没走索引如果是index、range、ref通常是能用上索引的。对大表做排序、范围查询建议建合适的索引ALTER TABLE users ADD INDEX idx_age (age);其他常见调优观感查询只取需要的字段别一上来就SELECT *把几百个用不到的大字段传输到应用层。写多读少时适当的冗余字段能减少不必要的JOIN。避免在索引列上做函数操作比如WHERE DATE(created_at) 2025-01-01会让索引失效写成范围比较created_at ... AND created_at ...更稳。连接数压力上来后优先考虑连接池后面专门讲。至于“高并发解决方案”这个更大话题通常不是靠单一驱动解决的需要从连接池、主从分离、分库分表、缓存等多个层面去设计。但应用层先做到短事务、批量写、复用连接、及时释放已经能挡住很大一部分常见压测了。6.3 存储过程也能直接调用有些团队习惯把复杂业务逻辑写进 MySQL 存储过程PyMySQL 完全支持。你可以用标准的 CALL 语法执行with conn.cursor() as cursor: cursor.execute(CALL get_user_count(%s), (18,)) result cursor.fetchall()也可以用callprocwith conn.cursor() as cursor: cursor.callproc(get_user_count, (18,)) cursor.execute(SELECT _get_user_count_1) print(cursor.fetchall())这里我不推荐业务逻辑全放存储过程但如果你接手的老项目里已经用了存储过程PyMySQL 不会成为障碍。需要注意callproc拿到的输出参数要另起一条SELECT _存储过程名_序号去查这一点和 MySQL 命令行客户端里直接看变量是一样的。7. 连接池从单脚本到稳妥小服务的必由之路7.1 连接池要解决什么问题每次pymysql.connect()背后都有一套完整的 TCP 握手、认证、初始化会话的开销。如果你的程序频繁打开、关闭数据库连接哪怕是几毫秒级的开销在高并发下也会被放大。数据库端的连接数量还是有限资源默认max_connections通常只有一两百多任务同时建连接很容易把连接数打满后面的请求就只能排队了。连接池的思想是预先在池子里存一些“空闲连接”程序要用时拿出来用完再放回去而不是真的把连接关掉。这样既省了频繁建连的开销又能限制总连接数避免把数据库“塞爆”。对于部署成 Web 服务、定时任务、批量处理脚本的场景连接池都能显著降低延迟和故障率。7.2 PooledDB PyMySQL 的最小实现用连接池最常用的库是DBUtils先安装pip install dbutils注意 2.0 版本之后的包名有所调整正确的导入方式是from dbutils.pooled_db import PooledDB老项目里如果看到from DBUtils.PooledDB import PooledDB那是 1.x 的写法建议升级后统一用新路径。建立一个简单的连接池模块我一般单独放一个db.pyimport pymysql from dbutils.pooled_db import PooledDB from pymysql.cursors import DictCursor POOL PooledDB( creatorpymysql, # 使用 PyMySQL 作为底层驱动 maxconnections20, # 连接池允许的最大连接数 mincached2, # 初始化时至少创建的空闲连接数 maxcached10, # 池里最多保留的空闲连接数 blockingTrue, # 连接不足时调用方阻塞等待 maxusageNone, # 单个连接最大复用次数None 表示不限制 setsession[SET NAMES utf8mb4], host127.0.0.1, port3306, userroot, password123456, databasetest, charsetutf8mb4, cursorclassDictCursor, ) def get_conn(): return POOL.connection()之后业务里使用conn get_conn() try: with conn.cursor() as cursor: cursor.execute(SELECT * FROM users WHERE id %s, (1,)) print(cursor.fetchone()) finally: conn.close()这里的conn.close()很重要但它并不是“真关闭连接”而是把连接归还给连接池。如果你不归还对应连接一直被占用池子很快就会被掏空。7.3 使用连接池时的两个提醒第一个提醒是事务控制。连接池里的连接复用性很强如果你在业务代码里开启了事务、执行到一半忘记commit()或rollback()就把连接还给池子下一个人拿到这个连接时会话状态是乱的很容易出现“上一个人的事务污染了下一个人的数据”。所以拿连接之前想好这件事是不是必须在同一个事务里连接归还之前务必确认事务已经收尾。第二个提醒是异常处理。连接池里的连接可能因为数据库重启或网络抖动而失效。如果拿到一个“看起来能用”的连接执行时却发现它已经被服务端断开了PyMySQL 通常会抛OperationalError。这种情况下没必要硬处理底层连接状态可以直接重试一次从池里再拿一个全新连接for attempt in range(2): conn get_conn() try: with conn.cursor() as cursor: cursor.execute(SELECT 1) break except pymysql.err.OperationalError: conn get_conn() finally: conn.close()真实项目可以封装一个更完善的重试机制但核心思想就四个字连接复用、用完归还。8. 新手绕不开的异常从安装到写入的完整排错8.1 先排查环境再排查代码在我的经验里PyMySQL 报错有八成是“环境没通”而不是“Python 代码写错”。遇到报错先别急着改代码按这个顺序过一遍确认 MySQL 服务有没有启动。Windows 可以执行net start mysql或者去服务列表看Linux 看systemctl status mysql或service mysql status。确认端口是否正确。默认 3306如果用了 Docker 映射到其他端口连接参数里的port要跟着改。确认账号权限。MySQL 的账号通常绑定了可访问的主机比如rootlocalhost就只能本机访问。你在 Python 里连127.0.0.1时实际上视作 localhost 请求连内网 IP 时就要确认账号是app%或对应 IP。用 MySQL 客户端先手动执行一下同一个 SQL。如果客户端能跑通问题大概率在 Python 连接层如果客户端也报错那就是 SQL 或数据本身的问题别甩锅给 PyMySQL。8.2 常见错误速查表我把日常最常撞到的报错整理成一张表方便你直接对照报错信息常见原因解决思路ERROR 1045 (28000): Access denied for user用户名或密码错误或账号来源地址不允许核对密码给账号授权对应来源pymysql.err.OperationalError: (2003, ...)连接不到服务没启动、端口错、防火墙挡检查服务、端口映射、放行防火墙pymysql.err.OperationalError: (2059, ...)MySQL 8 认证插件不兼容升级 PyMySQL/cryptography或改用 mysql_native_passwordERROR 1049: Unknown database库不存在SHOW DATABASES确认先创建库ERROR 1146: Table doesnt exist表不存在检查库名、表名是否匹配ERROR 1064: You have an error in your SQL syntaxSQL 语句拼写有误把 SQL 打印出来在客户端跑一遍ERROR 1062: Duplicate entry唯一键冲突用INSERT ... ON DUPLICATE KEY UPDATE或REPLACE INTOpymysql.err.OperationalError: (2013, Lost connection)服务端断开连接超时、包过大调大max_allowed_packet、检查网络Packet sequence wrong一个连接同时被多个游标并发复用确保结果集用完再复用连接或换新连接Lock wait timeout exceeded事务锁等待超时看事务持续时间缩短锁范围查innodb_lock_wait_timeout8.3 中文乱码的最后一公里现在大多数人已经知道把连接参数写成charsetutf8mb4但乱码问题仍然可能发生原因通常在“最后一公里”。如果你 Python 代码里写中文、连接层用 utf8mb4、MySQL 库表也是 utf8mb4但某些字段写进去还是乱码就要检查字段本身的字符集和排序规则。可以用这条命令看SHOW CREATE TABLE users;如果字段上显示的还是latin1或utf8mb3就需要修改表字段字符集。修改要谨慎会锁表并产生长时间的在线变更ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;另外终端工具查看也分两套代码里正常、客户端里乱码那是你连接工具显示的字符集问题代码里乱、客户端正常那问题还是在连接参数或字段字符集上。排查时先分清“哪一侧显示乱码”否则会白跑很多弯路。8.4 锁等待与性能异常的排查线索最后再说一个容易被误判的情况。有些 PyMySQL 项目不是报错而是“请求特别慢”最后定位到数据库端锁竞争。这时可以去 MySQL 里直接看锁等待情况SHOW ENGINE INNODB STATUS;里面会列出事务状态、持有锁、等待锁等信息。业务上更实用的是先看当前有哪些事务卡了很久SELECT * FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) 60;如果发现有长期未提交的事务顺着对应的连接去排查代码大概率是某个execute()之后漏了commit()或者一个连接正在被两个人同时使用。顺手检查应用侧的timeout参数也常常能提早触发断开而不是干等50秒。如果锁等待严重我会做的第一件事是确认所有事务是否“短平快”一条 SQL 就提交、条件走索引、不把外部 IO 塞进事务。把这些都做好锁竞争会小很多比单纯调数据库参数靠谱得多。说说我自己的习惯吧。早期用 PyMySQL 时我也被“忘写 commit”坑过一晚上都在查为什么数据没进库后来被大结果集的fetchall()拖垮过内存再后来才慢慢把连接池、批量写入、流式游标都用上。这套组合用熟练之后大多数 Python 项目的 MySQL 操作都会变得很“稳”调试也直观。最后再分享一个小技巧你可以在连接时多打几条临时 SQL把SELECT VERSION()、SHOW VARIABLES LIKE innodb_lock_wait_timeout一起打出来很多环境问题能当场看清不用来回猜。