欢迎光临
我们一直在努力

一次超卖 37 件的复盘:LikeShop 单商户高级版 SKU 体系的规格映射、库存扣减与并发

0. 写在前面:为什么值得单独写一篇 SKU

做过商城的人都有一个错觉:商品模块是 CRUD,订单模块才是核心。

直到某天大促,一个库存 200 件的爆款卖出了 237 件,客服群里开始出现"下单成功但仓库没货"的截图;直到运营改了商品规格,历史订单的售后页直接 500;直到你发现同一件衣服,用户选"红色 / L"和选"L / 红色"命中的是两个不同的 SKU,一个显示有货、一个显示售罄。

这些问题的共同点是:它们都不在订单模块里,而是从商品模型里长出来的。SKU 体系是整个交易链路的地基——价格从它来,库存挂在它上面,订单行指向它,售后、退款、对账、WMS 对接全部以它为最小单位。地基歪一寸,上面每一层都要花十倍的成本去补。

本文讨论的对象是 LikeShop 单商户高级版。单商户形态有一个容易被忽略的特征:没有商家维度。ls_ 前缀的表里不存在 merchant_id,平台自己就是唯一的卖家。这带来两个直接后果:

  • 好处是模型简单,库存、订单、结算不需要做租户隔离,锁粒度和对账口径都很干净;
  • 坏处是流量不分散。多商户系统里十个商家各自承接 10% 的流量,单商户系统里 100% 的流量打在同一个 goods_item 行上。热点行竞争反而更尖锐,秒杀、拼团、砍价、普通订单共享同一份库存,冲突面更大。

所以这篇文章不打算写成"LikeShop 源码导读",而是把它当成一个真实的生产系统来审视:它的模型哪里是对的,哪里在二开时一定会出问题,以及怎么用最小的改动补上。

说明:文中表名、字段名以 LikeShop 单商户版的 ls_ 前缀表结构为准,不同小版本可能存在字段差异,请以你手上的数据库字典为最终依据。凡标注【扩展】的字段,是我建议在二开中新增的,并非原生字段。

1. 事故复盘:三个 bug,一个根因

先把那次超卖摊开讲,因为它几乎覆盖了 SKU 体系所有典型的失败模式。

现象:某单品 3 个颜色 × 4 个尺码,共 12 个 SKU,主推款(黑色 L)库存 200。活动开始 8 分钟售罄,但订单行汇总卖出了 237 件,库存字段变成 -37。

事后从日志里还原出三条独立的缺陷链:

缺陷一:规格顺序不一致,SKU 匹配错位。

前端在选择规格时,用户的操作顺序是"先点颜色、再点尺码"或"先点尺码、再点颜色",两种顺序拼出来的 spec_value_ids 分别是 12,35 和 35,12。而当时的匹配逻辑是拿这个字符串直接去 ls_goods_item 里做等值查询。结果同一组规格命中了两条不同的 SKU 记录——价格一样,库存不一样。用户买的是"黑色 L",系统扣的是"黑色 M"的库存。这不是超卖,但它是超卖的前置条件:库存账目从这一刻起就不可信了。

缺陷二:先查后改,TOCTOU。

扣减逻辑写成了这样:

$stock = Db::name('goods_item')->where('id', $itemId)->value('stock');
if ($stock >= $num) {
Db::name('goods_item')->where('id', $itemId)->setDec('stock', $num);
}

SELECT 和 UPDATE 之间没有任何互斥。100 个并发请求同时读到 stock = 1,全部通过判断,全部执行 stock = stock – 1。setDec 本身是原子的,但它原子地执行了 100 次减法,最终库存是 -99。判断和修改被拆成了两步,原子性就丢在了两步之间。

缺陷三:取消订单重复回补,把负库存"洗白"了。

超时未支付关单的定时任务被部署了两个实例(一次扩容后忘了下线旧容器)。同一笔订单被关单两次,库存回补两次。于是负库存被虚增的库存掩盖,日常巡检看总量居然是"正常"的,直到大促结束做全量对账才暴露。

根因只有一个:我们把"库存"当成了一个可以随便加减的数字,而不是一本只能通过受控凭证记账的账本;把"读-判断-写"当成了一个可以跨越请求边界的安全操作。

