把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事务消息来解决。