接到请求,不代表已经买到票
FlashFlow 的下单接口返回 202 和请求 ID。这里表达的是“请求已被接受,后续还要处理”,客户端接下来查询的是 QUEUED、COMPLETED 或 REJECTED,不能直接把 202 当作出票成功。
项目提供本地模式和 distributed 模式。本地模式通过数据库任务处理器跑通闭环;distributed 模式增加 Redis Lua 和 Kafka。讨论链路时需要先说明正在说哪种模式,不能把本地实验结果当作整套分布式系统的性能。
幂等键要绑定一份业务意图
假设用户点击购买后没有收到响应,再点击一次。若每次都生成新请求,就可能重复占用购买资格。
OrderService.submit 接收 Idempotency-Key,先按当前用户与键查已有请求。找到了就返回原记录;同一个键换了场次,则返回冲突。即使两个请求同时到达、都没在第一次查询里看到旧记录,数据库唯一约束也会挡住重复插入,再回查已存在的结果。
另一个规则是每名用户每场只能提交一次,包括已经取消或失败的请求。它与“重放同一个幂等键”是两层约束:前者限制业务资格,后者辨认网络重试。把键换一个,并不能绕开购买规则。
这个例子让我更容易回答面试学习网站里的问题:超时只是没有及时收到结果,不能据此断言操作没有执行。
为什么先持久化请求
当前链路先把请求保存为 QUEUED,再由派发器推进 Redis 准入和消息发送。如果先扣 Redis,随后进程退出,而数据库没有请求记录、消息也没发出去,就很难知道应该补发什么。
请求表同时承担持久化待处理任务的作用,重启后还能继续扫描。这也带来一项明确代价:有效接单仍然需要写数据库。因此,项目不能描述成“所有请求都在 Redis 里完成,接单完全绕开数据库”。
Lua 的原子性有作用范围
Redis 端的 reserve.lua 把库存判断、预扣和请求标记放在同一段脚本里。关于脚本执行期间的原子性,可以对照 Redis 官方 Lua 说明。它保护的是 Redis 内部这一段逻辑,并没有把 Redis、Kafka 和数据库变成同一个事务。
当前项目把 Redis 当成准入闸门。最终出票仍依靠数据库里的条件更新:
UPDATE ticket_session
SET stock = stock - 1
WHERE id = ? AND stock > 0 AND starts_at > CURRENT_TIMESTAMP;
更新行数为零时,请求被拒绝;成功时,扣库存、创建订单、更新请求结果放在同一个数据库事务中。
重复消息怎样不重复出票
消费端会在事务里对请求行执行 FOR UPDATE,然后确认状态仍是 QUEUED。另一次投递即使到达,也要在拿到锁后重新检查状态。MySQL 的锁定读与普通查询不同,相关语义见官方锁定读文档。
因此,需要配合的是事务、状态检查和唯一约束。Kafka 的重复投递没有凭空消失,只是重复投递不应再次产生同一张业务订单。
我会用下面四个场景验证这一段设计:同键重试返回同一请求;同键改场次返回冲突;重复消费只创建一个订单;库存不足时不能产生负库存。这些比一句“我们用了幂等”更容易说明具体保证。
支付接口目前是模拟支付,不是接入真实支付网关。库存取消和超时回补还要面对另一类问题:数据库更新成功之后,Redis 释放失败怎么办?这部分放到持久化任务与租约继续记录。
源码阅读可以从 OrderService.java、OrderWorker.java 以及 reserve.lua 进入,先画出状态迁移,再看每一步在哪个存储系统里完成。