ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python操作MySQL游标全解:从PyMySQL基础到存储过程与性能优化

Python操作MySQL游标全解:从PyMySQL基础到存储过程与性能优化 先聊一个特别基础、但很多人一直没搞透的东西Python操作MySQL时那个谁都会用、却很少正经分析过的“游标”cursor。我第一次用Python连MySQL写业务代码时根本不知道自己已经在一个游标上操作了。那时候我只知道要先connection.cursor()然后cursor.execute()再fetchall()一气呵成。直到后来面试被问到“游标到底是什么”我才发现自己对它的理解其实很模糊。更别提去了新公司接手一个带存储过程的老系统里面又是DECLARE cur CURSOR FOR又是FETCH NEXT FROM直接看懵。这篇内容想把游标这件事掰开揉碎讲清楚包括Python驱动里游标对象怎么用、MySQL存储过程里SQL游标怎么写、为什么有时不用游标反而更快、以及我在实际项目里踩过的坑。适合刚学PythonMySQL的初学者也适合工作几年想系统梳理一下的开发者。写的时候以PyMySQL为例其他驱动比如mysql-connector-python、MySQLdb核心概念完全一样代码稍微改改就能跑。1. 先搞懂游标到底解决了什么问题1.1 游标是一根“数据水管上的吸管”关系型数据库处理查询的方式和你写Python列表完全不一样。Python里一个列表data [1, 2, 3]内存直接建好想访问哪个就访问哪个。但MySQL面对一条SELECT * FROM orders结果可能有几十万行服务端不可能一次性把所有数据打包塞给你网络也扛不住内存更扛不住。这时候就需要一种“按需取数据”的机制。游标就是干这个的。你可以把它理解成一根插在数据结果集上的吸管数据还留在MySQL服务器那头Python这边通过游标一行一行“吸”过来。吸一口就拿到一行再吸一口再拿到一行。不用的时候把吸管拔掉服务器端那部分缓冲也就释放了。这个比喻能解释很多现象为什么fetchone()每次只拿一行因为游标在服务器端维护了一个“当前位置”每次fetch都从这个位置往后取。为什么fetchall()在结果集极大时可能把内存撑爆因为你一次性把整根水管里的水全抽到了本地Python进程里。1.2 游标在Python里有“两层身份”这是新手最容易混淆的地方。第一层身份是MySQL存储过程里的“游标对象”。它是在SQL内部直接操作结果集的机制比如在存储过程里写DECLARE cur CURSOR FOR SELECT id, name FROM users; OPEN cur; FETCH cur INTO v_id, v_name; CLOSE cur;这是纯粹在数据库内部跑的和Python没有直接关系。第二层身份是Python数据库驱动比如PyMySQL提供给开发者的“游标对象”。它是客户端用来执行SQL、读取结果集的API。你在Python里写的cursor.execute()、cursor.fetchall()操作的都是这一层。很多人在存储过程里看到游标、在Python代码里也看到游标但不知道这俩到底谁是谁。简单总结Python里的cursor是“会话工具”存储过程里的cursor是“SQL内部的临时管道”。两者都能解决“结果集按需读取”的问题只是存在的位置不同。1.3 你以为的“正常查询”其实一直在用游标我给很多新人讲这个问题时会问“你们有没有用过pymysql连接数据库”十个人里九个人都说用过。接着问“那你们有没有写过conn.cursor()”所有人都说写过。对只要你写了cursor conn.cursor()你就已经创建了一个游标。后面所有操作不管你是fetchone()还是for row in cursor都是在游标上完成的。所以游标不是一个“高级特性”而是Python操作MySQL最基础、最底层的交互方式。搞明白这一点之后后面所有问题就有了抓手为什么游标用不好会内存暴涨、为什么连接不能随便关、为什么有的查询要分页拉取根源都在游标的工作机制上。2. 环境准备驱动选型和最小连接代码2.1 Python连MySQL的驱动怎么选先说驱动。Python连MySQL的主流方案有三个我用一张表说清楚它们的关系驱动维护状态纯Python常用场景备注PyMySQL活跃是日常开发、生产环境安装最简单跨平台mysql-connector-python官方维护是需要官方支持的项目某些API和PyMySQL略不同MySQLdbmysqlclient维护较慢否老项目、追求极致性能需要编译Windows安装麻烦我在自己的项目里默认选PyMySQL。原因很简单一条pip install pymysql就能装好不依赖C扩展在Windows、Linux、macOS上都能跑性能和mysqlclient差距不大而API又比mysql-connector-python更顺手。2.2 第一次建立连接需要注意什么安装好之后在一个.py文件里写下这段最小代码import pymysql conn pymysql.connect( host127.0.0.1, port3306, userroot, passwordyour_password, databasetest_db, charsetutf8mb4, ) try: with conn.cursor() as cursor: cursor.execute(SELECT 1) result cursor.fetchone() print(result) # 输出 (1,) finally: conn.close()这里有几个细节值得掰一下第一host到底写localhost还是127.0.0.1很多人不在意但实际影响很大。localhost在有些系统上会走Unix socket文件连接比如/var/run/mysqld/mysqld.sock而127.0.0.1走的是TCP网络协议。如果你的MySQL没启动socket监听或者Python环境权限不够写localhost就会报ERROR 2002改成127.0.0.1反而就好了。这个坑我后面还会重点讲。第二charset一定要写utf8mb4不要写utf8。utf8在MySQL里是历史遗留的“阉割版”最多存3字节字符遇到emoji或者生僻字直接报错或乱码。utf8mb4才是完整的UTF-8。第三这段代码里的with conn.cursor() as cursor只负责自动关闭游标不会自动关闭连接。连接必须单独conn.close()。很多新手以为with把连接也关了结果程序跑完连接还挂着最后数据库连接数暴涨。注意区分。2.3 连接参数里的隐藏选项再看几个常用但总被忽略的连接参数autocommit: 默认是False也就是手动事务模式。你执行INSERT、UPDATE后不写conn.commit()数据不会真正落库。新手最容易在这卡住代码跑了没报错但数据库里就是查不到新数据。cursorclass: 默认返回元组你可以传pymysql.cursors.DictCursor让它返回字典。这个对代码可读性帮助很大不用再row[0]、row[1]猜字段位置。read_timeout和write_timeout: 如果数据库查询很慢默认超时可能不够需要适当调大。下面这段代码把两个常用选项一起用上import pymysql from pymysql.cursors import DictCursor conn pymysql.connect( host127.0.0.1, userroot, passwordyour_password, databasetest_db, charsetutf8mb4, autocommitTrue, cursorclassDictCursor, ) with conn.cursor() as cursor: cursor.execute(SELECT id, name FROM users LIMIT 3) for row in cursor: print(row[id], row[name])这样每一行拿回来的都是字典字段名字直接对着列名取不容易出错。不过要注意字典游标拿到的字段顺序不一定和表结构一致按列名访问才安全。3. 游标对象的核心操作从执行到取值一整套3.1 execute之后先搞清楚行数很多人写cursor.execute(sql)之后直接fetchall()完全不管这个execute返回了什么。实际上execute的返回值在很多驱动里表示影响的行数但不建议依赖它因为不同驱动实现有差异。PyMySQL里execute的返回值是受影响行数对于SELECT来说它在某些版本里并不靠谱。更稳妥的做法是看cursor.rowcount。cursor.execute(SELECT * FROM orders WHERE status 0) print(未处理订单数:, cursor.rowcount)这个属性在SELECT和UPDATE/DELETE上都有意义。比如你执行了一个UPDATE ... WHERE想知道到底改了多少行用cursor.rowcount最直观。但要注意它表示的是“上一次执行影响的行数”如果中间穿插了其他查询旧值就会被覆盖。3.2 fetchone、fetchmany、fetchall怎么选这是我日常开发里最常用的三个方法各自适用场景完全不同。fetchone()拿一行返回元组或None。适合只需要一条记录、或者想循环处理大结果集的情况。cursor.execute(SELECT * FROM users WHERE id 1) user cursor.fetchone() if user: print(user) else: print(用户不存在)fetchmany(size)拿指定行数适合分批读取既不会一次把所有数据塞进内存又比一行一行拿要高效。比如导出10万条数据可以每次取5000条处理cursor.execute(SELECT * FROM big_table) while True: rows cursor.fetchmany(5000) if not rows: break for row in rows: process(row)fetchall()一次拿全部适合结果集很小的情况。如果你明确知道数据量在几百几千行内用它最简单。但要是几百万行还直接fetchall()轻则内存占用飙升重则进程直接卡死。3.3 直接遍历游标对象游标对象本身是可迭代的for row in cursor其实等价于不断fetchone()底层实现也是这么做的。不过这种写法有个隐藏好处代码更简洁而且处理大结果集时不会像fetchall()那样一次性加载所有数据。with conn.cursor() as cursor: cursor.execute(SELECT id, name FROM users) for row in cursor: print(row)我自己写脚本时如果逻辑简单都是直接for循环遍历游标。有一点要记住游标是一个“一次性”的可迭代对象遍历完了位置到了末尾再想从头读就没了。想要重来只能重新execute()。3.4 scroll移动游标位置scroll()可以让游标在结果集里前后移动支持相对移动和绝对移动两种模式cursor.scroll(1) # 相对当前位置往后移动1行 cursor.scroll(-2) # 相对当前位置往前移动2行 cursor.scroll(0, modeabsolute) # 移到结果集开头这个操作在实际应用中用得不算多但在某些需要“来回看”的场景下非常有用。比如你拿到一批数据先看了第一条又想让游标回到开头重新处理用scroll(0, modeabsolute)就能实现。注意绝对模式里mode参数要写absolute默认是relative别把两个参数顺序搞反了。3.5 拿列名description属性有时候你拿到的结果集是元组但你想知道每一列叫什么可以用cursor.description。它是一个元组列表每个元素包含字段名、类型、长度等信息。cursor.execute(SELECT id, name, created_at FROM users) print([col[0] for col in cursor.description]) # 输出: [id, name, created_at]这个属性在做动态数据处理、自动生成表格头、或者写通用ORM工具时非常有用。比如导出CSV前你可以先通过description拿到列名作为CSV的第一行再遍历游标输出数据。4. 参数化查询与批量操作别再用字符串拼SQL了4.1 占位符的正确姿势Python连MySQL执行带参数的SQL时占位符是%s这一点和SQL Server完全不一样。SQL Server的Python驱动pyodbc用?做占位符很多人从SQL Server切到MySQL时报1064语法错误十有八九就是这里出了问题。正确写法是这样的cursor.execute( SELECT * FROM users WHERE age %s AND city %s, (18, 上海) )注意这里的%s是占位符不是Python的字符串格式化。参数必须通过第二个参数传递用一个元组或者列表不要自己往SQL里拼值。还有个容易踩的坑如果SQL里本身有%字符比如LIKE %北京%在参数化查询里这个%会和占位符混淆。解决办法是把它也写成参数cursor.execute( SELECT * FROM users WHERE name LIKE %s, (%北京%,) )这样%成了参数的一部分不会被当成占位符解析。4.2 为什么不能直接拼接字符串很多人图省事会这么写name input(请输入用户名: ) cursor.execute(fSELECT * FROM users WHERE name {name})这种写法在开发环境跑得通但一旦name的内容被恶意构造你的整个库都有危险。更可怕的是正常情况下它还不报错。遇到字段里有单引号的数据比如输入ONeilSQL直接变成SELECT * FROM users WHERE name ONeil语法错误不说还暴露出一个更严重的问题如果你输入的是 OR 11 --整张表的数据全被查出来了。在用参数化查询之后驱动会自动处理转义和类型转换这类问题从根上就断了。这是我刚工作时踩过最深刻的坑后来养成了一条铁律任何SQL只要带外部输入一律用占位符。4.3 executemany批量插入批量插入数据时用executemany()比循环execute()快得多。它的原理是把多条数据一次性发给MySQL执行减少了网络往返次数。data [ (张三, 25, 北京), (李四, 30, 上海), (王五, 28, 广州), ] with conn.cursor() as cursor: cursor.executemany( INSERT INTO users(name, age, city) VALUES (%s, %s, %s), data ) conn.commit()注意executemany()只是“批量发送”不代表自动提交。如果你没开autocommit最后还是要conn.commit()。另外批量插入超大数据时不要把整个大列表一次性传给executemany建议分批比如每5000条一批避免单次SQL包过大把MySQL的max_allowed_packet打爆。4.4 拿到自增IDlastrowid插入一条记录之后经常需要拿到它的自增主键ID用来关联子表。PyMySQL里在插入操作之后直接访问cursor.lastrowid就行with conn.cursor() as cursor: cursor.execute( INSERT INTO orders(user_id, amount) VALUES (%s, %s), (1001, 299.00) ) new_id cursor.lastrowid conn.commit() print(新订单ID:, new_id)注意一个细节lastrowid是游标对象的属性不是连接对象的属性。如果你在同一个连接上创建了多个游标插完数据后又去另一个游标上查lastrowid拿到的可能不是你想要的值。5. 存储过程里的游标SQL内部的循环玩法5.1 存储过程为什么要用游标Python里的游标是给客户端用的而MySQL存储过程里的游标是给SQL自己用的。什么时候需要它典型场景是“对一行数据做一遍处理”比如有一张积分明细表你要按用户汇总积分再更新到用户表。SQL本身虽然有UPDATE、INSERT但没法“逐行读数据再根据不同条件逐个计算”这时候游标就派上用场了。但我不建议一上来就设计一堆复杂的存储过程游标。游标在数据库里是出了名的性能杀手因为它本质上还是逐行循环每FETCH一次都涉及内存和上下文的切换。能用一条UPDATE解决的就不要写游标写游标之前先想一想能不能用JOIN、子查询、窗口函数来替代。5.2 声明、打开、抓取、关闭的完整模板MySQL存储过程里用游标的固定流程是声明游标 - 打开游标 - 循环抓取 - 关闭游标。下面是一个最简单的例子遍历user表中的用户逐个做逻辑处理DELIMITER // CREATE PROCEDURE sp_process_users() BEGIN DECLARE done INT DEFAULT 0; DECLARE v_id INT; DECLARE v_name VARCHAR(100); DECLARE cur CURSOR FOR SELECT id, name FROM users WHERE status 1; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done 1; OPEN cur; read_loop: LOOP FETCH cur INTO v_id, v_name; IF done THEN LEAVE read_loop; END IF; -- 这里写你对每一行数据的处理逻辑 UPDATE user_stats SET total_processed total_processed 1 WHERE user_id v_id; END LOOP; CLOSE cur; END // DELIMITER ;这里有几个细节必须注意第一所有DECLARE语句必须放在存储过程BEGIN块的最前面不能写几条SQL之后再声明变量。这是MySQL的硬性规定很多人写游标报语法错误就是忘了这一点。第二CONTINUE HANDLER FOR NOT FOUND SET done 1是循环结束的关键。FETCH到结果集末尾时MySQL会触发NOT FOUND条件这时把done设为1循环里判断done就退出。不写这个handler游标会无限循环或者直接报错。第三游标用完一定要CLOSE。不关的话存储过程结束时虽然会自动释放但长时间运行的连接上游标资源可能一直占着影响性能。5.3 从Python调用存储过程写好了存储过程在Python里调用有两种常见姿势。第一种是用cursor.callproc()with conn.cursor() as cursor: cursor.callproc(sp_process_users)这个方法比较“高级”但坑也不少。PyMySQL的callproc()执行后会返回一个结果集但如果你读过一些网络教程会发现有人取不到数据、有人取到空结果集。原因在于callproc()的行为和直接执行CALL有差异尤其在存储过程里有多个SELECT时结果集会分多个批次返回。更稳的写法是直接用execute()执行CALL语句with conn.cursor() as cursor: cursor.execute(CALL sp_process_users()) # 存储过程如果返回结果集可以 fetchall 读取 result cursor.fetchall() # 如果还有多个结果集用 nextset 切换 cursor.nextset()我个人更推荐第二种。它语义直观行为可控而且多个结果集的处理逻辑也和普通查询一致先fetchall()取第一个结果集再nextset()跳到下一个。5.4 存储过程里游标和Python游标的关系如果你在一个Python项目里既写了存储过程里的游标又在外层用了Python游标去调用这个存储过程不妨在脑子里理一下它们的关系外层Python游标负责“把SQL发给MySQL”存储过程内部的游标负责“在MySQL里处理结果集”两者是不同层级的工具互不冲突。有一点要注意存储过程执行期间同样占用数据库连接。如果一个存储过程内部游标循环跑了很久外层连接在这段时间内是不能执行其他SQL的。所以存储过程里的游标循环体尽量只做必要的更新别在里面再调其他存储过程或者做复杂运算。6. 性能、资源管理与游标的“隐藏陷阱”6.1 游标用完必须关吗先说结论必须关。游标关不关短期看似乎无所谓反正Python垃圾回收可能帮你处理。但长期跑的服务里游标占用的不仅是Python对象还有MySQL连接上的上下文资源。如果你在同一个连接上反复执行查询不关游标一旦达到MySQL的某些资源上限就会出现莫名其妙的报错。我一般这么组织代码保证一定关闭conn pymysql.connect(host127.0.0.1, userroot, password123456, databasetest_db) with conn.cursor() as cursor: cursor.execute(SELECT * FROM users) rows cursor.fetchall() conn.close()with conn.cursor()结束时自动调用cursor.close()conn.close()放在最后。实在搞不清就记住一排顺序先关游标再关连接。别先关连接再操作游标那样拿到的就是“在关闭的连接上的游标”报错都不知道从哪里查。6.2 大结果集普通游标和SSCursor的区别前面提到fetchall()可能把内存撑爆那怎么办PyMySQL提供了一个服务端游标类pymysql.cursors.SSCursor以及配套的SSDictCursor。普通游标在执行execute()后驱动会尽量把结果集拉到本地之后你可以反复fetch。而SSCursor不会一次性把结果集拉到本地它让MySQL在服务器端保存游标状态你每次fetch时才真正去数据库取数据。这样内存占用大幅降低特别适合超大结果集的导出、ETL任务。import pymysql from pymysql.cursors import SSCursor conn pymysql.connect( host127.0.0.1, userroot, password123456, databasetest_db, cursorclassSSCursor, ) with conn.cursor() as cursor: cursor.execute(SELECT * FROM big_table) for row in cursor: process(row)但SSCursor有个非常坑的限制在结果集没有全部读完之前这个连接不能再执行其他SQL。如果你在遍历过程中又拿同一个连接去执行一条新查询就会报错。原因是连接底层只有一个socket旧的游标还在从socket里“吸”数据新的查询根本插不进来。所以用SSCursor时要么专门开一个连接跑大查询要么确保在遍历结束时把所有行都读完再干别的。6.3 事务没提交数据去哪了游标只是执行SQL的通道真正决定数据是否写入数据库的是事务状态。PyMySQL默认autocommitFalse这意味着你执行INSERT、UPDATE之后如果没调用conn.commit()数据只是“暂存”在事务里对别的连接不可见而且连接关闭时会回滚。一个比较推荐的用法是try: with conn.cursor() as cursor: cursor.execute(UPDATE users SET balance balance - 100 WHERE id 1) cursor.execute(UPDATE orders SET status paid WHERE id 100) conn.commit() except Exception: conn.rollback() raise把多个操作放在同一个事务里要么全部成功要么全部回滚。这里注意with conn.cursor()结束只关游标不会提交事务所以conn.commit()必须单独写。如果你用with conn:包裹整个代码块行为又不一样——PyMySQL的连接上下文管理器在代码块正常结束时自动commit异常时自动rollback但它同样不关闭连接。我在实际项目中习惯这样处理都用with conn:因为它天然避免“忘了commit”这种低级事故with conn: with conn.cursor() as cursor: cursor.execute(UPDATE ...) cursor.execute(DELETE ...)6.4 游标不是越多越好有些面试题或者项目里会看到“多个游标”的概念。其实游标是绑定在连接上的一个连接同时最多只能有一个“读取中的游标”。你可以在同一个连接上创建多个游标对象但如果你用一个游标执行了查询还没fetch完又用另一个游标执行新的查询行为就变得不可控。这也是为什么我建议一个连接同时只处理一个查询流。需要并发查询就开多个连接而不是在一个连接上搓多个游标。很多“锁等待超时”“connection is busy”的报错源头都是这个。7. 常见问题排查那些年我被游标坑过的瞬间7.1 ERROR 2002怎么连都连不上这个报错几乎每个MySQL新手都遇到过ERROR 2002 (HY000): Cant connect to local MySQL server through socket /var/run/mysqld/mysqld.sock (2)首先检查MySQL服务起没起systemctl status mysql或者mysql -u root -p手动连一下。如果命令行能连Python连不上多半就是host写成了localhost。在Linux上localhost会让驱动尝试连socket文件但你的Python进程可能没有权限访问或者socket文件的路径不对。改成127.0.0.1走TCP通常就通了。还有一种情况MySQL端口不是默认的3306而Python没传port参数。检查一下my.cnf里的port配置再在连接参数里手动指定。7.2 游标报“programming error”的常见场景pymysql.err.ProgrammingError是最通用的错误类型里面包含MySQL的错误码。最常见的两种1064SQL语法错误。先检查SQL本身再检查占位符是不是用了?而不是%s。1054字段不存在。如果你用了字典游标查出来的字段名拼错了就会报这个错它在SQL里其实不会立刻暴露因为MySQL接收到的是字符串。排查这类问题最快的方式是先把SQL打出来到命令行或者Navicat里跑一遍。7.3 存储过程执行了但是取不到结果这个问题特别经典。你写了cursor.callproc(sp_get_users) rows cursor.fetchall() print(rows) # 空原因很可能是存储过程里有多个结果集。callproc()之后第一个“结果集”可能根本不是一个SELECT的结果而只是个状态指示。你需要用nextset()跳到真正的数据结果集cursor.callproc(sp_get_users) cursor.nextset() rows cursor.fetchall()更省事的方案还是那句话直接cursor.execute(CALL sp_get_users())然后按需要fetchall()nextset()可控性高很多。7.4 读取大表时把Python搞崩了如果你SELECT *了一张千万行级别的表又在内存里fetchall()那Python内存占用会瞬间跑到几个G甚至十几个G轻则卡顿重则被系统杀掉。解决办法就是前面说的SSCursor或者改成分页查询def fetch_paged(cursor, table, page_size1000, last_id0): while True: cursor.execute( fSELECT * FROM {table} WHERE id %s ORDER BY id LIMIT {page_size}, (last_id,) ) rows cursor.fetchall() if not rows: break for row in rows: yield row last_id rows[-1][0]注意分页查询时ORDER BY id一定要加上不然每次取出来的“下一页”数据是乱序的很可能漏数据或者重复数据。7.5 锁等待超时1205错误执行UPDATE时偶尔会遇到Lock wait timeout exceeded; try restarting transaction (1205)这个报错通常不是游标本身的锅而是游标所在的事务没有及时提交把行锁一直握在手里其他事务拿不到锁超时等待。解决办法是检查代码里是不是有忘了commit()的操作是不是事务里嵌套了太耗时的外部调用。游标执行完尽早提交或回滚是避免这类问题最朴素也最有效的方法。7.6 字符集乱码Python查询出来的中文变成???或者乱码优先检查三处连接参数有没有charsetutf8mb4、数据表本身的字符集是不是utf8mb4、MySQL客户端的default-character-set配置。有时候程序里是对的但Navicat或命令行查出来乱码那是客户端显示的问题不是程序问题。最后分享一个我自己的使用习惯写到这里游标这东西算是讲得差不多了。最后说一个我这些年养成的习惯只要查询跑完结果集取完了我立刻把游标关掉绝不让它“躺”在连接上过夜。一开始我也觉得无所谓反正Python有垃圾回收。直到有一次在正式环境里一个定时任务把游标放在循环里忘了关跑了几天后MySQL连接数爆满业务直接挂掉。从那以后我写任何数据库代码第一反应就是with上下文管理器该关的绝不拖到后面。还有个小技巧分享给你调试游标问题的时候不要想当然地在代码里猜。把执行的SQL打出来把cursor.rowcount打出来把返回的前几行打出来问题基本就定位了一半。游标并不神秘它就是你和MySQL之间那根数据水管上的吸管学会控制它、善用它你的PythonMySQL开发就能顺畅很多。这篇文章的内容基于我自己的项目实践游标的很多细节在不同驱动下会有些许差异但核心思路是完全通用的。希望对你有帮助也欢迎你在评论区分享自己遇到的游标相关的坑。
返回列表