botfromzero
本章目录
    Telegram 机器人实战 第 4 部分 · 会让你翻车的地方

    第 6 章

    两个人同时抢最后一个名额

    1 件库存,20 个人同时点——我实测的结果是卖出 20 单,库存变成 −19。这类 bug 平时测不出来,一上线就出事。

    约 24 分钟新手和熟手的分水岭aiogram 3.x · 最后验证 2026-08
    01

    先看一眼后果

    假设你搞个活动:限量 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 单−1920 个人全都收到「抢到了」,库存变成负数
    条件写进 UPDATE1 单0只有一个人抢到,其余收到「没货了」
    这不是压测出来的极端情况。群里发个「限量 1 份」,几十个人同时点,就是这个场面。
    02

    缝在哪里

    问题在于这段代码是两个独立动作:先问「还有吗」,再说「那我拿一个」。两个动作之间隔着一段时间——哪怕只有几毫秒。

    小 A 小 B 查:还有 1 件 查:还有 1 件 扣:变成 0 扣:变成 −1 缝就在这段时间里 两个人都在库存还是 1 的时候查过了,于是都认为自己能买
    这类 bug 的特征是平时测不出来:你一个人点,两个动作之间没有别人插进来,看着完全正常。
    凡是「先查一下,再根据查到的结果去改」,中间就有缝。

    第 3 章的重复签到、第 4 章的扣分透支,都是同一个形状。认出这个形状,比记住任何具体解法都重要。

    03

    解法一:把条件塞进 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

    三个场景,同一个思路:把「能不能做」写成数据库的条件,而不是自己先查一下。

    04

    解法二:事务 —— 要么都成,要么都不成

    兑换是两件事:扣分、写记录。如果扣完分程序崩了呢?

    我实测了一下,在扣分和写记录之间人为抛个异常:

    写法用户积分兑换记录
    两次独立提交100 → 700 条分扣了,东西没给。用户来找你,你还查不到记录
    一个事务包住100(没动)0 条完整回滚,就像什么都没发生
    storage.py · 一个事务里做完
    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 IMMEDIATE 而不是 BEGIN

    默认的 BEGIN 是「延迟」的:先按读锁开始,等到真要写的时候才升级成写锁。两个事务同时想升级,就会有一个直接拿到 database is locked,而且不重试
    BEGIN IMMEDIATE 一上来就要写锁,拿不到会按 timeout 等着。写操作的事务用它更稳。

    05

    解法三:幂等 —— 重复执行,结果一样

    前两个解法管的是「同时」。还有一类问题是「重复」:同一个操作被执行了两遍。

    在 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 章会用到
    06

    按钮的两个必修动作

    用 inline keyboard 时,除了幂等,还有一件事必须做:

    handlers.py
    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)成功后把按钮去掉。这只是减少概率,不能代替幂等——网络慢的时候两次点击可能都已经发出去了。
    07

    怎么自己测出这类 bug

    并发 bug 手点是点不出来的。写个小脚本,几行就能验:

    test_race.py · 不需要 Telegram
    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 件库存」的实测,就是这么造出来的。

    08

    别过度紧张

    不是所有代码都要防并发。判断标准很简单:

    这段代码要不要防
    只读(/me/rank不用。读多少次结果都一样
    写,但覆盖式(改昵称、设时区)不用。两个人同时改自己的,互不相干
    写,且依赖当前值要防。扣分、扣库存、加积分——「当前值」正是会被别人改掉的东西
    一次要改多张表要事务。否则会出现改了一半的中间状态
    外部会重复触发要幂等。按钮点击、支付回调、webhook 重推

    这一章的要点

    1. 认出这个形状:先查一下,再根据查到的去改。中间必有缝
    2. 把条件塞进 UPDATE,用 rowcount 判断成没成 —— 最简单的解法,能覆盖大多数情况
    3. 一次改多张表就要事务,写操作用 BEGIN IMMEDIATE
    4. 外部会重复触发的,要幂等键:主键撞了就说明做过了
    5. 按钮必须 await cb.answer()不调用户就一直转圈,然后继续点
    6. 并发 bug 手点测不出来,写个多线程脚本,中间加 sleep 放大缝隙
    ✓ 试着改一个

    把第 4 章的兑换改成用本章的 redeem() 事务版本,加一张 redeems 表,然后写个 10 线程的测试脚本验证:100 分只能换 3 次 30 分的东西,而且兑换记录正好 3 条——不能出现「扣了分但没记录」或者反过来。

    这一章有点难,哪里卡住了?

    并发是新手和熟手的分水岭,看不懂很正常。具体是哪一段绕不过来?留不留联系方式都行。