Exactly Once 语义实现

解读

国内互联网面试中,"Exactly Once" 并不是让候选人背诵 Kafka 的源码,而是考察候选人是否真正理解「业务幂等 + 系统去重」在 PHP 工程落地中的完整闭环。PHP 常用于订单、支付、营销发券等高并发写场景,一条消息如果被重复消费,就可能出现重复出库、重复放款、重复发券,直接造成 P0 级资损。因此,面试官希望听到:

  1. 你能把「最多一次」「至少一次」「恰好一次」放在 Web 请求、队列消费、分布式事务三个维度说清楚;
  2. 你能给出 PHP 侧可落地的代码级方案,而不是空谈 Kafka 的 transactional.id;
  3. 你能评估方案在并发、分库分表、故障重启时的正确性与性能,并给出降级策略。

知识点

  1. 幂等令牌(Idempotency-Key)的生成、传递与生命周期
  2. MySQL 唯一索引、乐观锁、悲观锁、分布式锁的适用边界
  3. 本地事务 + 消息表、本地事务 + Binlog 订阅、RocketMQ 事务消息三种主流模式在 PHP 下的实现差异
  4. Redis LUA 脚本的原子性以及 Redlock 的争议点
  5. PHP-FPM 无共享内存,进程重启后去重状态丢失的应对策略
  6. SAGA/TCC 与 Exactly Once 的互补关系
  7. 压测时用「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:对账与监控

  1. 定时任务对比 stock_deductorder 表,发现缺失或重复立即报警;
  2. 在 Grafana 上监控「消息重试次数>3 的订单号」,人工介入;
  3. 灰度期间打开 RocketMQ 的 trace,把 msgId 与订单号绑定写入 ELK,方便秒级定位。

通过以上五步,实现了「入口幂等 + 存储去重 + 消息重投可幂等 + 对账兜底」的完整 Exactly Once 语义,且全程不依赖 Kafka 的事务消息,完全契合国内 PHP 技术栈。

拓展思考

  1. 如果库存服务是 Java 团队维护,如何用「本地事务 + Binlog」方案解耦,同时让 PHP 侧无侵入?
  2. 当订单表做了 1024 库分片,Idempotency-Key 全局唯一索引失效,如何设计分片键+二级索引保证路由正确?
  3. 大促峰值 10w TPS,消息表成为瓶颈,如何改为「Redis 幂等 + 延迟批对账」的准实时架构,同时不丢失一致性?
  4. 如果业务允许短暂超卖,如何降级为「乐观锁 + 补偿发货」的柔性事务,把性能提升一个量级?
  5. 在 Swoole 常驻内存模式下,如何利用 Table/Channel 实现进程内去重,减少 30% 的数据库压力?