1. 官方驱动与第三方驱动的选型,别只看名声
1.1 官方驱动到底比第三方驱动强在哪里
先直接说结论:mysql-connector-python是 MySQL 官方维护的 Python 驱动,搞定 Python 连 MySQL 这件事,它是最“正统”的选择。很多人从 PyMySQL 或者 MySQLdb 转过来,都会先问一句:有没有必要换?我的答案是,看场景,但如果你要做的是一个长期维护的项目,官方驱动能帮你省掉不少隐患。
先说它和 PyMySQL 这种纯 Python 驱动的核心区别。PyMySQL 是纯 Python 实现的,性能上限摆在那里;而 mysql-connector-python 提供了use_pure参数,一个参数可以切换纯 Python 模式和 C 扩展模式。C 扩展模式下走的是 MySQL C Client API,性能明显更强,而且对协议细节的处理更贴近 MySQL 服务端自身的行为。我自己压过一轮简单的 SELECT 循环,同样一万次取数,C 扩展模式大约能比纯 Python 模式快 20% 到 30%,这还不算大结果集的情况。
还有一点很实际:官方驱动对 MySQL 新特性的跟进速度是最快的。比如 X DevAPI、caching_sha2_password 这种新的认证插件,早期 PyMySQL 就吃过不少亏,而官方驱动基本都是和 MySQL 新版本同周期适配。如果你用的是 MySQL 8.0 以上,默认认证方式caching_sha2_password已经普及,这时候用官方驱动大概率少碰认证报错的坑。
MySQLdb(也就是mysqlclient)也是一个老牌选择,但它的底层依赖libmysqlclient,编译环节容易出幺蛾子,Windows 上尤其明显——不是缺编译环境就是缺 dll。官方驱动在 Windows 上有官方编译好的 wheel 包,直接pip install就能用,这也是我认为官方驱动更适合大众的原因:安装门槛低,不用折腾本地 C 库编译环境。对新人和生产环境来说,都是省心选择。
1.2 版本匹配与安装细节,这一步很多人踩坑
安装官方驱动其实非常简单,一个命令:
pip install mysql-connector-python但如果你用的版本不对,后面会遇到各种莫名其妙的事情。第一条经验:尽量用最新稳定版,并且留意 Python 版本的支持范围。比如 8.x 驱动已经完全放弃 Python 2,如果你的老项目还在跑 Python 2.7,那只能锁版本用mysql-connector-python==8.0.28左右的旧版本。
第二条经验:注意区分mysql-connector-python和mysql-connector这两个包。很多人搜索的时候会搜到mysql-connector,那是 Oracle 的另一个独立发行包,API 设计不完全一样,混用容易导入失败或者参数不兼容。认准 PyPI 上的名字,别装错。
还有虚拟环境的问题。我见过不少人把驱动直接装到系统 Python 里,结果项目跑到服务器上发现原来的用户没有系统权限,重装也装不进去。正确的姿势是给每个项目建一个虚拟环境:
python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install mysql-connector-python如果你的网络环境下载慢,可以换国内源,比如清华镜像:
pip install mysql-connector-python -i https://pypi.tuna.tsinghua.edu.cn/simple安装完验证一下:
import mysql.connector print(mysql.connector.__version__)能正常打印出版本号,说明驱动已经就位。另外提醒一点:在安装之前先确认目标 MySQL 版本。8.0 的驱动可以连 5.7 的服务端,反过来就不一定,5.6 之类的老版本服务端建议也用旧版驱动,避免协议兼容性问题。顺手也把 MySQL 服务端版本查一下:
SELECT VERSION();2. 核心 API 实操,从连接到复杂查询一步到位
2.1 建立连接时那些关键参数,一个都不能漏
官方驱动建立连接最基础的方式是传入host、port、user、password、database这些参数。但一个合格的连接配置,远远不止这五个。我先给一个生产级的最小配置示例:
import mysql.connector conn = mysql.connector.connect( host="127.0.0.1", port=3306, user="app_user", password="your_password", database="business_db", charset="utf8mb4", connection_timeout=5, autocommit=False, use_pure=True, )这里有几个参数是我特别想强调的。charset一定要明确写成utf8mb4,如果你不写,驱动默认按utf8mb4_general_ci这一套来,MySQL 8.0 下可能没问题,但 5.7 上连接乱码的概率很高。MySQL 的 utf8 其实是 utf8mb3,不能完整支持四字节的 emoji 和生僻字,所以统一用 utf8mb4 才是稳妥的。connection_timeout建议必填,尤其是在微服务或容器环境里,数据库暂时连不上时,如果没有超时限制,驱动会一直阻塞着,应用线程就被挂死。
use_pure这个参数就是我上一节说的切换开关。True表示用纯 Python 实现,False表示优先用 C 扩展。我这里写True是为了打个样板,实际生产环境你可以按自己的需求去调。如果第一次连接或者执行计划阶段出现一些诡异的内存错误、Segment Fault,基本可以锁定是 C 扩展在某台机器上不兼容,把use_pure改成True再跑一遍,大概率就正常了。
连接建立起来以后,我建议第一步先做个简单的连通性检测:
cursor = conn.cursor() cursor.execute("SELECT 1") print(cursor.fetchone()) cursor.close()能返回(1,)说明整个链路已经通了。这一招在排查网络隔离、权限配置的时候特别有用。
2.2 游标使用与参数化查询:防注入的第一道防线
官方驱动的游标用法和大多数数据库驱动类似,cursor = conn.cursor()然后cursor.execute(sql, params)。关键的一条铁律:永远不要手动拼接 SQL 字符串来传值,要使用参数化查询。
反面教材是这样的:
# 极其危险的写法,千万别学 sql = f"SELECT * FROM users WHERE name = '{user_input}'" cursor.execute(sql)如果user_input里带了' OR '1'='1,全表数据直接裸奔。正确的写法:
sql = "SELECT * FROM users WHERE name = %s" cursor.execute(sql, (user_input,))官方驱动的占位符是%s,这个跟 PyMySQL 是一致的。这里有个细节,很多人会把%s写成?,那是 SQLite 的占位符风格,在 MySQL 驱动下会直接报ProgrammingError。
再说取数方式。fetchone()取一行,fetchall()取全部,fetchmany(size)按批次取。如果结果集很大(几十万行以上),千万别用fetchall(),内存会直接爆炸。应该用fetchmany(1000)循环取:
cursor.execute("SELECT * FROM big_table") while True: rows = cursor.fetchmany(1000) if not rows: break for row in rows: process(row)还有一个很实用的点:官方驱动支持字典游标,让返回结果以字段名做 key。普通游标返回的是元组,比如(1, "张三", "2024-01-01"),你得靠位置去猜哪个是哪个,维护成本很高。字典游标一行代码就搞定:
cursor = conn.cursor(dictionary=True) cursor.execute("SELECT id, name, created_at FROM users WHERE id = %s", (1,)) row = cursor.fetchone() print(row["name"]) # 直接按字段名取我自己在写业务代码时基本一律用字典游标,除了代码可读性更好,更重要的是后面如果 SQL 字段顺序调了,代码不用跟着改。
2.3 事务处理:写库操作的正确打开方式
事务这一块,很多新手最容易犯的错是把事务直接依赖在自动提交上。如果你在初始化连接时没设置autocommit=False,那么每一条 DML 语句执行完马上就会提交,中间出错了都没办法回滚。对支付、订单这种强一致场景来说,这是灾难。
我常用的一个事务执行模板是这样的:
conn = mysql.connector.connect( host="127.0.0.1", user="app_user", password="your_password", database="business_db", autocommit=False, ) try: cursor = conn.cursor() cursor.execute("UPDATE account SET balance = balance - 100 WHERE user_id = %s", (1,)) cursor.execute("UPDATE account SET balance = balance + 100 WHERE user_id = %s", (2,)) # 模拟一个业务校验,失败了就整体回滚 if not check_something(): raise RuntimeError("业务校验失败") conn.commit() except Exception as e: conn.rollback() print(f"事务回滚: {e}") finally: cursor.close() conn.close()这里需要特别说明一个点:驱动里的commit()和rollback()是连接级别的方法,不是游标级别。很多从其他语言转过来的人会下意识找cursor.commit(),实际上根本没有这个 API。另外,如果你在autocommit=False的事务里执行了 DDL(比如CREATE TABLE),MySQL 会隐式提交前置事务,这一点不是驱动能控制的,是数据库本身的行为,写代码时要清楚这个坑。
还有一个经验:事务保持时间一定要短。别在事务块里塞耗时的外部 API 调用、文件读写,否则行锁、间隙锁会越积越多,最后把数据库拖死。事务里只做纯数据库操作,其他的活放到事务外。
2.4 批量写入的正确姿势:executemany 的大坑
批量插入场景下,很多人会用循环一条条执行INSERT,这种方式在数据量小的时候感觉不出问题,但一旦上千条上万条,性能断崖式下跌。正确姿势是executemany():
sql = "INSERT INTO users (name, email) VALUES (%s, %s)" data = [ ("张三", "zhangsan@example.com"), ("李四", "lisi@example.com"), ("王五", "wangwu@example.com"), ] cursor.executemany(sql, data) conn.commit()底层逻辑上,executemany()会复用同一个预处理语句,避免反复解析 SQL,网络交互次数也大幅减少。但这里有个必须注意的版本行为变化:在 MySQL Connector/Python 8.0.32 之前的某些版本中,executemany()对于INSERT语句会自动拼接成多值语句发送;而如果你拿到的是一个带RETURNING或依赖LAST_INSERT_ID()的业务场景,批量插入后逐行取自增 ID 的逻辑会非常绕。如果你需要批量插入后获取每个新行的主键,我的建议是:要么用循环单条插入,要么一次性插入后按业务唯一键回查,千万不要天真地认为cursor.lastrowid在批量模式下能给你返回一个数组,它大多数情况只会返回第一个生成的有效 ID。
还有一个小的坑:executemany()对包含ON DUPLICATE KEY UPDATE的 SQL 拼接兼容性在各个小版本之间有过调整。如果你的语句里带有这个子句,一定要先做小样本测试,不行就退回到循环插入,避免在线上突然报语法错误或者丢数据。
3. 进阶实战:连接池、SSL、存储过程与读写分离
3.1 连接池到底怎么配才不坑
每来一个请求就新建一条数据库连接,这个模式在大并发的场景下是致命的。连接建立要经过 TCP 握手、认证、资源初始化等过程,高频场景下光是握手开销就能把应用拖垮。解决方式就是连接池。
官方驱动自带连接池模块mysql.connector.pooling,用起来不算复杂:
from mysql.connector import pooling pool = pooling.MySQLConnectionPool( pool_name="mypool", pool_size=5, host="127.0.0.1", user="app_user", password="your_password", database="business_db", autocommit=False, )获取连接:
conn = pool.get_connection() cursor = conn.cursor() cursor.execute("SELECT * FROM users") rows = cursor.fetchall() cursor.close() conn.close()这里最重要的一条经验:从连接池拿出来的连接用完后一定要调用close(),这不是真的关闭连接,而是把连接归还给池子。如果你忘了归还,池子里的连接会慢慢被耗尽,后续请求全部卡在get_connection()上超时。这属于非常经典的“事故型踩坑点”,一旦发生产线大面积超时,第一反应就去查是不是出现了连接泄漏。
连接池还有一个隐蔽的参数pool_reset_session,默认是True。这个参数表示每次归还连接时是否重置换话session状态。如果你的连接池里某些连接在会话中设置了变量(比如SET wait_timeout = 100),归还后如果不重置,下一个人拿到这条连接会发现会话变量串味了,行为非常诡异。官方默认开启重置其实是好事,但要注意开启后每次归还连接都会多发一个 COM_RESET_CONNECTION,你的网络 RTT 开销会略升。如果追求极致性能、并且你确认自己的会话变量没有副作用,可以关掉它。
线程安全方面也要说一句:连接池本身是线程安全的,但单个连接不建议被多个线程同时使用。多线程场景下正确的做法是每个线程各自get_connection(),用各自的连接操作。共享连接的情况下,你没法保证事务边界和游标状态,很容易出现数据错乱。我在一个 FastAPI 服务里踩过共享连接的坑,两个线程交替execute,结果互相覆盖了游标状态,查出来的数据和实际完全对不上。
3.2 SSL 连接与 SSL 报错的根源排查
“mysql ssl连接错误”这个关键词搜索量一直很高,说明真的是很多人绕不过去的坎。先说背景:MySQL 8.0 的默认安装版通常会自动配置 SSL 证书,客户端连接时服务端会自动要求或者建议 SSL。官方驱动在连接时可以通过ssl_disabled=True来显式关闭 SSL,或者在ssl_ca中指定 CA 证书来建立受信连接。
最常见的一种报错是:
mysql.connector.errors.InterfaceError: SSL connection error: Failed to set ciphers to use这个错误我一开始也是一头雾水,后来发现根子往往不在驱动,而在OpenSSL 版本与 MySQL 服务端加密套件不匹配。客户端机器上如果 OpenSSL 太老,或者 MySQL 只支持某个特定套件而客户端不支持,连接请求就会在完成 TLS 握手前直接失败。
排查和解决思路分三步走:
第一,确认服务端 SSL 状态:
SHOW VARIABLES LIKE 'have_ssl'; SHOW VARIABLES LIKE 'ssl_cipher';如果have_ssl是YES,说明服务端启用了 SSL。第二,确认你的客户端驱动版本和 OpenSSL 版本。如果驱动是老版本,先升级最新版再测。第三,如果是因为测试环境基础设施不完善,实在没法搭完整证书链路,明确是内网环境、信任网络边界的情况下,可以临时用:
conn = mysql.connector.connect( host="127.0.0.1", user="app_user", password="your_password", database="business_db", ssl_disabled=True, )但要注意,这只适合开发/内网环境,生产环境压测和金融级业务必须把 SSL 链路搭好,否则明文传输的账号密码在链路上裸奔,风险极大。还有一种半吊子做法:本地生成的证书没加 SAN 扩展,结果客户端报 “Hostname mismatch” 或者 “IP address mismatch”。这属于证书配置问题,重新生成证书时必须带正确的 SAN 条目,别老在代码上打转。
另外提醒一下,如果你是从 5.7 升到 8.0,或者大量连接是走 8.0 默认的 caching_sha2_password,SSL 是否启用直接影响密码认证流程。没启用 SSL 时,caching_sha2_password 通常会用 RSA 公钥加密密码传输,客户端如果没配置server_public_key_path会报 RSA 加密失败。升级后如果突然连不上,优先查这个方向,比翻驱动日志更高效。
3.3 存储过程调用:细节藏在 result 集里
业务里如果已经沉淀了一堆 MySQL 存储过程,用官方驱动调用它的方式和普通 SQL 有点区别。最常见的调用方式是:
cursor = conn.cursor() cursor.callproc("sp_get_user_stats", [user_id, 0]) # 最后一个参数往往是 OUT 参数占位callproc的第二个参数列表对应存储过程的入参和出参,出参要在传入列表里先给一个占位值,调用后再从结果中取最终值。比如一个存储过程sp_get_user_stats(IN uid INT, OUT total_cnt INT),你这么写:
cursor.callproc("sp_get_user_stats", [100, 0]) results = cursor.stored_results() for result in results: rows = result.fetchall() print(rows)很多人在这里会犯一个错:直接用cursor.fetchall()取存储过程的结果集,发现返回空。这是因为存储过程可能返回多个结果集,游标内部状态是依次推进的,正确的做法是用cursor.stored_results()拿迭代器,一个一个结果集去消费。
还有一个容易被忽视的点:存储过程内部如果有多个SELECT,同时有OUT参数,你必须把所有结果集遍历完再去读参数。如果结果集没有消费完,出参的值可能还没被刷新。我在一个报表系统里就吃过这个亏,存储过程前面查了个临时结果集但没消费,后面读 OUT 参数永远拿到初始占位值,排查了很久才发现是这个顺序问题。
如果存储过程执行返回了错误行数或者你没预期的结果集,最好在调用前给连接设置dangerous_substitute或者仔细检查存储过程的SET语句。MySQL 驱动对存储过程的处理不是万能的,复杂的游标嵌套、DEALLOCATE 临时结果集,都可能因为连接会话状态回归导致异常,遇到说不清楚的问题,先在 Navicat 里跑一遍存储过程确认数据库侧行为,再回过来查驱动。
3.4 读写分离场景下,连接串和请求路由怎么设计
在实际系统里,“怎么使用 mysql 主从复制”是热点问题,但从驱动层面考虑,官方驱动不会自动帮你做读写分离,你需要在应用层做路由。常见做法是维护两个连接池,一个指向主库、一个指向从库。
primary_pool = pooling.MySQLConnectionPool(pool_name="primary", pool_size=10, host="主库IP", ...) replica_pool = pooling.MySQLConnectionPool(pool_name="replica", pool_size=10, host="从库IP", ...) def get_conn(for_write=False): return primary_pool.get_connection() if for_write else replica_pool.get_connection()这个方案简单可靠,但有一个核心问题必须想清楚:主从复制延迟。在写入后立刻查询的业务场景下,如果查询被路由到从库,从库还没同步最新的 binlog,你会读到旧数据。这个问题在 MySQL 原生异步复制下是无解的,驱动不可能替你感知延迟。
我见过两种缓解方案。方案一:关键查询强制走主库。比如用户刚提交完订单、马上要看到订单列表,这个读请求就打一个“require primary”的标记,路由到主库。方案二:从库延迟阈值判断。查询从库的Seconds_Behind_Master这个状态值,超过阈值就不路由过去,回退到主库。但这在搞并发下需要额外的状态查询开销,看场景取舍。
从驱动设计上还有一个要点:主从两边的连接池要统一好参数,特别是autocommit、charset、use_pure,否则切换读写的代码会变得很难维护。另外,如果主库在不断发 binlog 给从库的时候网络闪断,从库连接池里的连接虽然能建上,但数据状态是滞后的,最好在连接池外层做一个简单的状态探活逻辑,避免把请求派发给一个换主后已经变只读的连接。
4. 高频报错排查实录,避坑经验直接抄
4.1 Error 2002 (HY000) 的完整排查清单
“ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'” 这个报错应该是 MySQL 新手最常见的噩梦之一。这个错误意味着客户端尝试通过 Unix socket 文件连接到本地的 MySQL 服务,但服务端要么没有启动,要么 socket 文件的路径不对。
排查顺序我建议按下面的清单来:
| 罪魁祸首 | 表现 | 解决方式 |
|---|---|---|
| MySQL 服务没启动 | 连/tmp/mysql.sock不存在或连接被拒绝 | 启动服务,如systemctl start mysqld或service mysql start |
| socket 文件路径不同 | 感觉启动成功,但驱动配的是/tmp/mysql.sock,实际服务用/var/run/mysqld/mysqld.sock | 查看my.cnf或mysqld --verbose --help确认 socket 路径,连接串里显式指定unix_socket参数 |
| 监听地址只绑了 localhost | 远程机器用驱动连接时也报 2002 | 修改绑定地址为0.0.0.0(注意安全),或走 SSH 隧道 |
| 权限/进程隔离 | 某些容器环境内 socket 文件存在,但当前用户没权限访问 | 检查 socket 文件属主、权限,调整应用用户组 |
官方驱动如果要在本地用 socket 文件连接,写法是:
conn = mysql.connector.connect( unix_socket="/tmp/mysql.sock", user="root", password="xxx", )注意:host和unix_socket不能同时出现。如果指定了host,驱动走 TCP 连接;如果指定了unix_socket,才会走 socket 连接,二者是互斥的。
另外我在排查时有一个小技巧:在命令行先用mysql -u root -p手动连一次。如果命令行也报同样的 2002,那基本和驱动无关,问题在服务本身;只有命令行能连成功而驱动报错,才需要怀疑连接参数配置。
4.2 MySQL 8 认证插件引发的连接失败
MySQL 8.0 默认认证插件改成了caching_sha2_password,而很多老工具、老驱动默认只支持mysql_native_password。如果你在连接时看到:
mysql.connector.errors.ProgrammingError: 1045 (28000): Access denied for user 'xxx'@'localhost'但其实用户名密码都没错,原因多半就是认证插件不匹配。这个问题的本质是:服务端和客户端在 RSA 公钥交换或者认证算法协商上没达成一致。处理办法有两类。
第一类,让服务端兼容老驱动。创建一个用mysql_native_password的账号:
CREATE USER 'myuser'@'%' IDENTIFIED WITH mysql_native_password BY 'mypassword';或者修改现有账号:
ALTER USER 'myuser'@'%' IDENTIFIED WITH mysql_native_password BY 'mypassword';第二类,让客户端支持新认证插件。官方驱动的新版本(8.0.x 系列)通常已经支持caching_sha2_password,所以先升级驱动版本。如果在非 SSL 环境下,你还需要处理公钥问题,这时候在连接参数里加上:
conn = mysql.connector.connect( host="127.0.0.1", user="myuser", password="mypassword", database="business_db", server_public_key_path="/path/to/public_key.pem", )server_public_key_path是服务端提供的 RSA 公钥文件,从 MySQL 服务器的caching_sha2_password_rsa_public_key.pem文件拷贝到客户端即可。如果不方便拿文件,且连接走的是内网,还可以用:
allow_public_key_retrieval=True但这等同于把公钥传输信任交给服务端,仅在安全的网络环境下使用,否则存在中间人攻击的隐患。
4.3 “Lost connection to MySQL server during query” 是怎么回事
这个报错也是高频问题。字面意思是在查询过程中连接断了,原因层面往往指向三个方向:超时、包过大、网络抖动。
第一种,wait_timeout和interactive_timeout到期。MySQL 服务端默认wait_timeout是 8 小时,但如果你的连接被闲置很久,服务端会主动断开。这种问题的特点是:你之前用得正常,隔了一段时间再次执行 SQL 时,突然报Lost connection。解决思路是应用层在拿连接时加ping 检测:
conn.ping(reconnect=True, attempts=3)如果检查到连接已经断开,驱动会自动重连。
第二种,包过大。查询涉及大字段(比如一次性查大量 BLOB、TEXT),超过了max_allowed_packet限制,服务端会直接断开连接。碰到这种情况,修改服务端配置:
SET GLOBAL max_allowed_packet = 67108864;也就是 64MB,但这需要对应权限,并且在my.cnf里同样配置以保持重启后生效。
第三种,网络设备主动断连。云上环境里,负载均衡、安全组防火墙经常会对空闲连接发送 RST 包。代码层面虽然能做 TCP keepalive,但更实用的做法是在连接池里加上连接空闲过期时间,比如空闲超过 300 秒的连接直接丢弃重建,避免使用老化连接。
4.4 时区、乱码与结果集转换成 datetime 的坑
官方驱动在取DATETIME、TIMESTAMP字段时有自己的行为,常被忽略但非常影响结果。默认情况下,驱动返回的日期对象是datetime.datetime类型。但如果你在连接字符串里不指定时区,MySQL 服务端的time_zone就决定了返回值的基准。
我遇到过一种情况:服务端时区是+08:00,应用服务器时区是UTC,驱动直接返回了事务时间但在应用层再用datetime.astimezone()去做时区转换时,因为驱动返回的是 naive datetime(不带时区信息),转出来的结果是错的。解决方式是统一时区配置:
mysql> SET time_zone = '+08:00';或者在 MySQL 配置里设默认时区:
[mysqld] default-time-zone = '+08:00'在 Python 侧,拿到 datetime 后第一时间replace(tzinfo=zoneinfo.ZoneInfo("Asia/Shanghai"))补上时区信息,不要等着后面再猜。
乱码问题就简单多了:连接字符集、表的字符集、列字符集统一为utf8mb4,并把charset参数显式写明。如果从数据库读出来的是乱码,先检查这三级字符集,大部分情况是表或列还是老旧的latin1,需要执行:
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;这种迁移在数据量大时锁表风险高,放到业务低峰期执行,提前做好备份。
5. 性能调优与生产级代码组织
5.1 查询性能的驱动侧调优,别只知道 fetchall
很多人在 Python 里调 MySQL 性能,一股脑就往 SQL 优化上想,完全忽略了驱动侧本身的调优空间。实际上驱动侧能做的优化比你想的多。
第一个是前面提到过的use_pure,在能用 C 扩展的机器上果断设为False。特别是密集的 DataFrame 读取场景,C 扩展把字符串转换、数值转换都放到 C 层做,节省的 Python 层开销非常可观。
第二个是尽量让单次查询返回你需要的字段,而不是SELECT *。这一点看起来是 SQL 习惯问题,但在驱动层面影响的是内存和网络传输。SELECT *会让驱动把每一列都做一次编码转换,即便你只展示两列,剩余几十列全在做无用功。
第三个是批量操作的 chunk 大小。如果是用executemany批量插入,我建议一个批次控制在 500 到 1000 行之间。批次太小,节省不了多少次 RTT;批次太大,事务体积和 undo log 压力上升,一旦失败回滚的开销也大。这个数值在不同机器、不同版本上都有差异,建议用压测确定最优范围。
第四,如果读的是读多写少的场景,结果集比较大的时候可以尝试用raw=True游标,驱动会跳过一部分到 Python 对象的转换,直接返回原始字节,你可以再自己按bytes.decode或者struct.unpack精确定制转换。这种写法代码繁琐一些,但能榨出不少性能,适合对延迟极度敏感的分析类任务。
5.2 业务代码里怎么封装数据库访问层
生产环境里直接在每个函数里写mysql.connector.connect(...)是不可维护的做法。我个人的习惯是做成一个简单的数据库访问封装。不要上太重的东西,一个类就够了。
import mysql.connector from mysql.connector import pooling class Database: def __init__(self, **conn_params): self.pool = pooling.MySQLConnectionPool( pool_name="app_pool", pool_size=10, **conn_params ) def query(self, sql, params=None, dictionary=True): conn = self.pool.get_connection() try: cursor = conn.cursor(dictionary=dictionary) cursor.execute(sql, params or ()) rows = cursor.fetchall() return rows finally: cursor.close() conn.close() def execute(self, sql, params=None): conn = self.pool.get_connection() try: cursor = conn.cursor() cursor.execute(sql, params or ()) affected = cursor.rowcount return affected finally: cursor.close() conn.close()这样的封装其实已经把连接池、自动归还、异常安全都包进去了,使用方只需要db.query(...)和db.execute(...)。注意cursor.close()要放在finally里保证一定会执行,否则一旦查询发生异常,游标没关,连接归还也不安全。
如果你有读写分离需求,可以在Database类里加一个use_replica标志,让query方法选择不同的连接池。但要记住,事务操作必须固定用一个连接、一个游标,不能中途换池子。封装时如果直接透出get_connection()会让事务逻辑泄漏到业务层,不严谨。更合适的做法是给这个类加一个上下文管理器,让事务边界显式化:
@contextmanager def transaction(self): conn = self.pool.get_connection() conn.start_transaction() try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close()5.3 慢 SQL 与驱动日志的排查思路
线上出了性能问题,不要一开始就在驱动代码里瞎翻。先把 MySQL 慢查询日志打开,定位到底哪条 SQL 在数据库侧花的时间最长,再回来查是不是驱动写法的问题。开启慢查询日志的姿势:
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;long_query_time单位是秒,设成 1 表示超过 1 秒的查询都会被记录下来。注意在 8.0 里,可以通过:
SHOW VARIABLES LIKE 'slow_query_log%';确认日志文件路径,然后去分析。
如果确认 SQL 不慢,但是应用层整体很慢,这时候就要看驱动本身的日志了。官方驱动可以设置日志级别:
import logging logging.basicConfig(level=logging.DEBUG)它会把连接建立、SQL 执行、结果集获取等关键过程打印出来。驱动日志排错的关键是看两条记录之间的时间差。如果execute和fetchall之间隔了很久,问题多半在结果集转换或者网络传输,而不是 SQL 执行慢;如果在connect阶段耗时很长,就要扣网络、DNS、认证这些问题。
5.4 LIMIT 分页和防呆设计
分页这件事看着简单,但数据量一上去各种坑就出来。官方驱动本身不做分页,你需要自己在 SQL 里写LIMIT子句。最基本的错误是:LIMIT后面的参数用字符串拼接,结果恶意的1; DROP TABLE直接注入进来。正确写法还是用参数化:
sql = "SELECT * FROM orders ORDER BY id DESC LIMIT %s OFFSET %s" cursor.execute(sql, (page_size, offset))另一个坑是深分页问题。当OFFSET大到几十万时,MySQL 需要先扫描并丢弃前几十万行,性能极速恶化。真正稳妥的分页策略是使用“游标分页”或“键集分页”:
# 以上一页最后一条记录的 id 作为游标 sql = "SELECT * FROM orders WHERE id < %s ORDER BY id DESC LIMIT %s" cursor.execute(sql, (last_id, page_size))这种方式能走主键索引,即使翻到最后一页性能也保持稳定。驱动层面这一点没有特殊 API,但你在设计访问层接口时最好原生支持这种模式,方便以后迁移。
6. 我个人在实际项目里的最后几点体会
从PyMySQL转向mysql-connector-python对我个人来说并不是一个“非此即彼”的站队问题。我的体会是:小型脚本、Demo 项目用哪个都无所谓,但长周期、多团队协作的项目,选官方驱动更省心。官方驱动的语义边界更接近 MySQL 原生行为,排查问题时更容易参考官方文档和社区讨论,而不是自己对着源码猜。
还有一点是我反复在团队里强调的:多写一层薄薄的数据库访问封装,把连接池、事务边界、SQL 日志都包进去。长期看,这层封装节省的沟通成本和排障时间远超那点代码量。很多人一开始贪快,业务代码里直接裸写mysql.connector.connect,等到线上连接数被打满、或者需要统一加监控时,才体会到改造成本有多高。
如果你刚开始接触官方驱动,我建议从一条最简单的连接、一次最简单的SELECT跑起来,然后逐步往上加事务、加连接池、加 SSL、加读写分离。每加一层都做一次回归和数据校验,别贪多。数据库驱动这层东西,稳定性永远优先于炫技。