上周五晚上10点,我们的电商系统突然收到十几笔异常报警。这些全是货到付款订单被顾客拒收后,系统自动触发的库存回退操作失败案例。最要命的是其中有3笔涉及限量版商品,客服电话已经被打爆。作为经历过多次618、双11大促的老兵,我立刻意识到这绝不是简单的代码bug,而是业务流程与系统健壮性的双重考验。
在典型的货到付款流程中,当快递员联系顾客失败或顾客明确拒收时,物流系统会通过接口通知我们ERP变更订单状态。此时系统需要完成三个关键动作:
而这次故障就发生在第二步——库存明明显示可售数量增加了,但实际仓库盘点时却发现商品"消失"了。更诡异的是,这种情况只发生在部分SKU上,且没有明显的规律。
通过elk日志分析,我们发现所有失败的库存回退操作都有以下共同特征:
关键日志片段:
php复制[2023-08-18 22:15:42] WARNING: SKU_78901 stock rollback failed
- Current stock: 15 (virtual:10, physical:5)
- Expected: 16 (virtual:11, physical:5)
这提示我们问题可能出在:
我们的库存服务采用分层设计:
code复制库存核心服务
├── 实时库存计算层(Redis)
├── 持久化存储层(MySQL)
└── 库存操作日志(MongoDB)
问题出在组合商品的库存回退逻辑上。当礼盒类商品被拒收时,系统需要:
但在代码中我们发现了一个致命缺陷:
php复制// 错误示例:缺少事务管理
function rollbackStock($mainSku, $subSkus) {
$this->redis->incr($mainSku); // 主商品库存+1
foreach ($subSkus as $sku) {
$this->mysql->query("UPDATE inventory SET stock=stock+1 WHERE sku='$sku'");
}
}
当子商品库存更新失败时,主商品库存却已经完成回退,导致数据不一致。
我们通过以下方式验证猜想:
bash复制siege -c50 -t1M "http://api/inventory/rollback?sku=TEST_123"
测试结果证实:
最终采用的解决方案包含三个层面:
代码层改进:
php复制// 使用分布式事务
try {
$this->redis->multi()
->watch($mainSku)
->incr($mainSku);
$this->db->beginTransaction();
foreach ($subSkus as $sku) {
$this->db->execute("UPDATE inventory SET stock=stock+1 WHERE sku=? FOR UPDATE", [$sku]);
}
if ($this->redis->exec()) {
$this->db->commit();
} else {
throw new Exception("Redis transaction failed");
}
} catch (Exception $e) {
$this->db->rollBack();
$this->redis->discard();
throw $e;
}
架构层改进:
运维层改进:
在分布式环境下,我们需要处理三种事务:
对于库存回退场景,我们最终采用Saga模式:
code复制1. 开始Saga
2. 冻结主商品库存(Redis)
→ 成功则继续,失败则结束
3. 扣减子商品库存(MySQL)
→ 成功则提交,失败则补偿
4. 生成操作记录(MongoDB)
5. 提交Saga
我们对比了三种方案:
| 方案 | 实现复杂度 | 性能影响 | 数据一致性 |
|---|---|---|---|
| 悲观锁(FOR UPDATE) | 低 | 高 | 强 |
| 乐观锁(version) | 中 | 中 | 最终 |
| 队列串行处理 | 高 | 低 | 强 |
最终选择:
当库存服务不可用时,我们设计了多级fallback:
对应的PHP实现:
php复制class InventoryService {
public function rollback($sku) {
try {
return $this->remoteRollback($sku);
} catch (Exception $e) {
if ($this->isDegraded()) {
$this->logRollback($sku); // 记录到本地文件
return true;
}
throw $e;
}
}
private function isDegraded() {
return file_exists('/tmp/inventory_degraded.flag');
}
}
我们采用四层灰度发布:
对应的部署checklist:
新增的Prometheus监控指标:
code复制# HELP inventory_rollback_total Total rollback requests
# TYPE inventory_rollback_total counter
inventory_rollback_total{status="success"} 0
inventory_rollback_total{status="failed"} 0
# HELP inventory_rollback_duration_seconds Rollback duration
# TYPE inventory_rollback_duration_seconds histogram
对应的Grafana看板包含:
当再次出现库存回退失败时:
一级响应(单个SKU失败)
php复制./artisan inventory:fix --sku=SKU123 --type=rollback
二级响应(批量失败)
时间戳陷阱
发现部分库存记录使用服务器时间,有些使用数据库时间。当服务器时间不同步时,导致状态判断错误。现在强制要求所有时间取数据库当前时间:
php复制$timestamp = DB::select('SELECT UNIX_TIMESTAMP() as now')[0]->now;
浮点数精度灾难
某些商品按重量计算库存,使用float类型导致累计误差。全部改为DECIMAL(10,3)存储。
缓存穿透事故
恶意构造不存在的SKU频繁查询,导致缓存失效。增加布隆过滤器防护:
php复制if (!$bloom->mightContain($sku)) {
throw new InvalidSkuException();
}
批量操作优化
将单条回退改为批量处理:
php复制// 优化前:N+1查询
foreach ($skus as $sku) {
$this->rollbackOne($sku);
}
// 优化后:批量提交
$this->db->batchRollback($skus);
连接池配置
调整MySQL连接池参数:
ini复制[database]
max_connections = 100
wait_timeout = 600
Redis管道技术
减少网络往返时间:
php复制$redis->pipeline(function($pipe) use ($skus) {
foreach ($skus as $sku) {
$pipe->incr("stock:$sku");
}
});
建议在库存相关CR时重点检查:
示例检查清单:
markdown复制- [ ] 事务边界明确
- [ ] 异常处理完整
- [ ] 日志记录详细
- [ ] 监控指标覆盖
- [ ] 性能影响评估
这次故障给我们的最大启示是:库存系统不能只考虑"happy path",必须为各种异常场景设计防御性代码。现在我们在所有库存操作入口都加上了这句警示注释:
php复制/**
* 警告:此方法可能影响财务数据
* 修改前必须:
* 1. 阅读《库存操作规范》文档
* 2. 进行并发测试
* 3. 准备回滚方案
*/