前阵子帮一个刚转 Python 后端的朋友梳理项目代码,发现他在业务里每次请求都用 pymysql 重新创建一次数据库连接,高峰期时 MySQL 日志里绝大多数连接状态都是 TIME_WAIT。这种写法在本地跑 demo 感觉不到问题,放到线上,连接数直接被打满,增删改查本身倒是没写错,问题全出在连接生命周期和资源复用上。于是我把 Python 操作 MySQL 的完整链路梳理了一遍,覆盖增删改查、事务、连接池、性能优化这几个后端开发绕不开的核心点,内容不挑框架,示例统一用 PyMySQL,但思路和坑在生产环境中都一样适用。
1. 先理清三件事:驱动选择、环境准备和基础连接
1.1 PyMySQL 为什么是大多数人默认的答案
我先说结论:如果一个项目没有引入 SQLAlchemy,直接用 Python 操作 MySQL,PyMySQL 基本是默认选择。MySQL 官方提供过 mysql-connector-python 驱动,但实测下来,PyMySQL 胜在安装简单,纯 Python 实现,不需要编译原生扩展,Windows 和 Linux 上都是直接pip install pymysql就能用。社区资料也多,随便遇到一个报错粘贴到搜索引擎都能找到案例。
另一个隐藏理由是 PyMySQL 实现了 DB-API 2.0 规范。这意味着游标、execute、fetchone、commit、rollback 这些 API 都是统一且可预测的,以后如果项目切到 PostgreSQL,只要把连接部分换成 psycopg2,业务代码的改动成本极低。这个规范性的收益在项目迭代半年之后会越来越明显。
当然,纯 Python 驱动在执行复杂 SQL 时速度略慢于 C 扩展驱动,但这个差距在面对真实网络耗时的时候几乎可以忽略。真正决定你数据层性能的不是驱动本身,而是连接管理、SQL 写法、索引设计和事务控制,这些才是这篇文章要展开的重点。
1.2 驱动和 ORM 的分工:什么时候直接上 SQLAlchemy
一个常见误区是“用了 ORM 就不需要学原生 SQL”。我的建议是:增删改查和事务这些概念,先学会用原生 SQL 完整写一遍,再考虑 ORM。否则一个项目下来,你调用的orders.filter(...).join(...)底层到底发生了什么完全不清楚,一旦线上遇到慢 SQL、死锁、连接耗尽,排查起来寸步难行。
如果你确实要上 SQLAlchemy,也要清楚它只是一个数据访问层,底层仍然需要调用 PyMySQL 之类的驱动。连接串里通常写成mysql+pymysql://user:pass@host:3306/dbname,这里的pymysql就点明了底层驱动是谁。所以这篇文章全部用 PyMySQL 做原生示例,先把概念打通,之后你再看 ORM 的源码和文档会轻松很多。
1.3 建库建表和连接的基础代码
先把最基础的环境准备说清楚。假设你已经装好了 Python 和 MySQL,并且本地能启动 MySQL 服务。我们现在建一个用户信息表,用于后面所有增删改查演示。
CREATE DATABASE IF NOT EXISTS demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE demo; CREATE TABLE user_profile ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, email VARCHAR(128) NOT NULL, age INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB;建表这里有一个需要特别注意的点:字符集要选utf8mb4,不是utf8。MySQL 的utf8最多只能存 3 字节字符,用户随便填一个 emoji 或者生僻字就会直接报错,这是建表阶段就要避开的坑。排序规则用utf8mb4_unicode_ci足够覆盖大部分场景。
连接的基础代码很简单,用pymysql.connect拿到连接对象,再调用cursor()创建游标。游标读出来默认是元组,可以设置DictCursor让它返回字典,调试时一眼能看懂字段名。
import pymysql conn = pymysql.connect( host="127.0.0.1", port=3306, user="root", password="your_password", database="demo", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, ) cursor = conn.cursor() cursor.execute("SELECT 1") print(cursor.fetchone())跑通这段代码说明环境和驱动都没问题。但从这一步开始,我希望你给自己定一条纪律:不要在每个函数里随意创建连接,连接统一交给后面的连接池管理,否则代码里到处都是建立连接和关闭连接的样板代码,出问题时连排查点都找不到。
2. 增删改查的完整写法与一个必须养成的习惯
2.1 CRUD 的四段式代码
在 Python 里操作 MySQL 的增删改查,本质上就是四个 SQL 动词加上 Python 的 execute 调用。但先给结论:任何情况下都不要用字符串拼接 SQL。
先看最简单的查询:
cursor.execute( "SELECT id, username, email, age FROM user_profile WHERE age > %s ORDER BY id DESC LIMIT 10", (18,) ) rows = cursor.fetchall()对应插入:
cursor.execute( "INSERT INTO user_profile (username, email, age) VALUES (%s, %s, %s)", ("alice", "alice@example.com", 25), ) conn.commit()这里有一件很多人第一次写都会踩坑的事:执行 INSERT 语句之后必须显式调用conn.commit(),数据才会真正写入。PyMySQL 默认开启了事务,写操作不 commit,当前连接断开时数据会自动回滚,你在另一个客户端里查不到这条记录。这个“不 commit 就查不到”的体验,几乎每个初学者都至少遇到过一次。
更新和删除是同样的套路:change SQL 语句,然后 commit。注意 DELETE 语句执行后也要 commit,否则其他连接仍然能看到已经被标记删除的数据。如果是大批量删除或者更新,最好先 SELECT 确认一下范围,尤其是生产环境,先查出即将影响的行数再执行,能避免不可恢复的误删。
2.2 参数化查询背后的原理
上面写法里没有用 f-string 拼接 SQL,而是用%s占位符,然后把参数作为 execute 的第二个参数传入。这个举动不是代码洁癖,而是防 SQL 注入的关键屏障。
如果写成这样:
user_input = "alice'; DROP TABLE user_profile; --" sql = f"SELECT * FROM user_profile WHERE username = '{user_input}'" cursor.execute(sql)用户输入可以直接改变整个 SQL 的语义,数据库里最关键的表被删除也只是一瞬间的事。参数化查询的理念是让 MySQL 服务端把参数当作一个“值”来处理,而不是当作 SQL 片段去解析,从机制上杜绝了这类注入问题。
用生活化的类比:拼接 SQL 就像让一个自动回复机器人直接念出用户发来的每个字,用户发来“你是谁”,它就回“你是谁”;参数化查询则是把用户内容装进一个密封信封,收件人只把内容当作数据来读取,不会执行信封上写着的任何指令。所以不管前端有没有做输入校验,后端这条参数化底线必须守住。
2.3 更新、排序、limit 和几个高频细节
更新操作里有一个容易忽略的细节:如果 UPDATE 语句中使用了子查询,MySQL 某些版本不允许直接对同一张表进行子查询更新。比如执行下面这条 SQL 会报错:
UPDATE user_profile SET age = (SELECT MAX(age) FROM user_profile) WHERE id = 1;原因是要更新的表同时出现在子查询中,MySQL 为了避免不确定性会直接拒绝。处理方式是把子查询结果包一层临时表:
UPDATE user_profile SET age = (SELECT m.max_age FROM (SELECT MAX(age) AS max_age FROM user_profile) m) WHERE id = 1;排序和 limit 方面也有两个实操经验。分页查询不要用偏移量特别大的写法,比如LIMIT 100000, 20,MySQL 依然会扫描前 100000 行数据再丢弃,数据量大时性能会断崖式下跌。更好的方案是记录上一页最后一条数据的 ID,用WHERE id > last_id ORDER BY id LIMIT 20这种“向后翻页”的方式。如果必须支持任意跳页,再考虑用覆盖索引或者其他中间件方案。
还有一个容易被忽略的坑是sort排序的字段如果没建索引,数据量大时 MySQL 会使用 filesort,在临时文件里排序,速度很慢。ORDER BY 的字段最好能和 WHERE 条件一起命中联合索引,这个在后面的性能优化章节再展开。
3. 事务不是“自动的”:提交时机、隔离级别与分布式事务边界
3.1 一次扣库存为什么需要六行代码
最常见的业务场景:用户下单,要扣库存、生成订单、记录流水。三个操作必须“要么都成功,要么都失败”。如果你把它们拆成三条独立执行并且每一条都 commit,前两条成功、第三条失败时,库存和订单就永远不一致了,后续对账和对账都救不回来。
事务就是解决这个问题的。PyMySQL 默认autocommit=False,所以从你执行第一条写操作开始,MySQL 就已经在一个隐式事务里了。你之前的 INSERT、UPDATE 在没有 commit 之前,对其他连接是不可见的。最后执行conn.commit(),整个事务才真正落盘;如果中途发生异常,执行conn.rollback()回滚到事务开始之前的状态。
3.2 事务代码的写法与几个踩坑经验
推荐的事务写法是 try/except 包住所有 SQL 操作,异常时 rollback,正常时 commit,finally 里关闭游标。不要在一个接口里让事务跨秒级执行,更不要在事务进行中调用外部 HTTP 接口,因为事务期间持续持有的行锁、间隙锁会让后续并发请求全部进入锁等待状态。线上经常看到的“数据库突然变慢”,很多不是 SQL 本身慢,而是事务把锁持有时间拉长了,后面的请求全在排队。
来看具体代码:
def transfer_funds(conn, from_account, to_account, amount): cursor = conn.cursor() try: cursor.execute( "UPDATE account SET balance = balance - %s WHERE id = %s", (amount, from_account) ) if cursor.rowcount == 0: raise RuntimeError("转出账户不存在") cursor.execute( "UPDATE account SET balance = balance + %s WHERE id = %s", (amount, to_account) ) if cursor.rowcount == 0: raise RuntimeError("转入账户不存在") conn.commit() except Exception: conn.rollback() raise finally: cursor.close()这里特意检查了rowcount,这是实际经验中很容易漏掉的点。UPDATE 一个不存在的 id 时 MySQL 不会报错,它只会影响 0 行,如果你没有这个判断,业务上就会表现为“提示转账成功,但实际上转了个寂寞”。这类 bug 在单元测试里很难触发,因为测试数据总是存在的,真上了线才发现问题就晚了。
另外一个坑是不要在事务里混用 SELECT 和写操作时忽略返回值。SELECT 的结果如果不 check,可能会继续基于旧数据做更新,导致覆盖已提交的修改。很多更新丢失问题,根源就是“先读到旧值,然后拿着旧值去覆盖”。
3.3 隔离级别:脏读、不可重复读、幻读的距离
很多人分不清事务的四种隔离级别,这里用一个核心逻辑来记:隔离级别越严格,数据一致性越好,但并发能力越低。
- READ UNCOMMITTED:可以读到其他事务未提交的数据,也就是脏读。事务 A 改了数据但不 commit,事务 B 就能读到,如果事务 A 最终回滚,事务 B 读到的就是一个从来没存在过的“假数据”。
- READ COMMITTED:只能读到已提交的数据,解决了脏读。但同一个事务里两次 SELECT 可能读到不同的已提交结果,这就是不可重复读。
- REPEATABLE READ:MySQL 的默认隔离级别。同一事务中多次读取同一范围数据时结果一致,理论上可能出现幻读,也就是其他事务插入新行后,你再次查询多了一行。不过 InnoDB 通过 MVCC 和间隙锁已经能规避大部分幻读场景,所以 MySQL 的 RR 级别在实际使用中比理论上更安全。
- SERIALIZABLE:全部串行化,不会有任何并发问题,但性能最差,生产环境很少使用。
实际开发中,如果用默认的 REPEATABLE READ,绝大多数业务都没问题。不要把隔离级别当成一个需要频繁折腾的东西,它更多是排查问题时的背景知识。当你在一个事务里读两次数据发现结果不一样,首先确认当前事务的隔离级别,再推断是哪种并发读问题,这样方向才不会跑偏。
3.4 提到分布式事务,先分清问题边界
分布式事务是后端圈子里出现频率很高的词,很多新手一听到“分布式事务”就紧张。其实你要先分清:如果你的系统还是单库单服务,那就只需要关心本地事务;只有当调用多个服务、访问多个数据库、需要保证跨资源一致性时,才需要考虑分布式事务,也就是常见的 TCC、SAGA、基于消息的最终一致性等方案。
在 Python 后端项目里最常见的演化路径是:一开始一个服务一个数据库,本地事务解决一切;后来拆服务了,每个服务有自己的库,订单服务和库存服务各自独立提交,才出现分布式一致性问题。我的建议是不要过早引入分布式事务框架,先看业务能否用消息队列把链路改成最终一致,如果不能,再考虑 Seata 之类的方案。很多项目嘴里说的“分布式事务”,其实只是在代码里串行执行了多个本地事务,并没有真正的分布式资源竞争,这种场景根本不需要上框架。
4. 连接池:为什么每次请求新建连接是一种“慢性事故”
4.1 新建连接的真实代价
文章开头提到朋友的项目,每次请求都新建连接。很多人觉得“数据库连接不就是建立一个 TCP 连接吗”,实际情况比想象中复杂得多:
- TCP 三次握手,至少一个 RTT 的开销;
- MySQL 服务端认证、权限校验,需要读取用户表和权限信息;
- 如果启用 SSL 加密传输,还要进行 TLS 握手;
- 连接断开时还有四次挥手。
也就是说,一个连接从建立到销毁,消耗的时间很可能比 SQL 本身还长。高并发下,MySQL 默认的最大连接数通常只有几百,一次请求一个连接,如果建立连接的速度跟不上释放速度,服务端连接数就会被打满,后面的请求开始排队,表现就是“数据库突然连不上了”。
连接池的核心思想是复用:预先创建一批连接放在池子里,每次需要时从池子里取一个,用完之后归还,而不是销毁。这样反复握手和认证的开销被彻底去掉,单个请求的数据库耗时能明显降下来。
4.2 手写一个极简连接池,先理解原理再上生产方案
实际项目中我不建议手写连接池去替代成熟方案,但为了理解原理,可以先看一个简化实现的骨架:
import queue import pymysql class SimplePool: def __init__(self, size=5, **conn_kwargs): self._queue = queue.Queue(size) self._conn_kwargs = conn_kwargs for _ in range(size): self._queue.put(pymysql.connect(**conn_kwargs)) def acquire(self): return self._queue.get() def release(self, conn): self._queue.put(conn)这个版本没有处理连接失效、超时、线程安全等问题,但在思路上已经足够说明连接池的本质:池子里存的是一个一个已经建好的连接对象,业务只是借用和归还。理解了这一点,再看 DBUtils 之类的库,就容易明白它到底替你解决了哪些边界问题。
4.3 DBUtils 的 PooledDB 是 Python 生态里的标准答案
项目里直接使用 DBUtils 提供的 PooledDB 更靠谱。它把很多边界条件都考虑好了,比如连接空闲超时后自动重建、取不到连接时是否阻塞等待等。
from dbutils.pooled_db import PooledDB import pymysql pool = PooledDB( creator=pymysql, maxconnections=20, mincached=2, maxcached=10, maxshared=0, blocking=True, maxusage=None, setsession=[], ping=0, host="127.0.0.1", port=3306, user="root", password="your_password", database="demo", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, )拿到连接后的使用方式:
conn = pool.connection() try: with conn.cursor() as cursor: cursor.execute("SELECT ...") rows = cursor.fetchall() conn.commit() finally: conn.close()注意这里conn.close()并不是真的断开连接,而是把连接归还到池子里,所以一定不能漏。这个 close 语义和原生 pymysql 的 close 语义不一样,PooledDB 做了重写,很多人从新手阶段过渡过来时都会在这个细节上懵一阵。如果你的代码里finally漏写了 close,等于从池子里借了连接不还,池子很快就会耗尽。
4.4 连接池参数怎么调,先说原理再给建议
下面是一份 PooledDB 核心参数对照表,方便日常查阅:
| 参数 | 作用 | 建议 |
|---|---|---|
| maxconnections | 连接池最大连接数 | 通常设为应用并发上限除以单请求 DB 次数再乘一个冗余系数 |
| mincached | 初始化时预先创建的空闲连接数 | 2-5 即可,不要过多 |
| maxcached | 空闲连接上限,超过后多余连接会关闭 | 与 mincached 配合,保持适度 |
| maxshared | 允许共享的连接数 | PyMySQL 场景建议设为 0,连接不能跨线程共享 |
| blocking | 连接耗尽时是否阻塞等待 | 建议 True,宁可等待也不要直接抛异常 |
| maxusage | 单个连接最大复用次数 | 默认 None 不限制,可设 1000-5000 防止连接状态老化 |
| ping | 取连接时是否检测连接存活 | 调 0 表示不检测,生产可调 1 或 2 |
调优建议是:池的 maxconnections 不要盲目设得很大,它和 MySQL 的max_connections相关。一个粗略估算方法是:应用正常并发 200,每个请求访问数据库 2 次,单次查询 10 毫秒,一个连接大概能承载几个并发请求的周转,池大小设在 40-50 就比较合理。设置得太大,MySQL 后端线程数会飙升,上下文切换反而消耗 CPU。
5. 性能优化:索引、批量写入与 EXPLAIN 定位慢查询
5.1 索引是免费的加速,但乱建会反噬
数据库性能优化,第一件事永远是看索引。把表想象成一本没有目录的书,查找某一页只能从第一页翻到最后一页;索引就是这本书的目录,它让 MySQL 从“全表扫描”变成近似“二分查找”。
建立索引要基于实际查询,不能每个字段都建。一个经验是:把 WHERE、JOIN、ORDER BY 里最常出现的字段拿来做联合索引,并且遵守最左前缀原则。比如业务里经常用age和created_at查询,可以创建联合索引(age, created_at),这样age单独查询时也能命中索引,但created_at单独查询用不到。
一个容易踩的坑是给区分度极低的字段建索引,比如性别只有“男/女”两个值。区分度低时,MySQL 优化器认为“一查一大半都是这个值”,干脆走全表扫描,索引反而增加了每次写入的成本。所以在设计索引前,先算一下字段的区分度,也就是COUNT(DISTINCT field)/COUNT(*),这个值太低的字段就不适合单独建索引。
5.2 批量写入和分批提交的取舍
逐条 INSERT 跑 1000 次,就需要 1000 次网络往返,性能非常差。正确姿势是用executemany一次性传入多条数据:
user_list = [ ("user1", "e1@example.com", 20), ("user2", "e2@example.com", 22), ("user3", "e3@example.com", 23), ] cursor.executemany( "INSERT INTO user_profile (username, email, age) VALUES (%s, %s, %s)", user_list, ) conn.commit()executemany底层会尽量把多条数据合并成一次批量 INSERT 发送给 MySQL,减少了网络交互次数。但要注意,单次提交的数据量也不是越大越好。上万条数据一次性提交有两个隐患:一是 SQL 总长度超过max_allowed_packet限制,直接报错;二是事务时间太长,锁持有时间太久,阻塞其他请求。
一般建议分批提交,每批 500-1000 条,批与批之间 commit 一次。这样如果中途失败,只会回滚当前批次,重试成本比全量回滚要低得多。
5.3 用 EXPLAIN 找到真正的慢查询
光看执行时间找慢 SQL 不够,还要看执行计划。EXPLAIN 就是在一条 SQL 前面加EXPLAIN关键字,MySQL 会输出这条 SQL 的执行计划,不会真正去查数据。在结果里重点看这几列:
- type:从好到差依次是 system、const、eq_ref、ref、range、index、ALL。看到 ALL 说明是全表扫描,基本就是优化信号。
- rows:预估扫描行数,数值越大越可疑。
- Extra:如果出现 Using filesort,说明排序没能用上索引,在高频查询里这是大忌。
举个例子:
EXPLAIN SELECT id, username FROM user_profile WHERE age > 18 ORDER BY created_at DESC LIMIT 20;如果结果里 type 是 ALL,或者 Extra 里有 Using filesort,那就需要结合 WHERE 和 ORDER BY 字段建联合索引。示例表数据量小看不出差别,但套到千万级的订单表上,一次全表扫描就足以把数据库 CPU 打满。
5.4 几个容易拖垮性能的习惯
- SELECT *:尽量列出需要的列。MySQL 只需要读取和传输这些列,IO 和内存都会省不少,代码的可读性也更好。
- 隐式类型转换:比如
username字段是字符串,条件却写成WHERE username = 123456,MySQL 会把字符串列转成数字再做比较,导致索引失效。我排查过几次线上慢 SQL,最后定位到的原因都是这个。 - OR 条件:OR 在多字段上容易让索引失效,可以用 UNION 或者拆成多条 SQL 来优化,具体要看执行计划。
- 大事务里做远程调用:事务期间持锁,远程调用的网络延迟会被无限放大成数据库锁等待,这个问题前面已经强调过,但每次排查慢 SQL 时它都会冒出来,值得反复提醒。
写代码的时候,每次多加一层“这条 SQL 在 1000 万行数据下会怎么执行”的意识,就能避免掉大部分性能问题。真等线上报警再回来看,代价往往是好几倍的。
最后分享一个我平时写数据层代码的习惯组合:PyMySQL 做访问层、DBUtils 做连接池、事务永远包在 try/except 里并显式 commit 或 rollback、每条 SQL 上线前跑一遍 EXPLAIN。这套组合已经在多个项目里验证过,线上没出过大问题。实际排障时你会发现,绝大多数数据库故障都不是 MySQL 本身不行,而是连接管理、事务边界、SQL 写法这些细节出了问题。多花一点时间把这些基础打牢,后面写业务代码会顺畅很多。