跑通一个demo只需要五分钟,真正理解PyMySQL,可能要反复踩几天的坑。这不是吓唬你,而是我见过太多人在本地跑通示例后,一遇到真实业务就卡住:明明代码看着没问题,就是报错;明明查询结果就在那里,就是取不出来;明明insert执行成功了,数据就是没写进表里。这些问题十有八九都出在连接参数、游标机制、事务提交这些“看起来简单”的地方。
这篇教程的目的,就是把PyMySQL从连接到增删改查的完整路径走一遍,然后把每个关键API背后的运行原理讲清楚。顺手把新手最容易遇到的报错和坑提前列出来,你能少走很多弯路。内容适合刚学Python、需要操作MySQL写作业或做小系统的同学,也适合那些用了一段时间但总感觉“哪里没搞明白”的开发者。
1. 为什么第一门MySQL客户端库我推荐PyMySQL
在开始写代码之前,先花点时间说说选型问题。很多人上来就问“Python连MySQL用什么库”,结果搜出来一堆名字:PyMySQL、MySQLdb、mysql-connector-python、SQLAlchemy……容易看晕。
1.1 PyMySQL和几个常见方案的对比
我个人的结论很直接:如果你是初学者,或者项目规模不大,直接用PyMySQL就对了。
先看一个横向对比表格:
| 选项 | 实现方式 | 安装体验 | API风格 | 适用场景 |
|---|---|---|---|---|
| PyMySQL | 纯Python | pip直接装,零依赖 | 简洁直观 | 入门学习、中小项目、脚本工具 |
| MySQLdb | C扩展 | 需要编译,Windows上容易出问题 | 老派但成熟 | 维护老项目、追求极致性能 |
| mysql-connector-python | 官方提供 | pip可装但包比较大 | 参数命名偏官方风格 | 需要官方支持、特殊功能 |
| SQLAlchemy | ORM(也含Core) | pip安装 | 两个层面,学习曲线陡 | 大型项目、需要对象映射 |
MySQLdb在Python 2时代确实是主流,但它依赖C扩展编译,你在Windows上装它可能需要先解决编译器的问题。我自己早年就被这个折腾过,后来转投PyMySQL,体验瞬间清爽——pip install pymysql一条命令搞定,不需要编译任何东西,跨平台表现一致。
mysql-connector-python虽然顶着“官方”的名头,但它的安装包更大,而且API风格和Python社区的习惯略有出入。不是说它不好,而是在“快速上手”这个目标下,PyMySQL的写法更顺手。
SQLAlchemy是另一回事,它是个ORM框架,可以直接写SQL用它的Core部分,但那套Session、Engine的抽象对新手来说有点重。如果你只是想快速操作MySQL,这种重量级武器反而增加了心智负担。
1.2 什么场景适合直接选PyMySQL
PyMySQL最适合下面几类场景:
- 本地脚本、数据分析脚本需要读MySQL数据
- Flask、Django、FastAPI等Web项目要用MySQL,又不想上重型ORM
- 写运维自动化工具,需要批量更新数据库
- 学习阶段想搞懂SQL语句本身,而不是被对象映射掩盖掉SQL细节
另外提一句,Tornado等异步框架里还有aiomysql这类异步库,但那是异步入门之后的事了。先把PyMySQL这个同步库玩明白,异步库的很多概念也能迁移。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:从零跑到第一段可执行代码
选好了库,下面进入实际操作。这一步很多人会跳过去直接抄代码,但我的建议是,花几分钟把环境一次性弄干净,后面会顺畅很多。
2.1 安装与版本确认
Python版本建议3.7以上,PyMySQL新版本对3.8、3.9、3.10、3.11、3.12都做了兼容。安装很简单:
bash复制pip install pymysql
如果是在国内网络环境,可以用镜像源加速:
bash复制pip install pymysql -i https://pypi.tuna.tsinghua.edu.cn/simple
安装完成后验证一下:
bash复制python -c "import pymysql; print(pymysql.__version__)"
正常会输出一个版本号,比如1.1.0。如果这里抛了ModuleNotFoundError,大概率是pip和python不是同一个环境。这种情况在macOS和Linux上很常见,两个python在系统中并存,pip装给了其中一个,命令行跑的是另一个。解决办法是用python -m pip install pymysql,保证装的模块和执行的解释器是同一个环境。
2.2 准备数据库和表结构
在写Python代码之前,先确保MySQL服务本身是能连上的。你可以用任何你习惯的客户端,先建一个库和一张表,用来做后面的测试。我这里用MySQL命令行演示:
sql复制CREATE DATABASE IF NOT EXISTS demo_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE demo_db;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
注意字符集一定要用utf8mb4,而不是utf8。utf8在MySQL里实际上是utf8mb3,存不了emoji,也容易在一些生僻字上报错。这个细节在你第一次处理用户输入带表情符号的数据时就会体现出来。
2.3 第一个可运行Demo
环境都准备好了,写一段最小可运行demo:
python复制import pymysql
# 建立连接
conn = pymysql.connect(
host='127.0.0.1',
port=3306,
user='root',
password='your_password',
database='demo_db'
)
try:
# 创建游标
cursor = conn.cursor()
# 执行SQL
cursor.execute("SELECT VERSION() AS version")
row = cursor.fetchone()
# 获取结果
print("MySQL版本:", row[0])
# 关闭游标
cursor.close()
finally:
# 关闭连接
conn.close()
这段代码虽然简单,但它包含了所有PyMySQL程序的基本骨架:建立连接、创建游标、执行SQL、获取结果、关闭游标、关闭连接。
我见过不少新手只写连接和查询,不关闭连接,在脚本里跑一次没问题,但如果是循环查询,很快会报“Too many connections”的错误。MySQL服务器的连接数是有限的,你用完了不释放,服务器就会把连接断掉或者拒绝新连接。这就是为什么close()很重要。
3. 连接与游标:两个核心对象的使用逻辑
理解了基础骨架,接下来把两个核心对象掰开揉碎讲讲,因为后续所有操作,都是围绕它们展开的。
3.1 connect()参数逐个拆解
pymysql.connect()是最重要的函数,没有之一。一堆参数让你眼花缭乱,其实很多都有默认值,真正要关心的是这几个:
host:数据库服务器地址。本地是127.0.0.1或localhost。跨环境部署时,这一步最容易出错,比如服务器上的MySQL只监听内网IP,你用公网IP去连就连不上。port:MySQL端口,默认3306。如果你改了端口或者用Docker映射了其他端口,这里要对应改。user和password:数据库账号密码。必须确认账号有访问指定库的权限,否则就会出现2848错误码挂在Access denied上。database:要操作的默认数据库。可以不填,然后在每次SQL里写全库名,但建议填上,让后面代码简洁。charset:指定字符集,强烈建议直接填utf8mb4。注意是utf8mb4,不是utf-8,Python里的连字符在MySQL这边是不认的。autocommit:是否自动提交事务。默认False,这意味着你在执行INSERT、UPDATE、DELETE之后必须手动conn.commit(),否则数据不会真正落库。后面专门有一章讲事务,这里先记着。connect_timeout:连接超时时间(秒)。默认10秒,可根据网络环境调整。在跨机房、跨网络访问数据库时,这个参数能帮你快速失败,而不是让程序一直卡住。
涉及到连接参数,有一个经验值得分享:所有参数能显式写就显式写,不要依赖默认值。比如port,本地默认3306,但到了公司环境或其他云数据库场景很可能不是3306,如果代码里没写,排查起来很费劲。
3.2 游标到底是怎么工作的
游标(cursor)这个词对新手来说有点抽象。你可以把它理解成一个文件读取器里的指针,甚至一个“迭代器”。数据库执行完整条SQL后,结果集会放在MySQL服务端内存里,游标就是客户端这边用来逐行取数据的通道。
python复制cursor = conn.cursor()
cursor.execute("SELECT id, name FROM users")
execute()执行完之后,你并没有立即拿到所有数据。数据还在MySQL服务端,游标指向结果集的起始位置,你调用fetchone()是取一行,游标就自动移到下一行;调用fetchall()是一次性把剩余所有行取出来。
有个常见误区:有人以为execute()的返回值是查询结果,其实不是。execute()返回的是受影响的行数,比如SELECT返回的是查出了多少行,UPDATE返回的是影响了多少行。
3.3 fetch家族怎么选
三个常用方法:
fetchone():取一行,返回元组(或字典,取决于游标类型)。简洁,适合只需要一条记录的场景。fetchmany(size):取指定行数,返回一个列表。适合分页、分批流式处理大数据集。fetchall():取所有行,返回列表。数据量小时方便,但千万注意数据量巨大时一次性全取会撑爆内存。
| 方法 | 返回值 | 适用场景 |
|---|---|---|
| fetchone() | 单个元组/字典 | 查询单条记录 |
| fetchmany(n) | 列表,最多n条 | 大数据分批处理 |
| fetchall() | 列表,全部记录 | 结果集较小时 |
我的建议是:如果明确只要一条数据,用fetchone();数据量可能在几百条以上,用fetchmany()循环取;只有数据量可控(比如几十行)才直接用fetchall()。
这里还有个大坑:如果你用了fetchall()或fetchone()但没把结果集全部取完,又在同一个游标上执行新的SQL,会报commands out of sync错误。处理方法是:要么把结果取干净,要么干脆另开一个游标,不要复用一个没取完的游标。
4. CRUD实战:插入、查询、更新、删除全流程
骨架搭好、对象明白之后,就进入最核心的部分:对一个表完整做一遍增删改查。我会把所有代码串成一个完整的场景。
4.1 插入与批量插入
先看单条插入:
python复制import pymysql
conn = pymysql.connect(
host='127.0.0.1',
port=3306,
user='root',
password='your_password',
database='demo_db',
charset='utf8mb4'
)
try:
with conn.cursor() as cursor:
sql = "INSERT INTO users (name, email) VALUES (%s, %s)"
cursor.execute(sql, ("张三", "zhangsan@example.com"))
# 注意:execute只执行,不提交
conn.commit()
# 获取自增ID
print("新插入用户ID:", cursor.lastrowid)
finally:
conn.close()
几个细节说一下:
cursor.lastrowid可以拿到自增ID。在很多业务场景里,插入完要立刻拿着这个ID去做关联操作,这个属性就很有用。with conn.cursor() as cursor这段上下文管理器帮我们管住了cursor.close()。但是!它不会自动commit,也不负责conn.close()。连接的对象和上下文管理器管的是游标,不是事务。- commit写在
with外面,是因为它要在所有SQL执行完成后统一提交。
批量插入用executemany,性能会有质的提升:
python复制data = [
("李四", "lisi@example.com"),
("王五", "wangwu@example.com"),
("赵六", "zhaoliu@example.com"),
]
with conn.cursor() as cursor:
sql = "INSERT INTO users (name, email) VALUES (%s, %s)"
cursor.executemany(sql, data)
conn.commit()
executemany内部会对SQL进行批量化拼接发送,比循环execute快很多。我测试过一万条记录的插入,循环execute()耗时几十秒,executemany()只需要一两秒,差距非常明显。
4.2 条件查询与结果处理
查询的典型写法:
python复制with conn.cursor() as cursor:
sql = "SELECT id, name, email, created_at FROM users WHERE id > %s ORDER BY id DESC LIMIT %s"
cursor.execute(sql, (0, 10))
rows = cursor.fetchall()
for row in rows:
# row是个元组,按下标访问
print(row[0], row[1], row[2], row[3])
这里返回的rows是一个列表,里面每个元素是一个元组。元组访问起来不够直观,字段多了容易搞混。解决办法是使用字典游标:
python复制from pymysql.cursors import DictCursor
with conn.cursor(DictCursor) as cursor:
sql = "SELECT id, name, email FROM users WHERE id > %s"
cursor.execute(sql, (0,))
rows = cursor.fetchall()
for row in rows:
# 现在可以按字段名访问
print(row["id"], row["name"], row["email"])
用DictCursor之后,每一行都是字典,代码可读性提升很多。在真实项目里,我几乎默认用DictCursor,只有极少数对性能吹毛求疵的场景才用默认元组游标。
另外提醒一点:LIMIT后面不能用参数化占位符,至少PyMySQL是不允许LIMIT %s的。参数化的位置只能替换值,不能替换表名、列名、关键字。LIMIT这种需要传整数的,要么直接拼在SQL里并自己确保是纯数字,要么用int()转换后再格式化进SQL。
4.3 更新与删除的边界意识
更新操作:
python复制with conn.cursor() as cursor:
sql = "UPDATE users SET email = %s WHERE id = %s"
affected = cursor.execute(sql, ("newemail@example.com", 1))
print("影响行数:", affected)
conn.commit()
execute()返回的是影响行数。有个很微妙的点:如果UPDATE设置的值和原值一样,MySQL可能不把它算作影响行数,所以affected可能是0,但SQL本身执行成功了。
删除操作类似:
python复制with conn.cursor() as cursor:
sql = "DELETE FROM users WHERE id = %s"
affected = cursor.execute(sql, (999,))
conn.commit()
删除和更新最容易翻车的点是一样的:忘记写WHERE,或者WHERE写得不精确。一旦漏了WHERE,那就是全表更新、全表删除,在测试环境还好,在生产环境跑一下,后果很严重。我的习惯是:执行UPDATE和DELETE之前,先跑一遍同条件的SELECT,确认查出来的行数符合预期,再改动作为DELETE或UPDATE。
另外,删除之前如果表和其他表有外键关联,可能触发外键约束报错。比如用户有订单记录的时候,直接删用户会外键失败,代码里要做好异常捕获。
5. 参数化查询一定要从一开始就养成习惯
前面示例里,每个SQL都用了%s占位符。这个习惯值得强调。因为很多人贪图方便,直接拼字符串做SQL,我见过的最严重后果就是把整个库都“脱”了。
5.1 一个SQL注入的经典事故
想象一个登录页面,代码是这么写的:
python复制sql = f"SELECT * FROM users WHERE email = '{email}' AND password = '{password}'"
用户输入一个奇怪的邮箱:
code复制email = "admin@example.com' OR '1'='1"
password = "anything"
拼出来的SQL变成了:
sql复制SELECT * FROM users WHERE email = 'admin@example.com' OR '1'='1' AND password = 'anything'
这个OR '1'='1'恒为真,整条查询直接把第一个用户(很可能是管理员)的信息查出来了。攻击者不需要知道密码,只需要在输入框里玩字符串游戏,就能绕过登录校验。
这还只是最简单的注入。更严重的情况,攻击者可以输入'; DROP TABLE users; --这样的内容,把整张表删掉。全世界的数据库中招案例,绝大部分都是从“就拼一下字符串”开始的。
5.2 %s占位符的正确姿势
PyMySQL的参数化做法是:
python复制sql = "SELECT * FROM users WHERE email = %s AND password = %s"
cursor.execute(sql, (email, password))
注意两点:
- 占位符是
%s,不管字段是字符串、数字还是日期,都用%s,PyMySQL会自动帮你做类型转换和转义。不需要用%d、%f之类的格式化符号,那些是Python字符串格式化的东西,不是PyMySQL的。 - 第二个参数要传一个序列(元组、列表都可以)。只有一个参数时也要传元组,比如
(email,),注意那个逗号不能丢。丢了这个逗号,Python会把它当成一个普通字符串,和%s的数量对不上,直接报TypeError: not all arguments converted during string formatting。
在代码里养成的整洁写法:
python复制with conn.cursor(DictCursor) as cursor:
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
user = cursor.fetchone()
5.3 参数化覆盖不了的特殊场景
参数化什么都好,但它只能用在“值”的位置。下面这些场景它处理不了:
- 表名、列名不能参数化。比如动态排序的列名,你得用一个白名单校验后拼接。
- LIMIT、OFFSET的整数,只能手动转换后拼接。
- 动态SQL的语句片段,比如某些复杂查询的条件组装。
应对方式很简单:凡是这些位置,要么通过白名单强制校验,要么用int()强制类型转换后再拼。比如:
python复制allowed_columns = {"id", "name", "email", "created_at"}
if order_by not in allowed_columns:
raise ValueError("非法排序字段")
sql = f"SELECT * FROM users ORDER BY {order_by} DESC"
这样做的核心思路是:用户输入的数据永远不能直接进入SQL语句结构,只能是值。结构部分必须由开发者自己控制。
6. 事务边界和autocommit:什么时候该提交、什么时候该回滚
这一段是很多教程容易忽略、但实际工作中最容易出事的重点。前面反复提到commit,这里就展开讲透。
6.1 autocommit与提交时机
PyMySQL默认autocommit=False。这意味着每条SQL执行之后,改动还没真正写进磁盘,而是停留在当前事务的“待提交”状态。只有调用了conn.commit(),改动才会永久生效;如果不调用,程序退出的时候改动就丢了。
这个设计初看很麻烦,其实非常重要。数据库操作往往不是一条SQL单独存在的,而是一组SQL需要保持一致。要么一起成功,要么一起失败。这个“一起”的机制就叫事务。
举个例子,假设建了一张账户表:
sql复制CREATE TABLE accounts (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
balance DECIMAL(10, 2) NOT NULL DEFAULT 0
);
现在用户A要把100元转给用户B。这个动作包含两条SQL:
sql复制UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;
如果第一条执行成功,第二条执行失败(比如网络断开、账户不存在),用户A的钱就凭空消失了。事务机制能保证这两条要么都成功,要么都不生效,中间状态不会持久化。
6.2 转账场景的完整演示
python复制import pymysql
conn = pymysql.connect(
host='127.0.0.1',
user='root',
password='your_password',
database='demo_db',
charset='utf8mb4'
)
try:
with conn.cursor() as cursor:
# 检查用户1余额是否足够
cursor.execute("SELECT balance FROM accounts WHERE user_id = %s FOR UPDATE", (1,))
row = cursor.fetchone()
if not row or row[0] < 100:
raise Exception("余额不足")
# 扣款
cursor.execute("UPDATE accounts SET balance = balance - 100 WHERE user_id = %s", (1,))
# 加款
cursor.execute("UPDATE accounts SET balance = balance + 100 WHERE user_id = %s", (2,))
# 所有SQL都成功了,提交
conn.commit()
print("转账成功")
except Exception as e:
# 任何一步出错,回滚所有改动
conn.rollback()
print("转账失败,已回滚:", e)
finally:
conn.close()
这段代码有几个设计点值得讲:
FOR UPDATE给用户1的那行记录加了行级锁。这是为了防止两个并发请求同时读到“余额足够”然后都执行扣款,导致超扣。在高并发场景下,这种锁很关键。try...except把一批SQL包起来,哪里出错都统一rollback()。如果没这个回滚,第一条扣款可能已经执行成功了,后面出错后就会被提交,破坏账户数据。- 成功路径上,所有SQL执行完才
commit(),保证原子性。
6.3 异常回滚的完整写法
基于上面的例子,凝练出所有事务操作的通用模板:
python复制conn = pymysql.connect(host='127.0.0.1', user='root', password='your_password', database='demo_db', charset='utf8mb4')
try:
# 在这里执行一组数据库操作
with conn.cursor() as cursor:
cursor.execute("...")
cursor.execute("...")
# 全部成功则提交
conn.commit()
except Exception:
# 出现异常则回滚
conn.rollback()
raise
finally:
conn.close()
有人可能想问:with conn.cursor()这个上下文管理器的__exit__会不会帮我们自动提交或回滚?答案是不会。游标上下文管理器只负责关闭游标,不碰事务。我第一次跑通示例时也以为Python的with是万能的,结果数据没提交出去,白折腾了半天。
7. 高频报错与排查链路:遇到问题不要慌
很多新手遇到报错的第一反应是怀疑代码写错了,但很多时候问题出在连接层、权限层或配置层。这里把最常见的几个错误分类整理一下,并给出排查链路。
7.1 连接层报错与排查
错误1:Can't connect to MySQL server on '127.0.0.1' (2003)
这个错误的意思是客户端根本连不到MySQL端口。排查链路:
- 先确认MySQL服务是否启动。Linux上用
systemctl status mysql,Windows上到服务管理器看。 - 确认端口对不对。如果你本地MySQL用的不是3306,代码里就要改port。
- 确认防火墙或安全组是否放行了端口。本机测试还好,跨服务器部署时这是最常见的原因。
错误2:Access denied for user 'root'@'localhost' (1045)
账号密码错误或者权限不对。排查链路:
- 先用命令行或其他客户端尝试登录:
mysql -u root -p。如果命令行也登不上,问题在账号密码本身。 - 如果命令行能登录,仔细检查Python代码里有没有打错密码,空格算不算进去了。
- MySQL的账号是“用户名+来源主机”绑定的。如果代码里连接的是远程数据库,要注意账号是否允许从你当前IP访问。很多云数据库默认只允许白名单IP连接,这也会导致1045。
排查这类问题最好的入口是先用命令行工具(如mysql客户端、DBeaver、Navicat)连一次数据库。如果图形工具能连上而代码连不上,那肯定是代码参数问题;如果图形工具也连不上,那就是服务端、网络或防火墙的锅。这样能快速缩小排查范围。
错误3:Unknown database 'demo_db' (1049)
数据库不存在。要么是库名打错了,要么是还没执行建库语句。去服务端用SHOW DATABASES;看看到底有哪些库。
7.2 SQL执行层报错与排查
错误4:Lost connection to MySQL server during query (2013)
查询执行过程中连接断开。可能的原因:
max_allowed_packet太小,查询或插入的数据量太大。可以查看并调大:sql复制SHOW VARIABLES LIKE 'max_allowed_packet'; SET GLOBAL max_allowed_packet = 64 * 1024 * 1024;- 连接空闲太久被服务端断开。MySQL默认
wait_timeout是8小时,但如果前边有VIP或负载均衡设备,可能几分钟没流量就帮你断了。 - 网络本身不稳定,比如跨机房访问数据库。
错误5:commands out of sync; you can't run this command now (2014)
这个前面提过,同一个游标里还有没取完的结果集,就执行了下一个查询。遇到以后去代码里检查:是不是查询之后忘了fetchall(),或者用了fetchone()又没把剩余的取掉。最省事的办法是取一行就换个游标,或者复用前先fetchall()清空。
7.3 三种容易混淆的OperationalError
PyMySQL统一用pymysql.err.OperationalError来抛一部分连接和操作类的错误,新手经常分不清。但注意看错误信息里的数字:
| 错误码 | 错误信息关键片段 | 常见原因 |
|---|---|---|
| 2003 | Can't connect to MySQL server | 连接层,服务没起或网络不通 |
| 1045 | Access denied | 认证失败,账号密码或权限问题 |
| 1049 | Unknown database | 指定库不存在 |
| 2013 | Lost connection during query | 连接中断,可能是包太大或空闲超时 |
| 2014 | commands out of sync | 游标结果集未取干净 |
| 1062 | Duplicate entry | 唯一键冲突,插入重复数据 |
1062这条特别值得一提,它的错误信息长这样:
code复制pymysql.err.IntegrityError: (1062, "Duplicate entry 'zhangsan@example.com' for key 'users.email'")
当你的表里有UNIQUE约束,插入重复数据就会报这个。处理方式有两种:一是先查询再插入(但并发下仍有竞争风险),二是用INSERT ... ON DUPLICATE KEY UPDATE或者INSERT IGNORE,在SQL层面处理冲突。
我遇到这类报错,一贯的排查套路是:
- 先把错误码记下来,比如1045、2003、2013。
- 用命令行客户端模拟一次同样的操作。如果命令行成功而Python失败,问题出在Python代码参数上;如果命令行也失败,问题出在服务端配置、权限或网络上。
- 看完整的异常堆栈,不要只看最后一行。PyMySQL的异常会包含当前执行的SQL和参数,这些信息往往能直接暴露问题。
8. 真实项目里的几条实践心得
前面讲的都是基础能力,这一章补充一些真实项目里慢慢才能积累出来的经验。这些内容教科书里不太讲,但早晚会遇到。
8.1 字符集与连接参数的经验
首先,charset='utf8mb4'这个参数几乎是必须写的。见过有人写utf8,结果插入😀这种emoji直接报错:Incorrect string value: '\xF0\x9F\x98\x80' for column。虽然表结构也要是utf8mb4才能彻底解决,但连接参数不写对的话,即使表是utf8mb4,客户端和服务端之间转换也可能出问题。所以两端都要统一为utf8mb4。
其次,connect_timeout建议设置,不然在服务器网络异常时,默认的10秒可能会耽误很多时间。我见过一个内部工具,数据库服务器偶尔不可达,每次都卡在连接上,后来加了connect_timeout=5之后,程序会快速失败并走重试逻辑,体验完全不同。
8.2 结果集处理与游标选择的经验
默认游标返回元组,用row[0]取值的写法在列少时还行,列一多就容易串。提取几个月的经验,我的建议:
- 线上代码默认用
DictCursor,可读性优先。 - 只要数据可能会超过一两百行,就不要直接
fetchall(),用fetchmany()或者循环fetchone()。 - 如果需要处理非常大结果集,PyMySQL还有一个
SSCursor(服务端游标),它不会一次性把结果全部拉到客户端,而是逐行取。但SSCursor有个硬限制,就是游标存续期间不能在同一个连接上执行其他SQL。初学者暂时不用太在意,知道这个名字即可。
我处理几万条数据导出CSV,用的就是DictCursor加fetchmany(1000),内存占用非常稳定,速度也很快。
8.3 稳定性和性能的进阶提醒
到项目上线阶段,还要考虑几个事情:
- 连接复用:不要每次数据库操作都创建新连接。短连接开销很大,因为MySQL每次连接都要经历TCP握手、认证、权限检查。正确做法是复用连接,或者用连接池。
- 连接池:虽然PyMySQL官方没有带连接池,但你可以用
dbutils.pooled_db.PooledDB,或者用SQLAlchemy的create_engine来管理底层连接池。连接池能有效控制并发连接数,避免高并发时把数据库打挂。 - 索引的重要性:PyMySQL本身不会帮你优化SQL。当查询变慢,第一反应是看有没有走索引。用
EXPLAIN SELECT ...检查执行计划,看看是不是全表扫描。很多性能问题不是Python代码的锅,而是SQL没吃到索引。 - 不要在长事务里穿插大量耗时的非数据库操作,比如HTTP请求、大文件读写。事务期间数据库连接和锁会被占用,时间长了容易超时,还会积累大量undo日志。
写在最后:一次线上事故的复盘
最后分享一个真实教训。有一年我在服务器上部署了一个定时脚本,用来同步两个数据库的数据。脚本在本地测试一切正常,部署上去之后却频繁报Lost connection。排查了很久才发现,两个库不在同一个机房,而且数据量一大,单条SQL的执行时间超过了中间网络设备设置的静默超时阈值,连接被静默掐断。
后来我的处理方式变了:把大批量同步拆成小批次事务,每个事务控制在合理范围内,执行完立即提交并重新建立连接;脚本里加入断线重连机制,捕获2006/2013错误后自动重试。从那以后,我再也不在一条SQL里塞几万条数据的更新,也不在同一个连接上跑长时间多表关联查询了。
PyMySQL本身不复杂,它就是一个把SQL搬到Python里的翻译工具。真正决定你会不会用的,是能不能理解连接生命周期、游标状态和事务边界这三件事。把这几个基础吃透,再遇到什么报错都不会慌——无非是连接断了、结果集没取完、事务没提交这三类问题在变着花样提醒你而已。