后面所有的方案,本质上都在回答两个问题:

  • 如何保证"一组规格"永远只对应"一个 SKU"?(映射的唯一性)
  • 如何保证"库存的每一次变化"都可原子、可幂等、可追溯?(记账的严谨性)
  • 2. 先把问题定义清楚

    在动手改表之前,值得把 SKU 体系的职责边界写清楚。它其实只做四件事:

    职责输入输出出错的表现
    表达 规格名、规格值 一个规格空间 规格组合丢失、无法扩展
    定位 用户选择的规格值集合 唯一的 item_id 选 A 扣 B、同组合多 SKU
    定价 item_id + 会员等级 + 活动 成交价 前端传价、价格被篡改
    记账 item_id + 数量 + 业务单号 库存增减 + 流水 超卖、少卖、重复回补

    四个职责里,"定位"是最容易被低估的。大部分人认为规格映射就是"把用户选的值拼起来查一下",但真正难的是三件事:

    • 组合的空间是笛卡尔积,规格一多就是组合爆炸(3 色 × 4 码 × 2 材质 = 24 个 SKU,再加一个"版型"就是 48 个);
    • 组合的表示必须与顺序无关,否则同一组规格会有多种写法;
    • 组合必须在数据库层唯一,否则并发创建 SKU(商家疯狂点保存)会写出重复行。

    而"记账"最容易被简化成一句 setDec。可库存本质上是有价资产,任何一次增减都必须回答:谁改的、因为哪张单据、改之前多少、改之后多少、失败了怎么退回。这四个问题答不上来,对账就永远对不平。

    基于这两点,我先给出贯穿全文的三条原则,后面所有设计都是它们的推论:

    原则一(标识不可变):item_id 一旦生成,永不改变、永不复用。价格可以改,库存可以改,规格文本可以改,但 id 不行。

    原则二(账本可追溯):库存字段是"账本的当前余额",不是"真相"。真相是流水表,余额只是流水的一个物化视图。

    原则三(状态单向):订单状态、锁定状态只能沿着状态机迁移,任何"回补"动作都必须先完成状态迁移,再做库存操作。

    3. 数据模型:三层结构与它的代价

    3.1 经典三层:SPU — 规格定义 — SKU

    LikeShop 的商品模型是电商领域最经典的三层结构:

    • SPU(Standard Product Unit):ls_goods,描述"这是一件什么商品",管标题、主图、详情、分类、品牌、上下架;
    • 规格定义:ls_goods_spec(规格名,如"颜色"“尺码”)+ ls_goods_spec_value(规格值,如"黑色"“L”),描述"这件商品可以从哪些维度被选择";
    • SKU(Stock Keeping Unit):ls_goods_item,描述"具体到某一个组合的可售卖单元",管价格、库存、条码、SKU 图。

    这个结构的价值在于把"展示"和"交易"分开:详情页展示 SPU 级别的富文本和轮播图,加购和下单只认 SKU。所有交易数据——购物车、订单行、售后单、库存流水——外键都指向 item_id,而不是 goods_id。这一点非常关键,因为它决定了库存的最小粒度。

    3.2 关键表结构

    商品主表(节选,只保留与本文相关的字段):

    CREATE TABLE `ls_goods` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `name` varchar(120) NOT NULL DEFAULT '' COMMENT '商品名称',
    `code` varchar(64) NOT NULL DEFAULT '' COMMENT '商品编码',
    `spec_type` tinyint NOT NULL DEFAULT 0 COMMENT '0单规格 1多规格',
    `status` tinyint NOT NULL DEFAULT 0 COMMENT '-1回收站 0下架 1上架',
    `delete_time` int NOT NULL DEFAULT 0 COMMENT '软删除时间戳',
    PRIMARY KEY (`id`),
    KEY `idx_status` (`status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品主表';

    SKU 表(在原表基础上补了三个【扩展】字段):

    CREATE TABLE `ls_goods_item` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `goods_id` int unsigned NOT NULL COMMENT '所属商品',
    `spec_value_ids` varchar(255) NOT NULL DEFAULT '' COMMENT '规格值ID,逗号分隔,顺序不保证',
    `spec_value_str` varchar(255) NOT NULL DEFAULT '' COMMENT '规格值文本快照,如 黑色,L',
    `spec_key` varchar(64) NOT NULL DEFAULT '' COMMENT '【扩展】规范化规格键',
    `sku_code` varchar(64) NOT NULL DEFAULT '' COMMENT '【扩展】商家自编码/WMS编码',
    `sell_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '售价',
    `lineation_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '划线价',
    `cost_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '成本价',
    `stock` int NOT NULL DEFAULT 0 COMMENT '可售库存(有符号)',
    `lock_stock` int NOT NULL DEFAULT 0 COMMENT '【扩展】锁定库存',
    `version` int NOT NULL DEFAULT 0 COMMENT '【扩展】版本号',
    `volume` int NOT NULL DEFAULT 0 COMMENT '销量',
    `status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_goods_spec` (`goods_id`, `spec_key`),
    KEY `idx_goods_status` (`goods_id`, `status`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU表';

    注意几个刻意的选择:

    stock 用有符号 int,不用 int unsigned。 无符号列在执行 stock = stock – n 且结果小于 0 时,在严格模式下会直接抛 BIGINT UNSIGNED value is out of range,把一个业务判断变成一次 SQL 异常;非严格模式下更糟,会回绕成一个巨大的正数。用有符号列 + WHERE stock >= n 的条件,让"扣不动"表现为"影响行数为 0",这是最干净的业务信号。负库存如果真的出现了,也应该是可以被记录、被告警、被修正的事实,而不是被数据库直接拒绝写入的黑洞。

    spec_value_ids 保留原样,但不再作为业务键。 它是历史兼容字段,前端展示、后台列表、导出都还在用,删不得。新增的 spec_key 才是唯一索引的依据。

    uk_goods_spec 这个唯一索引,是整个 SKU 体系里性价比最高的一行 DDL。 它把"同一商品下不允许出现两个相同规格组合"这条业务规则,从"应用层记得校验"变成了"数据库拒绝写入"。并发创建、脚本导入、后台复制商品,任何路径都绕不过去。

    3.3 逗号串的利与弊:一次诚实的权衡

    spec_value_ids 用逗号分隔的字符串存规格值 ID,社区里批评很多。但先别急着否定它——在 LikeShop 的体量假设下,这个设计是有道理的。

    它换来了什么:

    • 详情页一次查询就能拿到 SKU 的全部信息,不需要 goods_item JOIN spec_value 再 GROUP,省掉一次多表关联;
    • 写入简单,商家保存商品时整批重建 SKU,不用维护关联表的一致性;
    • 前端拿到的就是"可展示的字符串",spec_value_str 直接渲染,少一次规格值名称的翻译。

    它付出了什么:

  • 顺序敏感,无法用 SQL 精确匹配。 spec_value_ids = '12,35' 和 '35,12' 在数据库里是两个不同的值。用 FIND_IN_SET 也只能做"包含"判断,做不了"集合相等",而且必然导致索引失效,退化成全表扫描。
  • 无法表达"集合相等"的唯一约束。 你可以给 (goods_id, spec_value_ids) 建唯一索引,但那只在字符串完全相同时才生效——顺序不同的重复 SKU 依然能写进去。这就是缺陷一的数据库层根源。
  • 无法反查。 "所有包含’黑色’的 SKU"这种需求,只能 LIKE '%12%',而 12 会误命中 112、120,典型的前缀匹配陷阱。
  • 规格值改名后语义丢失。 商家把"藏青"改成"深蓝",spec_value_ids 没变,但字符串里的文本已经对不上历史订单了——所以必须靠 spec_value_str 做快照,而快照一旦生成就不再更新。
  • 组合爆炸带来的是 SKU 行数膨胀,不是字符串膨胀。 五个维度、每个维度三个值,就是 243 个 SKU,每个 SKU 一行价格、库存、图片,后台保存一次要写 243 行;而逗号串本身反而还好——5 个 3 位 ID 加分隔符也就 20 来个字符。所以真正要控制的是维度数和 SKU 数,不是字符串长度。
  • 结论:这不是一个"错"的设计,而是一个"在 SKU 数量有限(单品通常 ≤ 200)、查询以 goods_id 为主键路径"的前提下成立的取舍。真正的问题不是逗号串本身,而是我们把它当成了业务键。正确的做法是"双键并存":spec_value_ids 继续做展示和兼容,spec_key 做业务唯一键。

    3.4 补上三样东西

    第一样:spec_key——规范化后的规格键。

    规则只有一句话:把规格值 ID 按数值升序排序,再用固定分隔符拼接。

    原始:35,12 → 排序后:12,35
    原始:12,35 → 排序后:12,35

    排序之后,同一个规格组合只有一种字符串表示。再配合 UNIQUE (goods_id, spec_key),重复 SKU 在数据库层就不可能存在。如果维度特别多、拼接后超过 64 字符,可以对规范化结果取 crc32 或 md5 的前 16 位,但要在注释里写清楚"仅用于唯一约束,不可逆"。

    第二样:version——版本号。

    它有两个用途:后台批量调库存时的乐观锁;以及排查问题时能判断"这一行在我的事务期间有没有被别人改过"。注意它不能替代 WHERE stock >= n 的条件更新,两者解决的不是同一个问题(后面 6.4 节会展开)。

    第三样:sku_code——商家自编码。

    只要系统要和 WMS、ERP、线下 POS 对接,就一定需要一个商家自己的编码。让它在 ls_goods_item 上有独立字段并建唯一索引——注意空串会参与唯一约束,同一个商品下只能有一个 SKU 的 sku_code 为空串,所以这个字段要允许 NULL(MySQL 的唯一索引允许多个 NULL),写入时把"没填"统一存成 NULL,不要存空串。这一条能省掉后面无数的对照表。

    3.5 索引与查询路径

    SKU 表的查询路径只有三条,索引照着这三条建就够了:

    查询路径触发场景索引
    goods_id + status 详情页拉全部 SKU、后台编辑 idx_goods_status
    goods_id + spec_key 用户选择规格 → 定位 SKU uk_goods_spec
    id 加购、下单、扣库存 主键

    不要给 stock 建索引。 它是高频更新列,每次 UPDATE 都会带来索引维护;而且 stock > 0 这种条件的区分度极低,优化器基本不会用。真要查"缺货商品",用后台的离线统计任务,不要放在交易链路上。

    在 LikeShop 的实际查询里,还有一条隐含路径值得注意:订单列表和售后页会通过 item_id 反查 SKU 信息。这条路径必须走主键,不能通过 spec_value_ids 去匹配——一旦写成 WHERE spec_value_ids = ?,就是慢查询的开始。

    4. 规格映射:从"用户点了三个按钮"到"一个 item_id"

    4.1 笛卡尔积必须由后端生成

    很多二开项目的前端是这样做的:拿到规格列表后,用 JS 生成所有组合,再和 SKU 列表做匹配,匹配不上就显示"该组合不存在"。逻辑上没错,但安全边界错了。

    前端传来的任何东西都不可信。攻击者可以构造一个 item_id + price 的请求,如果你直接用前端价格下单,就是把定价权交给了浏览器。正确的链路是:

  • 前端只传 goods_id 和"选了哪几个规格值"(或者只传 item_id);
  • 后端按 goods_id 重新拉取规格定义和 SKU 列表;
  • 后端自己算组合、自己定位 item_id、自己从数据库取价格;
  • 前端传来的 item_id 只作为"期望值"做一次校验——它必须属于这个 goods_id,否则直接拒绝。
  • 组合生成就是一个标准的笛卡尔积,用迭代比递归更省栈、也更好读:

    /**
    * 由规格值集合生成全部组合
    * @param array $dimensions [[12,13], [35,36,37]] 每个子数组是一个维度的值ID
    * @return array [[12,35],[12,36],[12,37],[13,35],…]
    */

    function cartesian(array $dimensions): array
    {
    $result = [[]];
    foreach ($dimensions as $values) {
    $next = [];
    foreach ($result as $combo) {
    foreach ($values as $vid) {
    $next[] = array_merge($combo, [$vid]);
    }
    }
    $result = $next;
    }
    return $result;
    }

    3 × 4 = 12 个组合,一次性算完是微秒级的。真正需要警惕的是组合爆炸:如果商家加了 5 个维度、每个维度 4 个值,就是 1024 个 SKU,前端渲染和后台保存都会开始卡。工程上的处理是给维度数和 SKU 数设上限(比如单商品 ≤ 6 维、≤ 500 个 SKU),超限时在后台提示"请拆分商品",而不是让系统在某个下午突然 OOM。

    4.2 规范化:让"顺序"从变量里消失

    生成组合之后,立刻做一次规范化,得到 spec_key:

    function specKey(array $valueIds): string
    {
    sort($valueIds, SORT_NUMERIC); // 关键:数值升序,消除顺序差异
    return implode('_', $valueIds); // 12_35
    }

    排序这一行代码,消灭了三类问题:

    • 用户点击顺序不同导致匹配失败;
    • 同一组合被重复创建(配合唯一索引);
    • 缓存 key、埋点、日志里同一个 SKU 出现多种写法。

    有了 spec_key,定位 SKU 就是一次主键级查询:

    $valueIds = array_map('intval', $request['spec_value_ids']);
    $specKey = specKey($valueIds);

    $item = Db::name('goods_item')
    ->where('goods_id', $goodsId)
    ->where('spec_key', $specKey)
    ->where('status', 1)
    ->find();

    if (!$item) {
    throw new BusinessException('该规格组合不存在或已下架');
    }

    uk_goods_spec (goods_id, spec_key) 保证这条查询最多命中一行,不需要 find() 之后再校验"是不是只有一条"。

    4.3 详情页的置灰:比"报错"高一个档次

    用户选了"黑色",尺码里的"XXL"应该变成灰色不可点,而不是等用户点完再弹一个"该组合无货"。这个体验差异背后是一个算法问题:给定已选集合,如何判断任意一个候选值是否还能构成有效组合?

    定义"有效"为:存在至少一个 SKU,同时满足(a)包含该候选值,(b)包含其余维度已选的值,(c)有库存且已上架。

    算法分三步:

  • 建立倒排索引 posting[值ID] = {可用 SKU 集合},只收录"有库存且上架"的 SKU;
  • 把已选值按维度归位,得到 selectedByDim[维度] = 值ID;
  • 对每个候选值 v,用 v 替换它所在维度的已选值,其余维度不变,对这几组集合作交集,非空即可选。
  • /**
    * @param array $skus 可用SKU:[['item_id'=>1,'value_ids'=>[12,35]]]
    * @param array $valueToDim 值ID => 维度标识:['12'=>'color','35'=>'size']
    * @param array $selected 已选值ID:[12, 35]
    * @return array 值ID => bool
    */

    function buildSelectableMatrix(array $skus, array $valueToDim, array $selected): array
    {
    // 1. 倒排:规格值 => 参与它的可用SKU集合(用 item_id 做键,天然去重)
    $posting = [];
    foreach ($skus as $sku) {
    foreach ($sku['value_ids'] as $vid) {
    $posting[$vid][$sku['item_id']] = true;
    }
    }

    // 2. 已选值按维度归位
    $selectedByDim = [];
    foreach ($selected as $vid) {
    $selectedByDim[$valueToDim[$vid]] = $vid;
    }

    // 3. 逐值试探:替换同维度,其余维度保持
    $matrix = [];
    foreach ($posting as $vid => $_) {
    $probe = $selectedByDim;
    $probe[$valueToDim[$vid]] = $vid;

    $hit = null;
    foreach ($probe as $pid) {
    $set = $posting[$pid] ?? [];
    $hit = $hit === null ? $set : array_intersect_key($hit, $set);
    if (!$hit) {
    break;
    }
    }
    $matrix[$vid] = !empty($hit);
    }

    return $matrix;
    }

    复杂度是 O(值数 × 维度数 × SKU数)。12 个 SKU、3 个维度,一次计算 36 次数组交集,完全是常数级开销——所以每次选择变化都重新算一遍,不要试图做增量更新,增量逻辑的 bug 成本远高于这点 CPU。

    工程上还有两个细节:

    • 结果要缓存:同一个 goods_id 的可选矩阵在库存变化前是稳定的,可以按 goods_id + 库存版本号 缓存 3~5 秒,挡住详情页的重复计算;
    • “可选"不等于"有货”:可选只代表组合存在,具体库存要看 stock。详情页可以在用户选完最后一个维度后,再单独查一次该 SKU 的实时库存,做"仅剩 3 件"的营销提示。

    4.4 规格变更:删除一个规格值会发生什么

    这是二开里最容易被忽略、后果也最严重的一段。假设商家把"藏青"这个值删了。此时数据库里有 4 个 SKU 引用了这个值,还有 3 笔历史订单买了这 4 个 SKU。

    错误做法:级联删除这 4 个 SKU。后果是历史订单的 order_goods.item_id 指向了一个不存在的行,售后页要展示"规格:藏青 L"时只能靠快照字段,退款要回补库存时找不到目标行,对账时凭空少了 4 个 SKU 的余额。

    正确做法:

  • 规格值做软删除,ls_goods_spec_value 增加 delete_time;
  • 引用它的 SKU 置 status = 0(停用),不物理删除;
  • 详情页和加购链路只查 status = 1 的 SKU,停用的 SKU 自然消失;
  • 历史订单、售后、库存流水仍然能通过 item_id 找到它;
  • 如果商家"重新添加"一个同名规格值,生成新的值 ID 和新的 SKU,绝不与旧的合并。
  • 第 5 条尤其重要。有些实现为了"数据干净",会让新规格值复用旧的 ID。一旦复用,旧订单退款时回补的库存就会打到新 SKU 上——新 SKU 的库存凭空 +1,随后超卖。ID 复用是库存账本的头号杀手。

    同理,商家在后台"编辑商品"时,很多系统会走"先删全部 SKU、再按当前表单重建"的逻辑。这个逻辑在 SKU 没有历史引用时是安全的,但只要有订单引用过,就会导致 item_id 全部变化。可行的折中方案是:按 spec_key 做差量比对,能匹配上的保留原 item_id(只更新价格库存),匹配不上的新增,表单里消失的置为停用。差量比对多写 30 行代码,能省掉未来无数次"售后单找不到 SKU"的工单。

    4.5 价格区间与库存汇总

    详情页要展示"¥89 – ¥129"和"库存 200 件"。这两个数都来自 SKU 聚合,但有陷阱:

    • 价格区间只统计在售 SKU。如果把停用 SKU 也算进去,会出现"展示 129,点进去最贵只要 99"的价格欺诈感;
    • 总库存要与可选性一致。SUM(stock) 必须过滤 status = 1,否则下架 SKU 的滞销库存会虚高展示;
    • 单规格商品不要走聚合。spec_type = 0 的商品在 LikeShop 里通常也有一条 goods_item 记录(用默认规格),直接取它的价格库存,别为了"统一"强行套聚合逻辑,性能和可读性都更差。

    5. 库存扣减:时机、口径与账本

    5.1 什么时候扣:三种口径的取舍

    口径扣减时机优点代价适用
    下单扣 创建订单时 强一致,不会超卖 未支付订单占库存,必须有超时回补 普通商品、预售定金
    支付扣 支付成功回调时 不占库存,转化率高 支付窗口内可能超卖 低客单、库存充足
    发货扣 出库时 不占库存 超卖几乎必然发生 虚拟商品、定制商品

    LikeShop 走的是下单扣 + 超时关单回补。这是电商的标准答案,因为它把"超卖"这个不可逆的资损,换成了"少卖"这个可逆的体验问题。少卖最多损失一单 GMV,超卖要赔违约金、掉评分、上投诉。

    但"下单扣"必须配一个可靠的关单器,否则库存会被未支付订单长期占死。关单的实现有两条路:

    • 延迟消息:下单时投递一条 30 分钟后的延迟消息,到期检查订单状态,未支付则关单回补。优点是准时、不扫表;缺点是要维护 MQ,消息丢了就永远不关单(必须有兜底扫描)。
    • 定时扫表:每分钟扫 order_status = 0 AND create_time < now – 1800 的订单,分批处理。优点是无外部依赖、天然幂等;缺点是精度只有分钟级,且订单量大时要控制扫描频率和批量大小(LIMIT 500 一批,处理完再下一批,避免长事务和主从延迟放大)。

    生产上建议两者都上:延迟消息负责时效,扫表负责兜底。两条路径都通过同一个"关单"方法,且该方法用状态机 CAS 保证只执行一次(见 5.6)。

    5.2 库存不是一个数,是四个

    原生表里只有一个 stock。我建议在二开时把它拆成一组概念,但只在必要时落库:

    概念含义是否落库说明
    stock 物理可售库存 是 扣减的目标
    lock_stock 已锁定未履约 可选 只有在需要区分"锁定/实扣"时才落库
    available 可售 = stock − lock_stock 否 用 SQL 表达式或应用层计算,绝不冗余存储
    version 版本号 是 审计与乐观锁

    available 千万不要落库。任何"冗余一个可售库存字段"的设计,最后都会变成两套数字对不上,然后有人写一个定时任务去"同步",再然后这个任务成了新的故障源。能算出来的东西就不要存。

    如果你的业务确实需要"下单只锁、支付才实扣"(比如高客单、支付成功率低),那 lock_stock 就有意义了:下单时 lock_stock += n,支付成功时 lock_stock -= n, stock -= n,取消时 lock_stock -= n。这时 CAS 的条件要变成 stock – lock_stock >= n,SQL 会稍复杂,但语义清晰。

    5.3 库存流水:让每一次增减都有凭证

    这张表是整个库存体系的地基。没有它,你永远无法回答"为什么库存是 -37"。

    CREATE TABLE `ls_stock_log` (
    `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    `item_id` int unsigned NOT NULL COMMENT 'SKU ID',
    `goods_id` int unsigned NOT NULL COMMENT '冗余商品ID,便于按商品聚合',
    `change_type` tinyint NOT NULL COMMENT '1下单扣减 2支付实扣 3取消回补 4退款回补 5后台调整 6盘点盈亏',
    `change_num` int NOT NULL COMMENT '正数增加,负数减少',
    `before_stock` int NOT NULL COMMENT '变更前库存',
    `after_stock` int NOT NULL COMMENT '变更后库存',
    `biz_type` varchar(32) NOT NULL COMMENT '业务类型:order/seckill/refund/manual',
    `biz_sn` varchar(64) NOT NULL COMMENT '业务单号,幂等键',
    `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
    `create_time` int NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_biz` (`biz_type`, `biz_sn`, `item_id`, `change_type`),
    KEY `idx_item_time` (`item_id`, `create_time`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水';

    uk_biz 是这张表的灵魂。它表达的是:同一个业务单号、同一个 SKU、同一种变更类型,只允许记账一次。 支付回调重复、关单任务重复、退款接口被重试,第二次插入都会撞唯一键,此时捕获异常、直接返回成功即可——这就是最朴素也最可靠的幂等。

    before_stock / after_stock 看似冗余(用 change_num 累加就能算出来),但它们在排查时价值极高:能立刻看出"这次扣减发生时,库存是不是已经是负数",也能在流水被并发交错时还原真实的执行顺序。写这两列的代价是每次多两个字段,收益是省掉几个小时的对账。

    goods_id 的冗余同理:按商品维度做日报、算动销,不需要 JOIN goods_item。

    5.4 一次下单的完整时序

    MySQL应用层客户端MySQL应用层客户端#mermaid-svg-N9YCL8SkTdqd0TQe{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-N9YCL8SkTdqd0TQe .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-N9YCL8SkTdqd0TQe .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-N9YCL8SkTdqd0TQe .error-icon{fill:#552222;}#mermaid-svg-N9YCL8SkTdqd0TQe .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-N9YCL8SkTdqd0TQe .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-N9YCL8SkTdqd0TQe .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-N9YCL8SkTdqd0TQe .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-N9YCL8SkTdqd0TQe .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-N9YCL8SkTdqd0TQe .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-N9YCL8SkTdqd0TQe .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-N9YCL8SkTdqd0TQe .marker{fill:#333333;stroke:#333333;}#mermaid-svg-N9YCL8SkTdqd0TQe .marker.cross{stroke:#333333;}#mermaid-svg-N9YCL8SkTdqd0TQe svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-N9YCL8SkTdqd0TQe p{margin:0;}#mermaid-svg-N9YCL8SkTdqd0TQe .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-N9YCL8SkTdqd0TQe text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-N9YCL8SkTdqd0TQe .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-N9YCL8SkTdqd0TQe .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-N9YCL8SkTdqd0TQe .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-N9YCL8SkTdqd0TQe .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-N9YCL8SkTdqd0TQe #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-N9YCL8SkTdqd0TQe .sequenceNumber{fill:white;}#mermaid-svg-N9YCL8SkTdqd0TQe #sequencenumber{fill:#333;}#mermaid-svg-N9YCL8SkTdqd0TQe #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-N9YCL8SkTdqd0TQe .messageText{fill:#333;stroke:none;}#mermaid-svg-N9YCL8SkTdqd0TQe .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-N9YCL8SkTdqd0TQe .labelText,#mermaid-svg-N9YCL8SkTdqd0TQe .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-N9YCL8SkTdqd0TQe .loopText,#mermaid-svg-N9YCL8SkTdqd0TQe .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-N9YCL8SkTdqd0TQe .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-N9YCL8SkTdqd0TQe .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-N9YCL8SkTdqd0TQe .noteText,#mermaid-svg-N9YCL8SkTdqd0TQe .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-N9YCL8SkTdqd0TQe .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-N9YCL8SkTdqd0TQe .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-N9YCL8SkTdqd0TQe .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-N9YCL8SkTdqd0TQe .actorPopupMenu{position:absolute;}#mermaid-svg-N9YCL8SkTdqd0TQe .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-N9YCL8SkTdqd0TQe .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-N9YCL8SkTdqd0TQe .actor-man circle,#mermaid-svg-N9YCL8SkTdqd0TQe line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-N9YCL8SkTdqd0TQe :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}提交订单(goods_id + 规格 / item_id + 数量)参数校验、限购校验、优惠计算只读校验:商品上架、SKU 归属、价格BEGIN按 item_id 升序逐条 CAS 扣减INSERT ls_order / ls_order_goodsINSERT ls_stock_logCOMMIT订单号 + 待支付金额

    几个必须遵守的约束:

  • 事务里只做数据库操作,不要调支付网关、不要调物流、不要发短信。事务持有行锁的时间应该以毫秒计;
  • 先把只读校验做完再开事务。商品是否上架、SKU 是否属于该商品、限购是否超限,这些都是读操作,放在事务外能显著缩短事务窗口;
  • 扣减顺序按 item_id 升序,这是防死锁的关键(6.3 节展开);
  • 订单行与库存流水在同一个事务里写。要么都成功,要么都回滚,不允许出现"扣了库存但没有流水"的中间态。
  • 5.5 营销场景的库存隔离

    LikeShop 单商户版同时支持秒杀、拼团、砍价、普通购买。这些场景最终都落到同一个 ls_goods_item.stock 上,于是出现一个结构性问题:秒杀的瞬时流量会把普通订单挤死。

    想象一下:秒杀 1000 件,10 万人抢。这 10 万请求全部去打同一行的行锁,普通用户此时下单同一商品,会在行锁队列里排到几秒钟之后,直接超时。你以为是"秒杀把数据库打挂了",其实是"秒杀把行锁队列占满了"。

    正确的做法是库存池隔离:

    • 秒杀商品使用独立的库存字段(如 seckill_stock)或独立的库存表,活动期间不碰主库存;
    • 活动结束后,把"秒杀库存 − 已售"的差额回补到主库存;
    • 拼团同理,但拼团通常库存压力小,可以共享主库存,只在成团失败时回补。

    隔离的代价是"总库存 = 主库存 + 活动库存",对账口径变复杂。所以隔离只应该用在确定性的大促商品上,不要给所有商品都开一套活动库存——那是给自己找麻烦。

    5.6 回补:幂等的最后一公里

    回补的触发点有四个:超时关单、用户主动取消、退款成功、后台手工补货。前三个都必须满足两个条件才能执行:

    // 1) 状态机 CAS:只有从未支付 -> 已关闭 的那一次迁移才算成功
    $affected = Db::name('order')
    ->where('id', $orderId)
    ->where('order_status', 0) // 待付款
    ->update(['order_status' => 4, 'close_time' => time()]);

    if ($affected === 0) {
    return true; // 已经被别的线程关过了,直接返回成功,不重复回补
    }

    // 2) 回补库存(内部再走一次 CAS + 流水唯一键)
    $stockService->restore($orderId);

    注意这里的顺序:先迁移状态,再回补库存。如果反过来,状态迁移失败时库存已经加回去了,就会凭空多出一份库存。而如果状态迁移成功、回补失败(比如进程被杀),流水表里没有记录,对账任务能发现差异并补偿;反过来则永远发现不了。

    restore 内部还要再防一层:流水表的 uk_biz 会拦住重复回补。两层幂等不是冗余——状态机保证"业务上只发生一次",流水唯一键保证"账本上只记一次",它们防的是不同层面的重复。

    6. 并发控制:从悲观锁到无锁串行

    这一节是全文的技术核心。先把结论放前面:电商库存扣减的最优解,是"条件更新 + 短事务",不是分布式锁。

    6.1 为什么 SELECT … FOR UPDATE 通常是错的

    悲观锁的写法很诱人,因为它符合直觉:

    Db::startTrans();
    $item = Db::name('goods_item')->where('id', $itemId)->lock(true)->find(); // FOR UPDATE
    if ($item['stock'] < $num) {
    Db::rollback();
    throw new BusinessException('库存不足');
    }
    Db::name('goods_item')->where('id', $itemId)->setDec('stock', $num);
    Db::commit();

    在低并发、单 SKU 的场景里它能跑。但放到生产环境,有四个问题:

    第一,锁的范围不可控。 InnoDB 在 RR 隔离级别下,FOR UPDATE 如果走的是非唯一索引或没有索引,会加间隙锁(gap lock),锁住一个区间而不是一行。你以为锁了 id = 100,实际锁了 (90, 110),隔壁 SKU 的扣减全被阻塞。

    第二,锁的持有时间等于事务时长。 事务里但凡有一次 RPC(查用户等级、调优惠券服务、写日志到远程),锁就从毫秒级变成几十毫秒级。1000 QPS 下,一个 50ms 的锁就足以让行锁队列排到几百个请求之后,连接池迅速耗尽,然后整个服务雪崩。

    第三,死锁概率随 SKU 数指数上升。 一个订单有 5 个 SKU,两个订单交叉加锁,就是经典的死锁场景。InnoDB 会自动检测并回滚其中一个,但你的业务代码必须处理 1213 这个错误码,而很多二开代码根本没有 catch。

    第四,它解决不了"跨库/跨服务"的问题。 一旦库存拆到了独立的库或服务,FOR UPDATE 就彻底失效。

    那什么时候可以用?低频、后台、单条的操作:后台改价、库存盘点、手工补货。这些场景并发极低,用悲观锁换来的是代码的直观。交易链路上不要用。

    6.2 条件更新:一行 SQL 解决问题

    把"判断"和"修改"合并进同一条 SQL,让数据库在行锁保护下原子地完成:

    UPDATE ls_goods_item
    SET stock = stock – 2,
    version = version + 1
    WHERE id = 100
    AND stock >= 2;

    这条语句的执行过程是:InnoDB 定位到 id = 100 的行,加排他锁,在当前读(current read)下重新评估 stock >= 2,满足则更新,不满足则影响行数为 0。整个过程不需要应用层做任何判断,也不存在 TOCTOU 窗口。

    应用层只需要看影响行数:

    $affected = Db::name('goods_item')
    ->where('id', $itemId)
    ->where('stock', '>=', $num)
    ->dec('stock', $num)
    ->inc('version', 1)
    ->update();

    if ($affected === 0) {
    throw new BusinessException('库存不足');
    }

    三个必须注意的细节:

    ① 影响行数的语义。 MySQL 的 affected_rows 默认返回"实际改变的行数",不是"匹配的行数"。如果 $num = 0,stock 没有变化,返回 0,会被误判成"库存不足"。所以业务层必须先拦截 $num >= 1,并且对 $num 做上限校验(防止有人传 999999 把库存一次扣穿)。某些驱动(PDO 开启 MYSQL_ATTR_FOUND_ROWS)返回的是匹配行数,行为不同,切换驱动时要重新验证。

    ② 不要用 stock = stock – n < 0 之类的表达式做条件。 有些实现写成 WHERE stock – n >= 0,逻辑等价但无法用索引下推,且可读性差。就用 stock >= n。

    ③ 批量扣减必须逐条执行。 不要写 WHERE id IN (1,2,3) 一次性更新——你无法知道是哪一个 SKU 库存不足,也无法保证"要么全扣、要么全不扣"的语义(虽然事务能保证后者,但错误信息会丢失)。逐条执行、任意一条失败就抛异常回滚,是最清晰的实现。

    6.3 多 SKU 死锁:排序是门艺术

    一个订单有多个 SKU,扣减顺序不确定,就会死锁:

    事务 A:锁 SKU 1 → 等 SKU 2
    事务 B:锁 SKU 2 → 等 SKU 1

    解决办法简单到不可思议:所有事务按 item_id 升序加锁。

    $items = $orderItems;
    usort($items, fn($a, $b) => $a['item_id'] <=> $b['item_id']);

    Db::startTrans();
    try {
    foreach ($items as $item) {
    $affected = Db::name('goods_item')
    ->where('id', $item['item_id'])
    ->where('stock', '>=', $item['num'])
    ->dec('stock', $item['num'])
    ->update();
    if ($affected === 0) {
    throw new StockNotEnoughException($item['item_id']);
    }
    }
    // 写订单、写订单行、写流水
    Db::commit();
    } catch (\\Throwable $e) {
    Db::rollback();
    throw $e;
    }

    排序之后,所有事务的加锁顺序一致,环路不可能形成。这是理论上的死锁-free,不需要重试机制。

    但现实里还有两个漏网之鱼:

    • 不同代码路径的顺序不一致。下单按 id 排序了,退款回补却没排序,两者交叉仍可能死锁。所以排序必须做成规范,所有涉及多 SKU 加锁的地方统一调用同一个方法;
    • 唯一索引冲突导致的死锁。插入 ls_stock_log 时如果撞唯一键,InnoDB 会加间隙锁,与其它事务的插入互相等待。这类死锁无法完全消除,只能靠"捕获 1213、重试 1~2 次、带随机退避"兜底。

    重试代码要克制:最多 2 次,每次退避 10ms + rand(0, 20)ms,并且只重试死锁(1213)和锁等待超时(1205),不要重试业务异常。

    6.4 乐观锁 version:什么时候值得

    version 字段常被当成"解决并发的银弹",但在库存扣减里,它其实是多余的——WHERE stock >= n 已经完成了"读-判断-写"的原子化,version 只是额外的一次写放大。

    真正需要 version 的是跨请求的读-算-写场景:

    请求 A:读出 stock=100 → 后台页面停留 5 分钟 → 提交"改为 80"
    请求 B:同一时间卖出 10 件,stock=90

    如果 A 直接 UPDATE … SET stock = 80,B 卖出的 10 件就被覆盖了。这时 version 才有意义:

    UPDATE ls_goods_item
    SET stock = 80, version = version + 1
    WHERE id = 100 AND version = 7;

    影响行数为 0 时提示"数据已被修改,请刷新后重试"。

    另外,后台调整库存不应该直接 SET stock = 80,而应该走增量记账:先算 delta = 80 – current,再用 CAS 扣减或增加,同时写一条 change_type = 5 的流水。这样每一次人工干预都留痕,对账时能把"人为调整"和"交易扣减"分开看。绝对值的写入是账本的天敌。

    6.5 Redis 预扣 + Lua:把判断和扣减合成一个原子操作

    当单 SKU 的峰值 QPS 超过 MySQL 单行更新的承载能力(经验值在 3000~5000 TPS 量级,取决于硬件和 sync_binlog 配置),数据库就会成为瓶颈。这时要把"拦截"前移到 Redis。

    关键点是:GET 之后判断、再 DECR 是非原子的,两个请求可能同时读到 stock = 1。必须用 Lua 脚本,让整个"读-判断-扣-记标记"在 Redis 的单线程里一次执行完:

    — KEYS[1] = 库存 key KEYS[2] = 去重 key
    — ARGV[1] = 扣减数量 ARGV[2] = 去重 key 的过期秒数
    local stock = tonumber(redis.call('GET', KEYS[1]))
    if stock == nil then
    return –3 — 未预热,交由上层降级到数据库
    end

    local num = tonumber(ARGV[1])
    if stock < num then
    return –1 — 库存不足
    end

    if redis.call('EXISTS', KEYS[2]) == 1 then
    return –2 — 重复请求
    end

    redis.call('DECRBY', KEYS[1], num)
    redis.call('SET', KEYS[2], '1', 'EX', tonumber(ARGV[2]))
    return stock – num — 剩余库存,供前端展示

    返回值设计成负数表示不同失败原因,正数表示剩余库存,这样一次调用就能拿到全部信息,不用再查一次。

    工程细节:

    • Key 必须带 hash tag。Redis Cluster 下,Lua 脚本涉及的所有 key 必须在同一个 slot,否则直接报 CROSSSLOT。写法是 stock:{sku:100}:qty,用 {} 把 sku:100 固定成 hash tag;
    • 必须预热。活动开始前把库存写入 Redis,并设置与活动结束时间对齐的 TTL。未预热时脚本返回 -3,上层降级到数据库扣减,而不是直接失败;
    • 必须最终校验。Redis 扣成功不代表数据库扣成功。落库时仍然要执行 WHERE stock >= n,失败则回滚 Redis(INCRBY),并给用户返回失败。Redis 是"过滤器",数据库才是"真相";
    • 必须有对账。Redis 宕机、主从切换、脚本执行到一半进程被杀,都会导致预扣数和数据库不一致。定时任务比对 Redis 剩余 与 数据库 stock,差异超过阈值告警,并按数据库为准重建缓存。

    关于"Redis 预扣失败要不要回滚"这一点,有个常见误区:脚本里已经 DECRBY 成功、但后续落库失败时,必须 INCRBY 回去。这个回滚本身也可能失败(网络抖动),所以回滚要放在 finally 里,并且记录一条待补偿的任务。任何跨系统的扣减,都必须假设"补偿也会失败",然后为补偿再准备一次补偿。

    6.6 分布式锁:用对是银弹,用错是灾难

    先说结论:库存扣减不需要分布式锁,条件更新已经足够。分布式锁真正适用的场景是"同一个业务动作不能被并发执行",比如:

    • 订单关单(防止两个实例同时关同一单);
    • 退款发起(防止重复退款);
    • 库存盘点(防止盘点期间有交易)。

    锁的正确写法(Redis):

    $key = "lock:order:close:{$orderId}";
    $token = bin2hex(random_bytes(16));

    // NX 保证互斥,PX 保证不会永久死锁
    $ok = Redis::set($key, $token, ['NX', 'PX' => 3000]);
    if (!$ok) {
    return false; // 拿不到锁,说明有人正在处理
    }

    try {
    // 业务逻辑
    } finally {
    // 只删自己的锁,防止误删别人的
    Redis::eval(
    "if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
    else
    return 0
    end"
    ,
    1, $key, $token
    );
    }

    三个反模式,踩过的人都懂:

    反模式一:锁的粒度过粗。 用 lock:goods:{goods_id} 锁整个商品,结果这个商品下 12 个 SKU 的扣减全部串行。锁的粒度应该等于竞争资源的粒度——库存的竞争单位是 item_id,锁就该是 item_id。

    反模式二:锁内做 RPC。 拿到锁之后调支付、调短信、查用户画像,锁的持有时间从 5ms 变成 500ms。锁内只做内存和数据库操作,RPC 全部移到锁外。

    反模式三:锁超时了但业务没执行完。 设置了 3 秒过期,业务跑了 5 秒,锁自动释放,第二个线程进来,两个线程同时写——锁形同虚设。解决办法是续期(Redisson 的 watchdog)或** fencing token**(每次加锁生成递增序号,写数据库时带上序号,旧序号的写入被拒绝)。前者简单,后者严谨。

    还有一个隐藏问题:Redis 主从切换会丢锁。主节点写入锁、还没同步到从节点就宕机,从节点升主后锁消失。对库存这种强一致场景,要么用 Redlock(争议较大),要么用 etcd/ZooKeeper 的 CP 锁,要么干脆不用锁、用数据库的唯一约束和状态机 CAS。大多数场景下,最后一种才是正解。

    6.7 无锁方案:串行化与分桶

    如果连条件更新都扛不住,还有两条路,它们本质上都是"把并行变成串行":

    方案一:队列串行化。 按 item_id 哈希到 N 个队列(比如 crc32(item_id) % 16),每个队列由单线程消费者处理。同一个 SKU 的扣减天然串行,完全不需要锁,也不需要 CAS 重试。吞吐上限是 16 × 单消费者处理能力,扩队列数就能线性扩容。

    代价是延迟:用户下单后要等队列消费完才能拿到结果。所以它通常只用于秒杀,且配合"预扣 + 异步落库 + 前端轮询"的交互。

    方案二:库存分桶。 把 1000 件库存拆成 10 个桶,每桶 100 件,存在 10 行记录里。请求按用户 ID 哈希到某个桶,行锁竞争直接降为 1/10。桶耗尽时可以从其它桶"借"库存,但借库存本身又是一次跨行事务,复杂度陡增。

    分桶适合"库存量大、SKU 少、流量集中"的超级爆款。普通商品用分桶是过度设计——它带来的对账复杂度和代码复杂度,远超那点性能收益。

    6.8 决策矩阵

    场景推荐方案理由注意
    普通下单(QPS < 1000) 条件更新 CAS + 短事务 简单、强一致、无外部依赖 按 item_id 排序,事务内不调 RPC
    大促普通商品(QPS 1000~5000) CAS + 读写分离 + 库存缓存 数据库仍能扛,缓存挡住读 缓存 TTL ≤ 5s,扣减后主动失效
    秒杀(QPS > 1 万) Redis Lua 预扣 + MQ + DB 最终校验 把流量挡在数据库之前 必须预热、必须对账、必须限流
    后台改库存 乐观锁 version + 增量流水 防覆盖,留痕 禁止写绝对值
    关单/退款 分布式锁 + 状态机 CAS 防重复执行 锁粒度 = 业务单号,锁内不做 RPC
    超级爆款(单 SKU 十万级) 分桶库存 或 队列串行化 突破单行锁上限 对账口径要重新设计

    7. 一致性:分布式事务的"最后一公里"

    7.1 支付回调:幂等是第一原则

    支付平台的回调是"至少一次"语义,同一笔支付可能通知 5 次。处理不当,就是重复发货、重复加积分、重复分佣。

    正确的顺序是:验签 → 查单 → 校验金额 → 状态机 CAS → 幂等返回。

    // 1) 验签失败直接拒绝,不查库
    if (!$payService->verify($raw, $headers)) {
    return 'fail';
    }

    // 2) 幂等:已支付则直接返回成功
    $order = Db::name('order')->where('order_sn', $orderSn)->find();
    if ($order['pay_status'] == 1) {
    return 'success';
    }

    // 3) 金额必须校验,防止篡改
    if (bccomp($order['order_amount'], $payAmount, 2) !== 0) {
    Log::error("支付金额不一致 {$orderSn}");
    return 'fail';
    }

    // 4) CAS:只有 0 -> 1 的那一次才算成功
    $affected = Db::name('order')
    ->where('id', $order['id'])
    ->where('pay_status', 0)
    ->update([
    'pay_status' => 1,
    'pay_time' => time(),
    'order_status' => 1,
    ]);

    if ($affected === 0) {
    return 'success'; // 并发下被别的线程处理了,同样返回成功
    }

    // 5) 后续动作(发货、分佣、通知)放在事务提交之后

    第 4 步的 CAS 是整个幂等的核心。没有它,两个并发的回调线程会同时通过第 2 步的检查,然后各自发货一次。

    7.2 本地消息表:不引入 MQ 也能最终一致

    库存扣了、订单建了,但"通知 WMS 出库"失败了,怎么办?

    最简单可靠的方案是本地消息表:在同一个事务里往 ls_message 插一条待发送记录,事务提交后由定时任务扫描并投递,投递成功标记为已发送。它的本质是"把消息和业务数据放在同一个本地事务里",避免了分布式事务的复杂度。

    对于 LikeShop 这种体量,很多"通知"其实可以直接做成定时补偿:WMS 每 5 分钟拉一次"已支付未同步"的订单,比推模式简单得多,也不怕丢消息。

    7.3 缓存与数据库:别让旧值覆盖新值

    详情页的库存缓存一定要短(3~5 秒),因为库存是高频变化的。但短 TTL 也有坑:

    线程 A:读 DB stock=100 → 准备写缓存
    线程 B:扣减 1,stock=99 → 删除缓存
    线程 A:写入缓存 100(旧值)

    结果缓存里是 100,实际是 99,用户在 5 秒内看到的是虚高的库存。解决办法有二:

    • 延迟双删:扣减后先删缓存,500ms 后再删一次;
    • 版本号写入:缓存值里带 version,写入前比较,旧版本不覆盖新版本(用 Lua 的 SET + 比较实现)。

    对于库存,我更推荐第二种,因为它不依赖"删除时机"这种脆弱假设。

    7.4 对账:唯一能兜住所有漏洞的手段

    前面所有的机制都是"尽力而为",只有对账是"确定性"的。库存对账的核心 SQL:

    SELECT
    i.id,
    i.stock,
    COALESCE(SUM(l.change_num), 0) AS ledger_sum,
    i.stock – COALESCE(SUM(l.change_num), 0) AS diff
    FROM ls_goods_item i
    LEFT JOIN ls_stock_log l ON l.item_id = i.id
    GROUP BY i.id, i.stock
    HAVING diff <> 0
    LIMIT 100;

    这条 SQL 成立的前提是:每个 SKU 在上线时必须有一条"期初"流水(change_type = 5,change_num = 初始库存)。没有期初,stock 和流水累加永远对不上,对账就变成了玄学。

    上线切换的步骤应该是:

  • 建流水表;
  • 为所有 SKU 插入期初流水(biz_sn = 'INIT');
  • 开启双写(扣减时同时写流水);
  • 观察一周,确认 diff = 0;
  • 把对账任务设为每 10 分钟跑一次,差异超过 0 就告警。
  • 对账发现差异之后不要自动修正。自动修正会把一次 bug 变成一次静默的数据篡改。正确做法是:告警 → 人工确认 → 走 change_type = 6 的盘点流水修正 → 记录原因。每一次修正都必须有一条流水,这是账本的底线。

    8. 一份可以直接落地的 StockService

    把前面的规则收敛成一个类。它不长,但每一行都对应前面讲过的一个约束。

    <?php
    declare(strict_types=1);

    namespace app\\common\\logic;

    use think\\facade\\Db;

    class StockService
    {
    /** 单个 SKU 单次扣减上限,防止参数被构造 */
    private const MAX_QTY = 999;

    /**
    * 批量扣减库存(下单场景)
    *
    * @param array $items [['item_id'=>1, 'goods_id'=>1, 'num'=>2], …]
    * @param string $bizSn 业务单号,幂等键
    */

    public function deduct(array $items, string $bizSn): void
    {
    // 排序:所有事务按同一顺序加锁,从结构上消除死锁环
    usort($items, static fn(array $a, array $b) => $a['item_id'] <=> $b['item_id']);

    Db::startTrans();
    try {
    foreach ($items as $row) {
    $itemId = (int) $row['item_id'];
    $num = (int) $row['num'];

    if ($num < 1 || $num > self::MAX_QTY) {
    throw new \\InvalidArgumentException("非法扣减数量: {$num}");
    }

    // 判断与扣减合并为一条 SQL,行锁内完成
    $affected = Db::name('goods_item')
    ->where('id', $itemId)
    ->where('stock', '>=', $num)
    ->dec('stock', $num)
    ->inc('version', 1)
    ->update();

    if ($affected === 0) {
    throw new StockNotEnoughException("SKU {$itemId} 库存不足");
    }

    // 本事务已持有该行排他锁,此时读到的一定是自己刚写入的值
    $after = (int) Db::name('goods_item')->where('id', $itemId)->value('stock');
    $before = $after + $num;

    Db::name('stock_log')->insert([
    'item_id' => $itemId,
    'goods_id' => (int) $row['goods_id'],
    'change_type' => 1,
    'change_num' => –$num,
    'before_stock' => $before,
    'after_stock' => $after,
    'biz_type' => 'order',
    'biz_sn' => $bizSn,
    'create_time' => time(),
    ]);
    }
    Db::commit();
    } catch (\\Throwable $e) {
    Db::rollback();
    throw $e;
    }
    }

    /**
    * 回补库存(取消 / 退款场景)
    * 依赖 uk_biz 唯一键实现幂等:重复调用不会重复加库存
    */

    public function restore(int $orderId, int $changeType = 3): void
    {
    $order = Db::name('order')->where('id', $orderId)->field('id, order_sn')->find();
    if (!$order) {
    return;
    }
    $bizSn = $order['order_sn']; // 与 deduct 使用同一个业务键,不能一个用订单号一个用自增ID

    $rows = Db::name('order_goods')
    ->where('order_id', $orderId)
    ->field('item_id, goods_id, goods_num')
    ->select()
    ->toArray();

    usort($rows, static fn(array $a, array $b) => $a['item_id'] <=> $b['item_id']);

    Db::startTrans();
    try {
    foreach ($rows as $row) {
    $itemId = (int) $row['item_id'];
    $num = (int) $row['goods_num'];

    // ① 先插流水占住唯一键:重复回补会在这里失败,此时还没动库存
    $logId = Db::name('stock_log')->insertGetId([
    'item_id' => $itemId,
    'goods_id' => (int) $row['goods_id'],
    'change_type' => $changeType,
    'change_num' => $num,
    'before_stock' => 0,
    'after_stock' => 0,
    'biz_type' => 'order',
    'biz_sn' => $bizSn,
    'create_time' => time(),
    ]);

    // ② 加库存
    Db::name('goods_item')
    ->where('id', $itemId)
    ->inc('stock', $num)
    ->inc('version', 1)
    ->update();

    // ③ 回填真实的前后值:与①在同一事务,要么都生效要么都不生效
    $after = (int) Db::name('goods_item')->where('id', $itemId)->value('stock');
    Db::name('stock_log')->where('id', $logId)->update([
    'before_stock' => $after – $num,
    'after_stock' => $after,
    ]);
    }
    Db::commit();
    } catch (\\think\\db\\exception\\PDOException $e) {
    // 1062 = Duplicate entry,说明这笔单已经回补过
    if (str_contains($e->getMessage(), '1062')) {
    Db::rollback();
    return;
    }
    Db::rollback();
    throw $e;
    } catch (\\Throwable $e) {
    Db::rollback();
    throw $e;
    }
    }
    }

    代码里有三个值得单独说明的设计:

    一是 before_stock 的取法。 我没有在扣减前 SELECT 一次,而是扣减后再读。原因是:扣减前读到的可能是旧快照,高并发下它与实际被减掉的值未必对应;扣减后读,本事务持有该行排他锁直到提交,其它事务无法修改,读到的必然是自己的结果,before = after + num 才是准的。代价是每个 SKU 多一次主键查询,换来的是流水与实际完全一致。

    二是回补时"先占位、再加库存、最后回填"。 唯一键冲突会在插流水这一步就发生,此时还没有动库存,直接回滚即可,不会出现"库存加了但流水没写"的中间态。如果反过来先加库存,一旦写流水失败,就得靠对账去补。代价是流水要先以 0 占位、再回填真实的 before/after——这三步在同一个事务里,要么都生效,要么都不生效,中间态对外不可见。

    三是回补没有校验订单状态,因为它不该在这里校验。 状态机 CAS 应该由调用方(OrderLogic)完成,StockService 只负责"按单号幂等地加库存"。职责分离之后,退款、取消、后台补单可以复用同一个方法,各自在自己的入口做状态校验。

    Java(Spring Boot + MyBatis-Plus)版本的核心只有一行:

    int affected = goodsItemMapper.update(null,
    Wrappers.<GoodsItem>lambdaUpdate()
    .setSql("stock = stock – " + num) // num 已在入口校验为正整数
    .setSql("version = version + 1")
    .eq(GoodsItem::getId, itemId)
    .ge(GoodsItem::getStock, num));

    if (affected == 0) {
    throw new BizException("库存不足");
    }

    注意 setSql 里拼接的是已经过类型校验的 int,不是用户输入的字符串。任何情况下都不要把请求参数直接拼进 SQL。

    9. 压测、监控与容量

    9.1 瓶颈在哪里

    库存扣减的性能瓶颈几乎只有两个:行锁排队和事务时长。

    • 单行热点更新,在 8C16G、MySQL 8.0、NVMe、sync_binlog = 1 的环境下,经验拐点在 3000~5000 TPS。超过之后 affected_rows 不一定下降,但 P99 会陡增——因为请求都在等同一把行锁;
    • 事务里每多一次 RPC,锁持有时间就多一个数量级,吞吐直接腰斩。

    所以压测的目标不是"跑出一个漂亮的 QPS 数字",而是找到P99 开始抬头的那个点,并确认它离大促峰值还有多少余量。

    9.2 压测怎么做才有效

    三个要素缺一不可:

  • 固定的 SKU 池:不能每轮随机取 SKU,否则行锁分散,压出来的数字毫无意义。要专门准备 1 个"超级热点 SKU"和 50 个"普通 SKU",分别压;
  • 固定的用户池:限购、优惠券、会员价都会改变执行路径,用户池要足够大以避免限购提前触发,又要固定以保证可复现;
  • 观察正确的指标:不要只看 QPS。重点看 affected_rows = 0 的比例(库存不足的比例)、死锁次数、P99、MySQL 的 Innodb_row_lock_time_avg、活跃连接数。
  • 9.3 线上必须盯的指标

    指标含义告警阈值建议
    stock_deduct_total{result} 扣减成功/失败计数 失败率突增 5 倍
    stock_cas_zero_total affected_rows = 0 次数 与"库存不足"业务量偏离
    stock_negative_total 扣减后库存 < 0 的次数 > 0 立即告警
    deadlock_total 1213 错误次数 每分钟 > 10
    lock_wait_timeout_total 1205 错误次数 > 0 即关注
    redis_prestock_diff Redis 与 DB 库存差 绝对值 > 阈值
    stock_reconcile_diff 流水与余额差 ≠ 0
    pay_callback_dup_total 重复支付回调次数 突增说明上游重试异常

    stock_negative_total 是最重要的一个。它只要大于 0,就说明有链路绕过了 CAS——可能是某个二开接口直接 setDec,可能是数据修复脚本,可能是历史遗留的定时任务。这个指标的优先级高于一切性能指标。

    9.4 容量估算

    一个粗略但实用的公式:

    所需行锁操作数/秒 = 峰值订单 QPS × 平均每单 SKU 数
    单行承载上限 ≈ 3000 ~ 5000 次/秒(含事务开销后更低)
    热点 SKU 需要的分片数 = 该 SKU 的峰值 QPS / 单行承载上限

    如果算出来热点 SKU 需要 4 个分片,那就别急着上分桶——先看看能不能用 Redis 预扣把它挡掉。分桶是最后的手段,因为它会永久性地提高对账复杂度。

    10. 十二个坑

    写到这里,把二开里最常见、也最贵的坑列成清单。每一条都对应前面某一节的结论。

  • 信任前端传来的价格和 item_id。价格必须以数据库为准,item_id 必须校验归属。这是资损级漏洞。
  • spec_value_ids 不排序就做匹配。同一组合多种写法,轻则匹配失败,重则扣错 SKU 的库存。
  • 先 SELECT 判断再 UPDATE。TOCTOU,高并发下必然超卖。判断必须进 SQL 的 WHERE。
  • 取消订单、退款重复回补。状态机 CAS + 流水唯一键,两层都要有。
  • 事务里调用支付、短信、物流接口。锁持有时间被 RPC 放大几十倍,连接池会被打满。
  • stock 用无符号整型。扣成负数时要么抛 SQL 异常,要么回绕成天文数字。
  • Redis 预扣成功后进程被杀。库存永久泄漏,只能靠对账发现。预扣必须配补偿任务。
  • 把缓存里的库存当成真实库存。缓存 TTL 再短也有窗口,下单必须回源校验。
  • 编辑商品时"全删重建" SKU。item_id 全变,历史订单售后找不到 SKU。
  • 库存为 0 仍可加购。加购不校验可以接受,但结算页必须实时校验,且下单接口必须再校验一次。
  • 分布式锁的 key 漏了维度。lock:stock 锁了全站库存,所有商品串行,性能直接归零。
  • 对账发现差异后自动修正。把 bug 变成静默的数据篡改,问题永远查不出来。
  • 11. 结语:SKU 体系的本质是"账本 + 状态机"

    回到开头那 37 件超卖。修复方案其实很朴素:

    • 加 spec_key 和唯一索引,让"同一组合只有一个 SKU"成为数据库约束;
    • 把"读-判断-写"压成一条带 stock >= n 的 SQL;
    • 加流水表和 uk_biz,让每一次增减都有凭证、都能幂等;
    • 关单走状态机 CAS,只有迁移成功的那一次才回补;
    • 上对账任务,每 10 分钟比对余额与流水。

    总共不到 300 行代码,但它把"库存"从一个可以被任意改写的数字,变成了一个只能通过凭证记账的账户。这才是本质区别。

    如果要把这篇文章压缩成三句话,我会这么说:

    第一,SKU 的标识必须不可变。 item_id 是交易系统里最稳定的锚点,价格、库存、规格文本都可以变,id 不能变、不能复用、不能物理删除。

    第二,库存是账本,不是字段。 stock 只是流水的一个物化余额,凡是不写流水的扣减,都是在制造无法解释的差异。

    第三,并发问题优先用数据库的原子性解决。 条件更新和唯一约束,比任何分布式锁都更简单、更可靠。锁是最后的手段,不是第一反应。

    LikeShop 单商户高级版给了一套干净的三层商品模型,它足够支撑绝大多数中小电商。真正把它用坏的,往往不是模型本身,而是二开时那些"看起来能跑"的写法:先查后改、字符串匹配、重复回补、事务里调接口。这些问题在低并发时全都不暴露,一旦流量上来,就会以超卖、错发、对不上账的形式集中爆发。

    赞(0)
    未经允许不得转载:171主机测评 » 一次超卖 37 件的复盘:LikeShop 单商户高级版 SKU 体系的规格映射、库存扣减与并发
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址