幂等、补偿事务与重复投递:把未知结果关进状态机
正在读取学习记录…
补偿是对已经发生的业务动作执行相反的业务动作。预订名额后扣了款,出票却失败,就需要退款并释放名额。退款可能产生新的流水,名额也可能已经变化,因此补偿需要遵守业务规则,逐步确认完成情况。
幂等处理重复同一个请求时的效果。补偿处理的是前面成功、后面失败的跨步骤流程。两者常常同时出现:预订请求可能重复投递,退款补偿也可能重复投递,正向与反向动作都要识别同一次业务意图。
运行一次出票失败后的恢复
固定初始值是两个名额、500元余额。一次120元预订先占一个名额再扣款,同一预订再投递一次。出票阶段用固定失败触发补偿,第一次退款暂不可用,第二次恢复,随后再重复补偿一次。
forward 保存业务键与金额,journal 记录成功步骤,undone 记录已完成的补偿。这三份数据分别回答“请求是哪一次”“做过哪些事”“撤销到哪里”。
保存完整程序并运行
先在 PowerShell 创建并进入学习目录:
mkdir "F:\学习\l4-compensation" -Force
cd "F:\学习\l4-compensation"保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。
新建 F:\学习\l4-compensation 文件夹,把下面完整代码保存为 compensation_demo.py,然后在PowerShell运行:
cd "F:\学习\l4-compensation"
ls
python -X utf8 compensation_demo.py先在 PowerShell 创建并进入学习目录:
mkdir "F:\学习\l4-compensation" -Force
cd "F:\学习\l4-compensation"保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。
"""预订失败后按逆序退款、释放名额,并安全重复处理补偿。"""
class Booking:
def __init__(self):
self.seats = 2
self.balance = 500
self.forward = {}
self.undone = set()
self.journal = []
def reserve(self, key, amount):
# 同一业务键绑定同一金额,重复投递不会再次占位或扣款。
if key in self.forward:
if self.forward[key] != amount:
raise ValueError("业务键与金额冲突")
return
if type(amount) is not int or amount <= 0:
raise ValueError("金额应为正整数")
if self.seats < 1 or self.balance < amount:
raise ValueError("名额或余额不足")
self.forward[key] = amount
self.seats -= 1
self.journal.append((key, "seat"))
self.balance -= amount
self.journal.append((key, "charge"))
def compensate(self, key, *, fail_refund=False):
# 从后向前撤销已经成功的步骤,并分别记录补偿完成状态。
for saved_key, step in reversed(self.journal):
if saved_key != key or (key, step) in self.undone:
continue
if step == "charge":
if fail_refund:
return "compensation_pending"
self.balance += self.forward[key]
else:
self.seats += 1
self.undone.add((key, step))
return "compensated"
def run_demo():
booking = Booking()
booking.reserve("booking-42", 120)
booking.reserve("booking-42", 120)
lines = [f"重复预订后: 名额={booking.seats}, 余额={booking.balance}"]
# 固定模拟出票失败;之前的预订、扣款已经发生。
lines.append("出票失败: 开始逆序补偿")
status = booking.compensate("booking-42", fail_refund=True)
lines.append(f"退款服务暂不可用: {status}, 名额={booking.seats}, 余额={booking.balance}")
status = booking.compensate("booking-42")
lines.append(f"恢复后: {status}, 名额={booking.seats}, 余额={booking.balance}")
booking.compensate("booking-42")
lines.append(f"重复补偿后: 名额={booking.seats}, 余额={booking.balance}")
return lines
if __name__ == "__main__":
print("\n".join(run_demo()))完整固定输出如下:
重复预订后: 名额=1, 余额=380
出票失败: 开始逆序补偿
退款服务暂不可用: compensation_pending, 名额=1, 余额=380
恢复后: compensated, 名额=2, 余额=500
重复补偿后: 名额=2, 余额=500为什么第一次失败后仍保留名额
第一行余额为380、名额为1,说明同一预订投递两次只占一次名额、扣一次款。第三行返回 compensation_pending,表示退款待恢复;金额和名额保持原状。第四行退款成功后释放名额,恢复到2和500;最后一行重复补偿没有多退钱。
compensate 从 journal 的尾部向前遍历。正向顺序是占位、扣款,反向顺序是退款、释放名额。固定规则要求先退款再放位,因此退款失败立即停在这个步骤。如果业务允许独立释放,也可以采用另一条补偿顺序,但必须把这条规则写出来。
给正向动作和补偿各自一个标识
reserve 使用预订键识别同一意图,同键同金额直接返回,同键不同金额拒绝。用户想再买一张同价票时应使用新键。补偿则使用 (key, step) 识别反向动作,完成退款后把 ("booking-42", "charge") 存入集合;再次恢复时跳过已退款步骤。
外部退款服务需要自己的幂等键,例如 booking-42/refund。仅在本地集合记“已退款”无法消除崩溃窗口:外部服务可能退款成功,本地记录尚未写入。如果响应丢失,需要复用同一退款键或查退款流水,不能直接发起一笔新退款。
补偿失败时任务停在哪里
compensation_pending 是明确的业务状态,后台工作者可以稍后继续,超过恢复时限则交给人工处理。记录至少包含原订单、正向回执、补偿类型、补偿回执和下一次恢复时间。补偿成功后才能报告流程已撤销,不能把“已发出退款请求”当成“退款已到账”。
示例数据都在一个进程内。真实跨服务流程要在每一步成功后持久保存记录,并根据服务契约区分已失败、已成功和结果未知。若外部动作失败前未产生效果,不应登记成功步骤;若结果未知,先查明是否需要补偿。补偿也不能撤销已经发送给用户的信息,只能再发送更正通知。
重复投递怎样影响整个流程
把同一预订再次投递到正向入口,当前代码返回已有处理,不会自动重新预订已补偿的订单。若用户决定重新购买,这是新的业务意图,应产生新键和新记录。这让“恢复旧流程”与“发起新流程”保持可辨认。
配套测试检查正向去重、金额冲突、余额不足、补偿暂失败、恢复后结果和重复补偿。它们检查余额与名额的具体值,而不以“函数没抛异常”代替业务结论。关于服务端如何绑定业务意图,可以继续读幂等 API 设计。
改一处输入,核对机制变化
在 run_demo 中将两次 reserve("booking-42", 120) 的120一起改为100,运行 python -X utf8 compensation_demo.py。重复预订后余额变为400,第一次退款失败仍为400;恢复补偿后余额回到500、名额回到2。两处一起恢复120再重跑。
运行配套测试
将下面测试保存为同一文件夹的 test_compensation_demo.py。它使用固定场景检查状态与业务结果,可以直接运行:
python -X utf8 -m unittest -v test_compensation_demo.py完整测试:test_compensation_demo.py
import unittest
from compensation_demo import Booking, run_demo
class CompensationTests(unittest.TestCase):
def test_duplicate_forward_and_compensation(self):
booking = Booking()
booking.reserve("k", 120)
booking.reserve("k", 120)
self.assertEqual((booking.seats, booking.balance), (1, 380))
self.assertEqual(booking.compensate("k"), "compensated")
booking.compensate("k")
self.assertEqual((booking.seats, booking.balance), (2, 500))
def test_failed_refund_preserves_progress(self):
# 退款失败时尚不能释放名额;恢复后分别登记两个反向步骤。
booking = Booking()
booking.reserve("k", 120)
self.assertEqual(booking.compensate("k", fail_refund=True), "compensation_pending")
self.assertEqual(booking.undone, set())
self.assertEqual((booking.seats, booking.balance), (1, 380))
booking.compensate("k")
self.assertEqual(booking.undone, {("k", "charge"), ("k", "seat")})
def test_key_conflict_and_insufficient_balance(self):
booking = Booking()
booking.reserve("k", 120)
with self.assertRaises(ValueError):
booking.reserve("k", 130)
with self.assertRaises(ValueError):
booking.reserve("other", 400)
def test_demo(self):
self.assertEqual(len(run_demo()), 5)
if __name__ == "__main__":
unittest.main()课后练习展开习题收起习题
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
objective · 幂等、补偿事务与重复投递
围绕 L4-compensation 说明一个可验证的工程做法。
对应知识点:L4-compensation、L4-retry
short_answer · 幂等、补偿事务与重复投递
围绕 L4-compensation 说明一个可验证的工程做法。
对应知识点:L4-compensation、L4-retry
coding · 幂等、补偿事务与重复投递
围绕 L4-compensation 说明一个可验证的工程做法。
对应知识点:L4-compensation、L4-retry
scenario · 幂等、补偿事务与重复投递
围绕 L4-compensation 说明一个可验证的工程做法。
对应知识点:L4-compensation、L4-retry
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。