如果你做过几年 Python 后端,早晚会碰到一个场景:程序里得连一次 MySQL,读点数据,再写回去。
我这些年写业务接口、做数据分析脚本、处理自动化任务,最常用的方案就是一估pymysql起步。它不是什么新东西,却是 Python 生态里最“顺手”的 MySQL 客户端驱动之一:纯 Python 实现,pip install pymysql就完事,不用编译 C 扩展,测试环境、生产环境都少踩一堆依赖坑。这篇文章我就从实际使用的角度,把 Python + PyMySQL 操作 MySQL 的完整链路拆一遍,从环境准备、连接参数、增删改查,到事务、批量写入、连接池和常见报错排障,做一个能直接参考的操作指南。
适合谁来读?一种是刚学会 Python 基础、想连数据库练手的新人;另一种是在公司里写内部工具、不想为了一个 CRUD 就引入重型 ORM 的老手。看完这套内容,你对“Python 连接 MySQL 时到底发生了什么”会有一个比较完整的认识,之后再去用 SQLAlchemy、aiomysql 之类的东西,也会轻松很多。
1. PyMySQL 选型逻辑:为什么不用 MySQLdb 和 SQLAlchemy
1.1 纯 Python 驱动到底好在哪
先聊最基础的问题:为什么选 PyMySQL,而不是其他方案。
MySQL 的 Python 客户端驱动有好几个,最传统的是 MySQLdb,也就是mysqldb。MySQLdb 是老项目里常见的东西,性能其实不错,但它是 C 扩展实现的,安装前往往要编译一堆库,尤其在 Windows 上装旧版本,那个“缺libmysqlclient”的报错能卡住不少人。Python 3 的生态里,MySQLdb 的维护也一直不太活跃,所以新项目再倾向于选它,就把自己丢进了兼容性的坑里。
PyMySQL 就不一样。它直接用纯 Python 实现了 MySQL 客户端协议,所以只要你机器上已经有 Python,pip 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 略有差异 |
| MySQLdb | C 扩展 | 性能好、老项目存量多 | 编译麻烦,Python 3 支持不友好 |
| SQLAlchemy | ORM 框架 | 高级查询、模型映射、多数据库适配强 | 引入成本高,初学者容易不知道怎么调试 |
平时大家最爱讨论的一句话是“PyMySQL 和 SQLAlchemy 选哪个”。这其实不是同一个维度:SQLAlchemy 是 ORM,它可以把表结构映射成 Python 对象,写起来很像在操作普通类;而 PyMySQL 是驱动,负责底层传输 SQL。SQLAlchemy 这样的 ORM 在底层也会用到某个驱动,通过mysql+pymysql://这种连接串,就能让 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 和 PyMySQL
2.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 管理依赖,就把版本锁一下,我用的是:
pymysql==1.1.1 cryptography==42.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 快速准备一个测试用 MySQL(Docker 一把梭)
很多新手卡在“我已经在写 Python 了,但本地没有 MySQL”。你要是只是为了测试 PyMySQL,没必要手动去官网装完整版 MySQL,用 Docker 起一个临时的更干净。
前提是机器上已经装了 Docker,然后执行:
docker run -d --name mysql-test \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ -e MYSQL_DATABASE=test \ mysql:8.0这条命令做的事:拉取 MySQL 8 镜像,把容器里的 3306 端口映射到宿主机 3306,设置 root 密码为123456,并自动创建一个test库。MySQL 容器首次启动要初始化数据,大概等 30 到 60 秒再用客户端检查。
注意:如果你是 Windows 上跑 Docker,3306 端口可能被本机的 MySQL 服务占用。真遇到端口冲突,把映射改成
-p 3307:3306,后面 Python 连接时对应把 port 改成 3307 就行。
2.4 用一段代码验证连接是否通畅
环境准备好以后,先用最小代码验证一下“能不能连上”。新建一个check_conn.py:
import pymysql conn = pymysql.connect( host="127.0.0.1", port=3306, user="root", password="123456", database="test", charset="utf8mb4", ) 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 为什么推荐 utf8mb4
PyMySQL 的连接入口是pymysql.connect(),最常用的参数也就是这几个:
| 参数 | 作用 | 常用取值 |
|---|---|---|
| host | MySQL 主机地址 | 127.0.0.1 或内网 IP |
| port | MySQL 服务端口 | 3306 |
| user、password | 账号信息 | 按实际配置 |
| database | 默认连接的库 | test |
| charset | 客户端字符集 | utf8mb4 |
| autocommit | 是否自动提交事务 | False / True |
| cursorclass | 返回游标类型 | DictCursor |
| connect_timeout | 连接超时秒数 | 5 或 10 |
这里最常被忽略的是charset。MySQL 的utf8在很早的版本里其实只是utf8mb3,并不完整支持四字节字符,比如 emoji 和一些生僻字就存不进去。所以我一直建议连接参数里统一写utf8mb4:
conn = pymysql.connect( ..., charset="utf8mb4", )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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;接下来是标准的增删改查。这里我用的是默认游标,先让你看到最原生的执行过程:
import pymysql conn = pymysql.connect( host="127.0.0.1", port=3306, user="root", password="123456", database="test", charset="utf8mb4", ) # 插入 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(f"SELECT * FROM users WHERE name = '{name}'")如果name是用户输入,比如传入"'; DROP TABLE users; --",拼出来的 SQL 就会变成恶意语句。哪怕反向防御,你还得处理各种转义、引号、特殊字符,很容易漏。更别提 f-string 拼出来的 SQL,MySQL 每一条看起来都是新语句,执行计划缓存基本失效。
所以老老实实用参数化:
cursor.execute("SELECT * FROM users WHERE name = %s", (name,))PyMySQL 拿到参数之后,会把它们按类型安全地传给服务端,你完全不需要关心转义。有一点要提醒:%s只能占位“值”,不能占位“表名”和“列名”。如果你要动态切换表名或排序字段,只能自己先把标识符拼进 SQL,但必须用白名单校验,不能直接信任外部输入。
4. 事务、提交回滚与锁等待的问题
4.1 手动提交和自动提交该怎么选
MySQL 默认的会话行为里,事务提交有两种模式:自动提交和手动提交。PyMySQL 默认是没有开启自动提交的,也就是autocommit=False,所以每次执行了INSERT/UPDATE/DELETE之后,都需要手动调conn.commit()。
如果你希望每条 SQL 都立即生效,可以在连接时打开:
conn = pymysql.connect( ..., autocommit=True, )开启之后就不用每次写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 和自增 ID
5.1 元组游标 vs DictCursor
前面所有示例里,默认游标返回的都是元组。比如查询SELECT id, name, age,拿到的是(1, '张三', 25)。元组的问题在于,你必须记得字段顺序,写row[1]或row[2]的时候,时间久了根本分不清哪个是哪个。
我强烈建议在连接时把游标换成DictCursor:
import pymysql from pymysql.cursors import DictCursor conn = pymysql.connect( ..., cursorclass=DictCursor, )这样查询结果就变成字典:
{'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、甚至直接卡死。这种场景可以用流式游标SSCursor:
from pymysql.cursors import SSCursor conn = pymysql.connect( ..., cursorclass=SSCursor, ) 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 的做法是读取游标的lastrowid:
with 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()也可以用callproc:
with 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.py:
import pymysql from dbutils.pooled_db import PooledDB from pymysql.cursors import DictCursor POOL = PooledDB( creator=pymysql, # 使用 PyMySQL 作为底层驱动 maxconnections=20, # 连接池允许的最大连接数 mincached=2, # 初始化时至少创建的空闲连接数 maxcached=10, # 池里最多保留的空闲连接数 blocking=True, # 连接不足时,调用方阻塞等待 maxusage=None, # 单个连接最大复用次数,None 表示不限制 setsession=["SET NAMES utf8mb4"], host="127.0.0.1", port=3306, user="root", password="123456", database="test", charset="utf8mb4", cursorclass=DictCursor, ) 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 的账号通常绑定了可访问的主机,比如
'root'@'localhost'就只能本机访问。你在 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_password |
ERROR 1049: Unknown database | 库不存在 | SHOW DATABASES确认;先创建库 |
ERROR 1146: Table doesn't exist | 表不存在 | 检查库名、表名是否匹配 |
ERROR 1064: You have an error in your SQL syntax | SQL 语句拼写有误 | 把 SQL 打印出来在客户端跑一遍 |
ERROR 1062: Duplicate entry | 唯一键冲突 | 用INSERT ... ON DUPLICATE KEY UPDATE或REPLACE INTO |
pymysql.err.OperationalError: (2013, 'Lost connection') | 服务端断开连接:超时、包过大 | 调大max_allowed_packet、检查网络 |
Packet sequence wrong | 一个连接同时被多个游标并发复用 | 确保结果集用完再复用连接,或换新连接 |
Lock wait timeout exceeded | 事务锁等待超时 | 看事务持续时间,缩短锁范围,查innodb_lock_wait_timeout |
8.3 中文乱码的最后一公里
现在大多数人已经知道把连接参数写成charset="utf8mb4",但乱码问题仍然可能发生,原因通常在“最后一公里”。
如果你 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'一起打出来,很多环境问题能当场看清,不用来回猜。