Exactly Once 语义实现
解读
国内互联网面试中,"Exactly Once" 并不是让候选人背诵 Kafka 的源码,而是考察候选人是否真正理解「业务幂等 + 系统去重」在 PHP 工程落地中的完整闭环。PHP 常用于订单、支付、营销发券等高并发写场景,一条消息如果被重复消费,就可能出现重复出库、重复放款、重复发券,直接造成 P0 级资损。因此,面试官希望听到:
- 你能把「最多一次」「至少一次」「恰好一次」放在 Web 请求、队列消费、分布式事务三个维度说清楚;
- 你能给出 PHP 侧可落地的代码级方案,而不是空谈 Kafka 的 transactional.id;
- 你能评估方案在并发、分库分表、故障重启时的正确性与性能,并给出降级策略。
知识点
- 幂等令牌(Idempotency-Key)的生成、传递与生命周期
- MySQL 唯一索引、乐观锁、悲观锁、分布式锁的适用边界
- 本地事务 + 消息表、本地事务 + Binlog 订阅、RocketMQ 事务消息三种主流模式在 PHP 下的实现差异
- Redis LUA 脚本的原子性以及 Redlock 的争议点
- PHP-FPM 无共享内存,进程重启后去重状态丢失的应对策略
- SAGA/TCC 与 Exactly Once 的互补关系
- 压测时用「gorace」或「tcpcopy」模拟重复请求,验证方案有效性
答案
【场景】Laravel 项目,用户提交订单后发送「库存扣减」消息到 RocketMQ,要求消息被恰好消费一次,且库存不能多扣。
步骤 1:入口幂等
前端在订单确认页调用 /order/confirm 时,后端生成 UUID 作为 Idempotency-Key 并写入 Cookie,同时返回给前端。后续同一浏览器的重试必须带上该 Key。
步骤 2:订单落库去重
订单表对 idempotency_key 建唯一索引。Service 层用「悲观锁」方式:
DB::transaction(function () use ($userId, $skuId, $idempotencyKey) {
$order = Order::where('idempotency_key', $idempotencyKey)->lockForUpdate()->first();
if ($order) {
return $order; // 已处理,直接返回
}
$order = Order::create([
'user_id' => $userId,
'sku_id' => $skuId,
'status' => ' unpaid',
'idempotency_key' => $idempotencyKey,
]);
return $order;
});
唯一索引兜底,万一并发绕过了 SELECT,INSERT 会抛 DuplicateEntry,catch 后再次查询即可。
步骤 3:本地事务 + 消息表
在同一个事务里把业务单据和「待发送消息」一起落库:
$msg = [
'topic' => 'stock_deduct',
'payload' => json_encode(['order_id' => $order->id, 'sku_id' => $skuId, 'num' => 1]),
'status' => 'pending',
];
MessageBox::create($msg);
事务提交后,PHP 进程异步投递:Laravel Queue 启动一个常驻进程,批量扫描 status='pending' 的消息,调用 RocketMQ SDK 发送,发送成功置 status='sent'。如果进程 crash,重启后重新扫描,保证「至少一次」。
步骤 4:消费端去重
库存服务消费时,用订单号 + SKU 维度做幂等:
DB::transaction(function () use ($orderId, $skuId, $num) {
$deduct = StockDeduct::where(['order_id' => $orderId, 'sku_id' => $skuId])
->lockForUpdate()->first();
if ($deduct) {
return true; // 已扣减
}
$stock = Stock::where('sku_id', $skuId)->lockForUpdate()->first();
if ($stock->available < $num) {
throw new InsufficientStockException();
}
$stock->decrement('available', $num);
StockDeduct::create(['order_id' => $orderId, 'sku_id' => $skuId, 'num' => $num]);
});
唯一索引 (order_id, sku_id) 再次兜底。
步骤 5:对账与监控
- 定时任务对比
stock_deduct与order表,发现缺失或重复立即报警; - 在 Grafana 上监控「消息重试次数>3 的订单号」,人工介入;
- 灰度期间打开 RocketMQ 的 trace,把 msgId 与订单号绑定写入 ELK,方便秒级定位。
通过以上五步,实现了「入口幂等 + 存储去重 + 消息重投可幂等 + 对账兜底」的完整 Exactly Once 语义,且全程不依赖 Kafka 的事务消息,完全契合国内 PHP 技术栈。
拓展思考
- 如果库存服务是 Java 团队维护,如何用「本地事务 + Binlog」方案解耦,同时让 PHP 侧无侵入?
- 当订单表做了 1024 库分片,Idempotency-Key 全局唯一索引失效,如何设计分片键+二级索引保证路由正确?
- 大促峰值 10w TPS,消息表成为瓶颈,如何改为「Redis 幂等 + 延迟批对账」的准实时架构,同时不丢失一致性?
- 如果业务允许短暂超卖,如何降级为「乐观锁 + 补偿发货」的柔性事务,把性能提升一个量级?
- 在 Swoole 常驻内存模式下,如何利用 Table/Channel 实现进程内去重,减少 30% 的数据库压力?