## 1. 游标不是“指针”,而是数据库操作的执行手柄
很多人刚接触数据库编程时,看到“cursor”这个词,下意识就联想到C语言里的指针——以为它是指向某一行数据的内存地址。其实完全不是这么回事。游标在Python数据库操作中,更像你手里握着的一把多功能螺丝刀:它不存储数据,也不长期持有结果,而是在你下达指令(比如`execute("SELECT * FROM users")`)的那一刻,才真正去数据库里跑一趟,把命令交过去,再把响应带回来。我第一次写爬虫项目时就踩过这个坑:误以为`cursor`对象本身存着全部查询结果,反复调用`fetchall()`还奇怪为什么第二次返回空列表——后来才明白,游标执行完一次查询后,结果集是“一次性消费”的,就像打开一罐可乐,倒完就没了,不会自动 refill。
这种设计背后有实际考量。想象你要查一个用户表,里面有50万条记录。如果`cursor`一创建就默认把全部数据加载进内存,那光是初始化游标就要吃掉几百MB内存,程序还没开始处理就卡死了。所以真正的流程是:`cursor.execute()`只发SQL请求、等数据库返回元信息(比如字段名、类型),真正取数据的动作由`fetch*`系列方法按需触发。你可以把它理解成银行柜台——你递上取款单(SQL语句),柜员(数据库)核对后告诉你“可以取”,但钱(数据)并不会自动塞进你口袋,得你主动伸手(调用`fetchone`)才能拿到第一张,再伸手(`fetchmany(10)`)拿十张,或者直接说“全给我”(`fetchall`)。这种“懒加载”机制让小内存机器也能处理大表,也避免了网络传输浪费。
另外要注意,游标和连接是强绑定关系。你不能把A数据库连接创建的游标,拿去执行B数据库的SQL;也不能在`conn.close()`之后还继续用它的游标。我之前维护一个老系统,有个函数里先`conn.close()`再`cursor.fetchall()`,报错信息很隐晦:“OperationalError: cursor already closed”,折腾半小时才定位到关闭顺序错了。所以记住一个铁律:游标的生命期必须严格嵌套在连接的有效期内,就像螺丝刀必须插在对应型号的电动扳手上才能转动,换错了接口根本转不动。
## 2. 四种取数方式的实际场景选择
`fetchone()`、`fetchmany(n)`、`fetchall()`看着只是参数不同,但在真实业务里选错一个,可能让接口响应时间从200ms飙升到8秒。我做过一个订单导出功能,原始代码直接`fetchall()`拉出12万条记录,内存瞬间涨到1.7GB,用户等得手机都发热了。后来改成`fetchmany(500)`分批处理,内存峰值压到60MB,导出速度反而快了3倍——因为数据库不用一次性组装超大结果集,网络传输也更平滑。
`fetchone()`适合明确知道只要一条数据的场景,比如登录验证时查用户密码哈希值:`cursor.execute("SELECT password_hash FROM users WHERE email = ?", [email])`,后面紧跟`row = cursor.fetchone()`。这里用`fetchall()`就浪费,`fetchmany(1)`又画蛇添足。注意`fetchone()`返回的是元组(即使只有一列),比如`('sha256abc123',)`,不是字符串,解包时容易出错。我习惯加个防御性判断:`if row: password_hash = row[0] else: raise UserNotFound`。
`fetchmany(n)`是性能平衡点。当你要处理中等规模数据(几千到几万行),又不想一次性全载入内存时,它最稳妥。参数`n`不是随便写的,我一般按数据库页大小设为500或1000——这是经过实测的甜点值。太小(如50)会导致频繁IO,太大(如5000)又失去内存优势。举个具体例子:后台任务要批量更新用户积分,先`execute("SELECT id, current_score FROM users WHERE last_login > ?")`,然后循环`while True: batch = cursor.fetchmany(1000); if not batch: break; process_batch(batch)`。这样每批处理完立刻释放内存,GC压力小,数据库连接也不会长时间被占着。
`fetchall()`只推荐三种情况:结果集确定很小(<100行)、做单元测试造数据、或者你真的需要随机访问所有行(比如要按分数排序再取Top10)。有一次我写报表脚本,`fetchall()`后用`sorted(rows, key=lambda x: x[2])`排序,结果发现数据库本身支持`ORDER BY score DESC LIMIT 10`,改完SQL后执行时间从3.2秒降到0.04秒——这提醒我:能推给数据库做的计算,绝不拉到Python里做。表格对比了不同场景的推荐策略:
| 场景描述 | 推荐方法 | 关键原因 | 实际案例 |
|---------|---------|---------|---------|
| 登录校验、唯一ID查询 | `fetchone()` | 避免无谓内存分配,语义清晰 | `SELECT token FROM sessions WHERE user_id=?` |
| 导出Excel、批量更新 | `fetchmany(500)` | 内存可控,网络传输稳定 | 处理10万订单状态同步 |
| 配置表加载(<50行) | `fetchall()` | 代码简洁,无性能风险 | 加载系统参数字典 |
| 流式处理日志(百万级) | `for row in cursor:` | 迭代器模式,内存恒定 | 实时分析Nginx访问日志 |
> 提示:`for row in cursor:`这种写法本质是`fetchone()`的语法糖,但更符合Python习惯,且自动处理空结果。不过要注意,它无法跳过前N行或限制总数,灵活性不如显式调用`fetch*`。
## 3. 事务控制:commit与rollback的实战边界
新手最容易混淆的是:到底什么时候该`commit()`?什么时候该`rollback()`?简单说,**只要SQL修改了数据(INSERT/UPDATE/DELETE),且你希望这些修改永久保存,就必须显式`commit()`**。我见过太多人依赖数据库的自动提交模式(autocommit=True),结果在转账逻辑里漏掉`commit()`,钱转出去了却没落库,半夜被报警电话叫醒。
真实案例:一个电商库存扣减服务,伪代码是:
```python
cursor.execute("UPDATE products SET stock = stock - 1 WHERE id = ? AND stock >= 1", [product_id])
if cursor.rowcount == 0:
raise InsufficientStock
# 这里忘记commit()!
```
测试环境用sqlite(默认autocommit)没问题,上线MySQL后全量失败——因为InnoDB默认关闭autocommit,连接断开时未提交的修改全回滚。后来加上`conn.commit()`,问题解决。但更好的做法是用上下文管理器:
```python
with conn: # 自动commit,异常时自动rollback
cursor.execute("UPDATE products ...")
cursor.execute("INSERT INTO orders ...")
```
`rollback()`不是补救措施,而是防御性编程。比如处理用户注册时,要同时写users表和profiles表:
```python
try:
cursor.execute("INSERT INTO users ...")
user_id = cursor.lastrowid
cursor.execute("INSERT INTO profiles (user_id, ...) VALUES (?, ?)", [user_id, data])
conn.commit()
except Exception as e:
conn.rollback() # 关键!否则users表已插入,profiles失败,数据不一致
raise
```
这里`rollback()`保证了原子性:要么两个表都成功,要么都失败。注意`rollback()`只影响当前事务内未提交的更改,对已`commit()`的数据无效。我曾经误以为`rollback()`能撤回昨天的错误更新,结果白忙活两小时——它只对本次连接内、`commit()`前的操作起作用。
还有一个易忽略点:`SELECT`语句在某些隔离级别下也会开启隐式事务。比如PostgreSQL的`REPEATABLE READ`模式,第一个`SELECT`会启动事务,后续操作若不`commit()`或`rollback()`,连接会一直占用事务槽位。我们监控系统曾因此触发连接池耗尽告警,排查发现是某个报表查询忘了关闭游标,导致事务挂起24小时。所以养成习惯:所有DML操作后,明确`commit()`或`rollback()`;纯查询也建议用`with conn:`确保收尾。
## 4. 资源管理:游标关闭的时机与陷阱
游标不是“用完即焚”,但也不是“永不关闭”。很多人觉得`conn.close()`会自动清理所有关联游标,这没错,但中间过程可能埋雷。比如你在一个长循环里反复创建游标却不关闭:
```python
for item in big_list:
cursor = conn.cursor() # 每次都新建
cursor.execute("INSERT ...", [item])
# 忘记cursor.close()
```
在SQLite里可能只是慢一点,在MySQL里可能快速耗尽服务器允许的最大游标数(`max_connections`),新请求直接被拒绝。我线上遇到过最狠的一次:一个定时任务每秒创建10个游标,30分钟后数据库报错“Too many connections”,整个应用雪崩。
正确姿势是“谁创建,谁关闭”。游标应该在数据处理完立即关闭,而不是等连接关闭。最佳实践是用`with`语句:
```python
with conn.cursor() as cursor:
cursor.execute("SELECT * FROM logs WHERE created_at > ?", [threshold])
for row in cursor:
process(row)
# 这里cursor自动close(),哪怕process()抛异常也安全
```
`with`块结束时,游标自动调用`close()`,释放底层资源。注意`conn.cursor()`返回的游标对象支持上下文协议,这是PEP 343明确规定的。
还有个隐蔽陷阱:游标关闭后还能调用`fetch*`吗?答案是“看情况”。SQLite允许关闭后读取已缓存的结果(因为数据早就在内存里),但MySQL Connector/Python会直接抛`ProgrammingError: cursor closed`。所以别依赖这种行为,一律在`close()`前取完数据。我建议在`with`块内完成所有数据消费,把转换逻辑也放进去:
```python
with conn.cursor() as cursor:
cursor.execute("SELECT name, email FROM users")
# 立即转换为字典列表,避免后续处理时游标已关
users = [dict(zip([col[0] for col in cursor.description], row))
for row in cursor]
```
这里`cursor.description`获取字段名,配合`zip`生成字典,既安全又直观。最后强调一个原则:**游标生命周期越短越好,数据搬运越早完成越好**。就像快递员,送完货(取完数据)就该交还工牌(关闭游标),而不是揣着工牌去喝咖啡。