本章目录
第 6 章
两个人同时抢最后一个名额
1 件库存,20 个人同时点——我实测的结果是卖出 20 单,库存变成 −19。这类 bug 平时测不出来,一上线就出事。
先看一眼后果
假设你搞个活动:限量 1 份的定制头像,先到先得。代码这么写,很自然吧:
stock = conn.execute("SELECT stock FROM items WHERE id=1").fetchone()[0]if stock <= 0: return False # 没货了conn.execute("UPDATE items SET stock = stock - 1 WHERE id=1")conn.execute("INSERT INTO orders (user) VALUES (?)", (user,))conn.commit()
我拿 20 个线程同时跑这段,库存设 1。结果:
| 写法 | 成功单数 | 剩余库存 | |
|---|---|---|---|
| 先查再改 | 20 单 | −19 | 20 个人全都收到「抢到了」,库存变成负数 |
| 条件写进 UPDATE | 1 单 | 0 | 只有一个人抢到,其余收到「没货了」 |
缝在哪里
问题在于这段代码是两个独立动作:先问「还有吗」,再说「那我拿一个」。两个动作之间隔着一段时间——哪怕只有几毫秒。
第 3 章的重复签到、第 4 章的扣分透支,都是同一个形状。认出这个形状,比记住任何具体解法都重要。
解法一:把条件塞进 UPDATE
最简单也最该先想到的办法:别自己判断,让数据库在改的同时判断。
cur = conn.execute( "UPDATE items SET stock = stock - 1 " "WHERE id = 1 AND stock > 0" # ← 判断和修改是同一个动作)if cur.rowcount == 0: return False # 一行都没改到 = 没抢到
rowcount 是这个手法的关键:它告诉你实际改了几行。改到 0 行说明条件没满足,也就是没库存了。数据库保证同一行的 UPDATE 不会同时执行两次,缝就被消掉了。
你已经用过两次这个手法了:
| 第 3 章 · 防重复签到 | PRIMARY KEY (user_id, day) + 直接 INSERT,撞了就说明签过 |
| 第 4 章 · 防扣成负数 | UPDATE ... WHERE points >= ? |
| 本章 · 防超卖 | UPDATE ... WHERE stock > 0 |
三个场景,同一个思路:把「能不能做」写成数据库的条件,而不是自己先查一下。
解法二:事务 —— 要么都成,要么都不成
兑换是两件事:扣分、写记录。如果扣完分程序崩了呢?
我实测了一下,在扣分和写记录之间人为抛个异常:
| 写法 | 用户积分 | 兑换记录 | |
|---|---|---|---|
| 两次独立提交 | 100 → 70 | 0 条 | 分扣了,东西没给。用户来找你,你还查不到记录 |
| 一个事务包住 | 100(没动) | 0 条 | 完整回滚,就像什么都没发生 |
def redeem(user_id: int, item: str, cost: int) -> int | None: """扣分 + 记账,一起成功或一起失败。""" conn = sqlite3.connect(DB_PATH, timeout=10) try: conn.execute("BEGIN IMMEDIATE") # 立刻拿写锁 cur = conn.execute( "UPDATE users SET points = points - ? " "WHERE user_id = ? AND points >= ?", (cost, user_id, cost), ) if cur.rowcount == 0: conn.execute("ROLLBACK") return None # 分不够 conn.execute( "INSERT INTO redeems (user_id, item, cost, ts) " "VALUES (?, ?, ?, strftime('%s','now'))", (user_id, item, cost), ) left = conn.execute( "SELECT points FROM users WHERE user_id = ?", (user_id,) ).fetchone()[0] conn.execute("COMMIT") return left except Exception: conn.execute("ROLLBACK") raise finally: conn.close()
默认的 BEGIN 是「延迟」的:先按读锁开始,等到真要写的时候才升级成写锁。两个事务同时想升级,就会有一个直接拿到 database is locked,而且不重试。BEGIN IMMEDIATE 一上来就要写锁,拿不到会按 timeout 等着。写操作的事务用它更稳。
解法三:幂等 —— 重复执行,结果一样
前两个解法管的是「同时」。还有一类问题是「重复」:同一个操作被执行了两遍。
在 Telegram 里最常见的来源是按钮。你发一条带 inline keyboard 的消息,用户点了没反应(网络慢),他就再点一下——两次 callback 都会到你这里。
# 建表时声明:同一个操作 id 只能有一条CREATE TABLE IF NOT EXISTS done_actions ( action_id TEXT PRIMARY KEY, ts INTEGER NOT NULL)def once(conn, action_id: str) -> bool: """第一次见到这个 action_id 返回 True,之后都返回 False。""" try: conn.execute( "INSERT INTO done_actions (action_id, ts) " "VALUES (?, strftime('%s','now'))", (action_id,) ) return True except sqlite3.IntegrityError: return False # 做过了
关键是这个 action_id 怎么取。它必须能唯一标识「这一次操作」,而且重复的那次要算出同一个值:
| 按钮点击 | f"cb:{callback.id}" —— Telegram 给每次回调的 id,重发时相同 |
| 每日签到 | f"checkin:{user_id}:{day}" —— 就是第 3 章那张表在干的事 |
| 支付回调 | 支付平台给的交易号 —— 第 9 章会用到 |
按钮的两个必修动作
用 inline keyboard 时,除了幂等,还有一件事必须做:
from aiogram.types import CallbackQuery@router.callback_query(F.data.startswith("buy:"))async def on_buy(cb: CallbackQuery) -> None: await cb.answer() # ① 必须调,否则用户那边一直转圈 item = cb.data.split(":", 1)[1] ok = storage.buy_once(cb.from_user.id, item, action_id=f"cb:{cb.id}") if ok is None: await cb.answer("手慢了,没货了", show_alert=True) return # ② 把按钮撤掉,从源头减少重复点击 await cb.message.edit_reply_markup(reply_markup=None) await cb.message.answer(f"抢到了:{item}")
await cb.answer() | 不调的话用户手机上一直转圈,他会以为卡了,然后继续点。这是重复点击最大的来源。要尽早调。 |
edit_reply_markup(None) | 成功后把按钮去掉。这只是减少概率,不能代替幂等——网络慢的时候两次点击可能都已经发出去了。 |
怎么自己测出这类 bug
并发 bug 手点是点不出来的。写个小脚本,几行就能验:
import threadingimport storagestorage.init_db()# 先给测试用户 100 分results = []def hit(): results.append(storage.redeem(1, "贴纸", 30))ts = [threading.Thread(target=hit) for _ in range(10)][t.start() for t in ts][t.join() for t in ts]ok = [r for r in results if r is not None]print(f"成功 {len(ok)} 次(应为 3),余额 {storage.get_user(1)[1]}(应为 10)")
把线程数调到 10、20、50,只要结果稳定就说明扛得住。这个脚本值得留在项目里——以后改了存储逻辑,跑一次就知道有没有破坏并发安全。
如果测不出问题,在「查」和「改」之间加一句 time.sleep(0.01)。真实世界里这段时间是网络和计算耗掉的,人为放大之后 bug 立刻现形。
本章开头那个「20 单卖光 1 件库存」的实测,就是这么造出来的。
别过度紧张
不是所有代码都要防并发。判断标准很简单:
| 这段代码 | 要不要防 |
|---|---|
只读(/me、/rank) | 不用。读多少次结果都一样 |
| 写,但覆盖式(改昵称、设时区) | 不用。两个人同时改自己的,互不相干 |
| 写,且依赖当前值 | 要防。扣分、扣库存、加积分——「当前值」正是会被别人改掉的东西 |
| 一次要改多张表 | 要事务。否则会出现改了一半的中间状态 |
| 外部会重复触发 | 要幂等。按钮点击、支付回调、webhook 重推 |
这一章的要点
- 认出这个形状:先查一下,再根据查到的去改。中间必有缝
- 把条件塞进 UPDATE,用
rowcount判断成没成 —— 最简单的解法,能覆盖大多数情况 - 一次改多张表就要事务,写操作用
BEGIN IMMEDIATE - 外部会重复触发的,要幂等键:主键撞了就说明做过了
- 按钮必须
await cb.answer(),不调用户就一直转圈,然后继续点 - 并发 bug 手点测不出来,写个多线程脚本,中间加 sleep 放大缝隙
把第 4 章的兑换改成用本章的 redeem() 事务版本,加一张 redeems 表,然后写个 10 线程的测试脚本验证:100 分只能换 3 次 30 分的东西,而且兑换记录正好 3 条——不能出现「扣了分但没记录」或者反过来。
并发是新手和熟手的分水岭,看不懂很正常。具体是哪一段绕不过来?留不留联系方式都行。