唯一索引在分布式代购系统中的“失灵”事故:一次订单防重实战复盘
事故现场:订单量暴增,唯一索引却“形同虚设”
先交代背景。我负责的跨境代购系统(基于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个重复订单,就是那个“小概率”的真实案例。 这次事故让我深刻理解了一句话:**“大智知止”**——知道什么时候该停下来,比知道什么时候该加速更难。在系统设计里,知道该在哪个环节“止住”重复数据,比一味追求性能更重要。性能基准测试:加锁后的代价
教训总结:唯一索引不是银弹