TAOCARTS 知识

唯一索引在分布式代购系统中的“失灵”事故:一次订单防重实战复盘

2026-06-26 系统功能介绍

事故现场:订单量暴增,唯一索引却“形同虚设”

先交代背景。我负责的跨境代购系统(基于Taocarts架构)在去年双十一期间,订单量突然暴增——平时每天2000单,那天直接冲到6000单。按道理,我们的订单号生成策略是“时间戳+用户ID+随机数”,并且在订单表上对 `order_no` 字段建了唯一索引,理论上不会出现重复订单。

但问题来了:中午12:00到12:05这五分钟内,系统生成了17个重复订单。17个!对代购来说,这意味着客户付了两次钱,或者同一件商品被采购了两次,直接导致库存超卖、客户投诉、客服崩溃。

**适合谁看**:本文适合后端开发者、电商系统架构师,如果你只关心业务逻辑可以跳过代码部分。前置知识:熟悉MySQL索引、Redis、分布式锁基础。

根因分析:唯一索引在分布式环境下的“假象”

事故发生后,我第一时间拉出慢查询日志和业务日志,发现重复订单的 `order_no` 完全一样。这不可能啊——唯一索引不是应该拦截吗?

查了MySQL官方文档和InnoDB实现,发现一个被很多人忽略的细节:**唯一索引的防重能力,依赖于事务的隔离级别和并发控制**。

我们的订单创建流程是:

```

1. 生成order_no

2. INSERT INTO orders (order_no, ...) VALUES (?, ...)

3. 如果插入成功,继续后续操作

4. 如果插入失败(唯一索引冲突),回滚

```

看起来没问题。但在高并发下,两个请求同时生成相同的 `order_no`(概率极低但真实存在),它们同时执行 `INSERT`,InnoDB的行锁机制会让其中一个等待。**关键在于**:如果第一个事务还没提交,第二个事务的 `INSERT` 会等待,而不是立即报唯一索引冲突。

更致命的是,我们的订单号生成器是独立服务(基于Redis自增 + 时间戳),在Redis主从切换或网络抖动时,可能生成重复ID。唯一索引只能保证已提交的数据唯一,无法阻止“正在插入但未提交”的并发冲突。

修复方案:从“被动防重”到“主动锁重”

既然唯一索引在分布式环境下有盲区,那就需要在前置环节做主动控制。我选了Redis分布式锁 + 数据库唯一索引的双重保险。

核心思路:**先抢锁,再插入,锁释放后才允许下一个请求进来**。

```php

// 订单创建入口

public function createOrder(array $data): Order

{

$orderNo = $this->generateOrderNo($data['user_id']);

$lockKey = "order_lock:{$orderNo}";

$lockValue = uniqid('', true); // 唯一值,用于解锁校验

// 尝试获取锁,超时3秒

$locked = Redis::set($lockKey, $lockValue, 'NX', 'EX', 3);

if (!$locked) {

// 锁已被占用,说明有重复请求

throw new OrderDuplicateException("订单号 {$orderNo} 正在处理中");

}

try {

// 开始事务

DB::beginTransaction();

// 插入订单表(唯一索引作为兜底)

$order = Order::create([

'order_no' => $orderNo,

'user_id' => $data['user_id'],

'amount' => $data['amount'],

'status' => OrderStatus::PENDING,

]);

// 插入订单商品表

foreach ($data['items'] as $item) {

OrderItem::create([

'order_id' => $order->id,

'product_id' => $item['product_id'],

'quantity' => $item['quantity'],

'price' => $item['price'],

]);

}

DB::commit();

return $order;

} catch (\Exception $e) {

DB::rollBack();

// 唯一索引冲突(理论上不会触发,但保留兜底)

if ($e instanceof UniqueConstraintViolationException) {

Log::error("唯一索引兜底触发: {$orderNo}");

throw new OrderDuplicateException("订单号重复,请重试");

}

throw $e;

} finally {

// 释放锁(使用Lua脚本保证原子性)

$script = <<

if redis.call("GET", KEYS[1]) == ARGV[1] then

return redis.call("DEL", KEYS[1])

else

return 0

end

LUA;

Redis::eval($script, 1, $lockKey, $lockValue);

}

}

```

1. **锁的粒度**:以 `order_no` 为锁键,精确到每个订单,不影响其他订单的并发。

2. **锁的超时时间**:3秒。我们的订单创建平均耗时200ms,3秒足够。如果超时自动释放,避免死锁。

3. **唯一值校验**:`$lockValue` 用 `uniqid` 生成,释放锁时校验,防止误删其他线程的锁。

4. **数据库唯一索引作为兜底**:即使锁机制有bug,唯一索引还能拦截重复数据。

性能基准测试:加锁后的代价

加锁必然有性能损耗。我做了对比测试:

| 方案 | 平均耗时 | 99% 分位耗时 | QPS |

|||||

| 无锁(仅唯一索引) | 5ms | 30ms | 1500 |

| Redis分布式锁 | 8ms | 45ms | 1200 |

| Redis锁 + 唯一索引 | 9ms | 50ms | 1100 |

看起来QPS从1500降到1100,损失了约27%。但实际场景中,我们的峰值QPS只有200左右,完全够用。**在正确性和性能之间,正确性优先**。

更重要的是,加锁后重复订单率降到了0。优化后订单处理效率提升了3-4倍——不是速度变快了,而是不再因为重复订单而回滚重试了。

教训总结:唯一索引不是银弹

这次事故让我重新审视了“唯一索引”的定位:

1. **唯一索引是“最后一道防线”,不是“唯一防线”**。在分布式系统里,事务隔离级别、网络延迟、主从同步都会让唯一索引“失灵”。

2. **业务层防重比数据库层防重更可靠**。用Redis分布式锁、数据库乐观锁、或者分布式唯一ID生成器(如雪花算法),都能在前置环节拦截重复。

3. **不要依赖“小概率事件不会发生”**。17个重复订单,就是那个“小概率”的真实案例。

  • 订单号生成器必须保证全局唯一,推荐雪花算法或Redis自增 + 时间戳。
  • 创建订单时先加Redis锁,锁键用 `order_no`,超时时间2-3秒。
  • 数据库唯一索引保留,作为兜底。
  • 监控锁竞争情况,如果锁等待率超过1%,说明并发过高,需要扩容。
  • 这次事故让我深刻理解了一句话:**“大智知止”**——知道什么时候该停下来,比知道什么时候该加速更难。在系统设计里,知道该在哪个环节“止住”重复数据,比一味追求性能更重要。