把RPC包进事务的暗坑

本书作者:测试开发刚哥,文章版权归个人所有,未经授权请勿转载。

# 方式:把判断放进 SQL,一条语句的原子性
rows = db.execute("""
    UPDATE pay_record SET status = 'PAID'
    WHERE order_no = ? AND status = 'INIT'
""", order_no)

if rows == 0:
    return "已处理"          # 别人改过了,我不是第一个

grant_coupon(order_no)       # 只有改成功的那个才发券
return "success"

grant_coupon失败会发生什么?

券永久丢失,该发没发,资损。

第一次请求:

UPDATE status: INIT → PAID   ✅ 已提交,改不回去了
grant_coupon(order_no)        ❌ 抛异常 / 超时 / 下游挂了

第二次请求:

rows = db.execute("UPDATE ... WHERE status = 'INIT'")  # status已是PAID
# rows == 0
return "已处理"   # ← 被自己的幂等逻辑拦住,不会执行grant_coupon

包进一个事务能解决吗

把update和grant_coupon这两个操作,包进一个事务里,要么同时执行,要么同时回滚,是不是就解决了?

无法解决,因为grant_coupon一般是RPC操作,数据库事务管不了。

  • RPC真的失败,DB回滚 —— 没问题
  • RPC超时(实际发券了),DB回滚 —— 重试,券重复发
  • RPC成功,但commit失败 —— 回滚,重试后券重复发

包进一个事务虽然能解决“券不发”的问题,但是会出现“券超发”的新问题。

解决方案

问题根因:status字段有两个职责,一是“支付幂等锁”,二是“发券进度标记”,发券失败时,这两个职责就冲突了。

核心思路:在本地事务里,把“要发券”持久化成一条记录,RPC交给可以重试的异步任务。

表设计

-- 已有:支付记录,status 只管"支付是否已处理"
pay_record(order_no, status, ...)

-- 新增:发券任务表,单独管"发券进度"
CREATE TABLE coupon_task (
    id            BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no      VARCHAR(64) NOT NULL,
    status        TINYINT NOT NULL DEFAULT 0,  -- 0=待发 1=已发 2=终态失败
    retry_count   INT NOT NULL DEFAULT 0,
    next_retry_at DATETIME,
    UNIQUE KEY uk_order_no (order_no)          -- 防止重复插任务
);

第一步,同一个事务处理状态管理和“要发券”

def on_pay_callback(order_no):
    with db.transaction():                      # 本地事务:两条都是本地库操作
        rows = db.execute("""
            UPDATE pay_record SET status = 'PAID'
            WHERE order_no = ? AND status = 'INIT'
        """, order_no)

        if rows == 0:
            return"已处理"                       # 幂等:别人已处理过

        # 关键:同一事务里记下"要发券"
        db.execute("""                            
            INSERT INTO coupon_task (order_no, status, next_retry_at)
            VALUES (?, 0, NOW()) 
        """, order_no)
    # 事务提交:状态改动 和 “要发券” 要么都成、要么都不成

    try_grant(order_no)                          # 快路径:立即试发一次
    return"success"

update和insert是同一个库的数据库操作,InnoDB能保证它们的原子性。

coupon_task记录了“要发券”,就永远不会丢。

第二步,发券幂等,标记完成

def try_grant(order_no):
    try:
        grant_coupon(order_no)                   # 必须幂等!靠 order_no 去重
        db.execute("""
            UPDATE coupon_task SET status = 1
            WHERE order_no = ? AND status = 0
        """, order_no)
    except Exception:
        # 失败要推进退避,否则 next_retry_at 不变,兜底任务扫到就一直撞
        db.execute("""
            UPDATE coupon_task
            SET retry_count = retry_count + 1,
                next_retry_at = DATE_ADD(NOW(), INTERVAL POW(2, retry_count) MINUTE),
                status = IF(retry_count + 1 >= 5, 2, 0)   -- 超阈值置终态失败
            WHERE order_no = ? AND status = 0
        """, order_no)

第三步,定时扫描,兜底重试

def retry_pending():
    tasks = db.query("""
        SELECT order_no FROM coupon_task
        WHERE status = 0 AND next_retry_at <= NOW() AND retry_count < 5
        LIMIT 100
    """)
    for t in tasks:
        try_grant(t.order_no)                    # 复用同一发券逻辑
    # retry_count 超阈值 → status=2 终态失败,告警人工介入

再看幂等的重要性

发券的幂等是用order_no来做的,发过券的订单不再重复发。

如果没有幂等,看这个场景:

grant_coupon(order_no)                 ✅ 券发出去了
UPDATE coupon_task SET status = 1      ❌ 这一步 DB 抖动,失败了

由于加了兜底重试,更新失败的记录,会再次被扫到执行,就又会调一次grant_coupon,重复发券。

有重试的地方,必须要考虑幂等。

总结

数据库事务只能保证本地数据库的原子性,管不了RPC。

所以不能硬把RPC塞到事务里。可以通过本地消息表或RocketMQ事务消息来解决。