库存系统最危险的时刻,往往不是库存为零,而是库存还显示有货,却已经被大量未完成订单“占住”。我在做高并发交易链路评审时反复看到同一种现象:数据库里的库存扣减语句本身没有写错,真正出问题的是请求重试、支付超时、消息重复消费和锁定库存未回收。结论先说在前面:库存锁定不是给数据库扣减前面加一层缓存,而是把“可售、占用、确认、释放”拆成可追踪的状态机,再用缓存削峰、数据库留痕、幂等约束和对账补偿组成完整闭环。
数据库存:运维团队增长视角:用库存锁定放大保证扣减一致性
很多库存实现把问题简化成一条 SQL:当库存大于购买数量时,将库存减去购买数量。这个判断在低并发、短事务、没有支付环节的场景里可以工作,但一旦业务进入秒杀、活动、团购或高峰交易阶段,库存就不再是一个静态数字。
一次完整的库存交易至少包含四个状态:可售库存、锁定库存、已确认库存和已释放库存。可售库存可以被新请求占用;锁定库存已经属于某个订单,但订单还没有完成最终确认;已确认库存代表业务事实已经成立;已释放库存则说明原来的占用被取消或超时回收。
如果系统只有“库存减一”和“订单成功”两个结果,就很难解释下面这些问题:用户支付失败后库存去哪了?客户端超时重试会不会再次扣减?消息重复到达会不会重复确认?缓存扣减成功但数据库写入失败时,谁负责修正?
我不建议在库存方案里直接写“保证绝对不超卖、不少卖”。这种表述通常没有定义一致性的边界,也没有说明 Redis、数据库、消息队列和订单服务发生故障时如何处理。
更准确的说法应该是:在库存锁定原子执行、业务请求具备幂等键、状态迁移受到约束、确认与释放可重试、异常库存能够对账补偿的前提下,系统优先保证不超卖,并将少卖和暂态不一致控制在可发现、可恢复的范围内。
不超卖是一条实时约束,少卖是一个恢复能力问题。前者要求任何时刻都不能把同一份库存分配给两个有效订单;后者要求因异常被暂时占用或丢失的库存,最终能够被识别、释放或人工决策。
库存锁定适合放在高并发入口,用原子操作快速判断和占用热点库存,避免所有请求直接竞争数据库中的同一行。但缓存并不天然等于事实源,也不能替代订单状态、库存流水和审计记录。
我通常会把职责分成三层:缓存负责快速筛选和削峰,数据库负责持久化业务事实,消息与补偿任务负责把异步过程推进到最终状态。这样做的代价是系统复杂度上升,但复杂度是显式的、可监控的;直接把所有逻辑压进一条数据库事务,表面简单,故障时往往更难恢复。

库存系统的难点不在于全站平均请求量,而在于请求是否集中到同一个商品、同一个 SKU 或同一条库存记录。一个系统平均每秒几百次请求并不一定危险,但当其中大部分请求同时争抢一个限量商品时,数据库面对的是同一行记录的高频更新。
这类请求通常会经历查询库存、开启事务、加锁、判断数量、更新库存、写订单、提交事务等步骤。只要其中一个事务因为网络抖动或下游写入变慢而延长,后续事务就可能排队等待。连接池被占满后,表现出来的就不再只是库存接口变慢,而是订单、支付回调甚至后台管理接口一起超时。
这也是为什么我在容量评估时不会只看数据库总 QPS,而会额外看三个维度:单 SKU 请求集中度、库存行锁等待时间和库存事务平均持锁时长。热点集中度比总流量更能预测库存事故。
假设某活动商品可售 100 件,用户提交订单时,系统需要先防止其他用户继续占用这 100 件。此时不能直接把库存理解为“已经卖出”,因为订单可能在支付阶段失败,也可能因为风控、地址校验或库存校验被取消。
因此,创建订单时更合理的动作是锁定库存。锁定成功后,可售库存减少,锁定库存增加,订单进入待支付状态。用户支付成功,系统再把锁定库存转为已确认库存;用户取消或支付超时,系统把锁定库存释放回可售库存。
如果不设置锁定状态,系统往往只能在两个极端之间选择:要么订单创建时直接扣死库存,导致大量未支付订单长期占用;要么等支付成功后才扣库存,导致多个用户同时看到有货,最终出现超卖风险。
正常流程通常很容易写出来:扣库存、创建订单、支付成功、确认库存。真正让值班人员难以判断的是异常流程:缓存已经扣减但订单创建失败、订单创建成功但响应丢失、支付成功回调重复到达、取消消息晚于确认消息、回收任务执行到一半进程重启。
所以,库存方案不能只回答“接口怎么写”,还要回答“凌晨两点库存少了 7 件时,值班人员如何定位”。如果没有唯一业务号、库存流水、状态迁移记录和对账口径,运维人员只能通过猜测日志来恢复现场。

常见写法是使用带版本号的更新语句:只有版本号匹配时才允许扣减。它确实可以避免两个事务同时覆盖彼此的更新,但当大量请求竞争同一行时,失败请求会不断重试,数据库仍然承受大量查询和更新压力。
乐观锁解决的是“写入冲突后的正确性”,不一定解决“冲突请求太多带来的容量问题”。如果重试策略没有上限,流量高峰时还可能形成重试风暴,令原本已经拥塞的数据库进一步恶化。
Redis 的 Lua 脚本可以把“检查数量”和“扣减数量”放在一个原子执行单元中,这是防止并发超卖的重要手段。但它只保证这两个动作在 Redis 内部不可被其他脚本插入,并不保证订单服务、数据库和消息队列同时成功。
例如,缓存扣减成功后,应用实例在写数据库前崩溃,系统就会出现缓存少了库存、数据库没有对应记录的状态。如果没有库存流水和补偿机制,重启缓存时就可能恢复出错误数量。
分布式锁可以减少同一资源的并发执行,但锁本身不能替代状态机、幂等和事务。锁过期后,原持有者可能仍在执行;网络分区时,客户端可能误判锁已释放;释放库存时,如果没有业务唯一号,重复执行仍然会造成库存回加。
我的判断是:分布式锁适合保护短小的临界区,不适合承载跨支付、跨消息和跨服务的长流程。如果一个锁需要持续几分钟等待用户付款,系统最终会被锁续期、锁泄漏和异常释放拖垮。
只依赖每分钟扫描一次订单的做法,容易产生两个问题。第一,回收存在时间延迟,用户会看到库存暂时不可售;第二,多个任务实例可能重复扫描同一订单,若释放逻辑不幂等,就可能把库存回加两次。
更稳妥的做法是“事件触发加定时兜底”:订单取消或支付超时先发送释放事件,定时任务再扫描长时间未处理的锁定记录。两条路径最终都必须落到同一个幂等释放函数,而不是各自实现一套库存回加逻辑。
超卖通常很容易被业务感知,因为用户下单后无法履约。少卖则更隐蔽:库存实际还在,但被错误锁定、缓存偏差或失败消息暂时占用,系统因此拒绝了本可以成交的订单。
对于毛利较低、库存周转快的商品,少卖同样会造成明显损失。库存锁定时间过长、回收延迟过高和对账不及时,都会把可售库存变成“系统看不见的库存”。

在方案评审中,我会先问一个看似基础的问题:当缓存、数据库和库存流水的数量不一致时,最终以谁为准?如果团队无法回答,后续关于 Lua、分布式锁和消息队列的讨论都很容易变成局部优化。
常见选择是以数据库中的库存账本和库存流水作为长期事实源,以缓存中的可售库存作为高并发运行态。这样,缓存可以被重建,数据库记录则不能随意覆盖。若业务对实时吞吐要求极高,也可以使用独立库存服务作为事实源,但仍需要持久化流水和恢复机制。
缓存可以丢,事实不能丢;缓存可以重建,流水必须可追溯。这是我判断库存架构是否可运营的第一条标准。
锁定成功不应该只返回一个布尔值。至少需要关联业务请求号、订单号、商品或 SKU、锁定数量、锁定时间、过期时间和当前状态。
锁定成功后,系统要能够回答三个问题:这批库存属于谁?什么时候失效?下一步允许确认还是释放?如果只在缓存中保存一个递减后的数字,后续就无法准确处理取消、超时和人工补偿。
库存操作不能是任意加减,而应该是受到约束的状态变化。例如,待支付订单可以进入已确认或已取消,但已确认订单不能因为重复消费再次进入已确认;已释放的锁定记录也不能再次释放。
一个可落地的库存锁定记录可以包含以下字段:
“库存一致性”本身太宽泛,无法直接监控。我会把它拆成锁定一致性、确认一致性、释放一致性和账实一致性。
锁定一致性关注缓存扣减是否都有对应锁定记录;确认一致性关注支付成功后是否完成库存确认;释放一致性关注取消和超时订单是否最终归还库存;账实一致性则关注可售数、锁定数、已售数和库存流水之间是否满足业务公式。
如果库存总量为初始库存,通常可以用下面的关系进行校验:
初始库存 = 当前可售库存 + 当前锁定库存 + 已确认库存 + 待补偿差异
这不是所有业务都适用的唯一公式。比如仓储系统还可能包含在途、冻结、损耗和盘点差异,但核心思路不变:把“数字对不对”转化为“各类状态之和是否闭合”。

锁定请求应携带稳定的业务幂等号,例如订单号加商品行号,或者由上游生成的唯一请求号。服务收到请求后,先验证数量、SKU 状态和销售规则,再执行原子库存锁定。
缓存侧可以使用 Lua 脚本,将检查可售数量、扣减可售数量、增加锁定数量和记录锁定标识放入一个原子操作中。实际实现还要考虑脚本执行时间、热点 Key、集群路由和失败后的重试策略。
一个简化的伪代码示例如下,重点是表达状态顺序,不代表可以直接用于生产环境:
function lockInventory(request): existing = loadLockRecord(request.idempotencyKey) if existing is not null: return existing.result result = atomicReserve( sku = request.sku, quantity = request.quantity, lockId = request.lockId, expireAt = request.expireAt ) if result == INSUFFICIENT: return LOCK_FAILED createLockRecord( lockId = request.lockId, orderId = request.orderId, sku = request.sku, quantity = request.quantity, status = "LOCKED", expireAt = request.expireAt ) publishInventoryEvent(request.lockId) return LOCK_SUCCESS
这段流程里最容易被忽略的是“缓存锁定”和“锁定记录落库”之间存在窗口。缓存成功后应用进程可能崩溃,因此不能只依赖同步写库成功。常见做法包括记录可靠事件、使用待补偿状态、周期性扫描缓存锁定标识,或让库存服务本身具备可恢复的锁定日志。
如果锁定阶段已经减少了可售库存,确认阶段就不应该再次从可售库存中扣减同样的数量,否则很容易发生重复扣减。确认的核心动作,是把“锁定”转换成“已确认”,并记录对应的库存流水。
确认请求必须校验当前状态。只有处于待确认或锁定状态的记录,才允许进入已确认;已经释放的记录不能因为迟到的支付消息重新确认。支付回调晚到是常见情况,系统需要根据业务规则决定是拒绝确认、触发人工审核,还是重新走库存补偿流程。
订单取消、支付失败和支付超时都可以触发释放事件。释放消费者收到事件后,首先通过幂等键查找锁定记录,只有状态仍为锁定或待释放时,才执行库存归还和状态更新。
释放成功后,记录应变为已释放,而不是简单删除。删除会损失审计依据,也会让重复消息无法判断“之前是否已经释放过”。保留释放记录可以帮助运维人员回答:库存为什么回来了、由哪条消息触发、处理了几次、是否经过补偿。
定时扫描的职责不是替代事件,而是兜底处理漏消息、消费者宕机和任务失败。扫描任务应按过期时间建立索引,限制每次处理数量,并使用分片或租约避免多个实例重复抢同一批记录。
对账至少要同时比较缓存可售数、数据库库存账本、锁定记录汇总、已确认订单汇总和库存流水汇总。只拿缓存数量和数据库数量做相减,无法判断差异来自锁定未落库、确认未写入,还是释放重复执行。
对账结果最好分为三类:可以自动修正的短暂差异、需要重试的处理失败、必须暂停销售并人工确认的高风险差异。不同差异使用不同处置策略,不能把所有异常都直接“把库存加回来”。
| 差异类型 | 常见原因 | 自动处理建议 | 运维动作 |
|---|---|---|---|
| 缓存少于数据库 | 缓存扣减后锁定失败或缓存未重建 | 先冻结相关 SKU 的自动修正,重新计算运行态 | 核对库存流水和未完成订单 |
| 缓存多于数据库 | 释放重复、缓存回滚或确认未同步 | 禁止直接继续放量,进入差异队列 | 判断是否存在潜在超卖 |
| 锁定记录无订单 | 订单创建失败、响应丢失或异步落库失败 | 超过安全时间后释放 | 保留原始请求和补偿记录 |
| 支付成功但库存未确认 | 回调丢失、消费失败或状态冲突 | 重试确认,失败后进入人工审核 | 优先核查履约风险 |

下面使用一个脱敏后的情景推演说明问题。某活动商品初始库存 5000 件,活动开始后大量请求集中在一个 SKU。系统采用缓存预扣减,订单和锁定记录通过消息异步写入数据库。
活动前半小时,缓存侧响应稳定,但消息消费者的处理速度低于生产速度。部分锁定记录延迟写入,支付超时事件却已经开始产生。释放消费者找不到对应的锁定记录,于是把异常消息放入重试队列。
在业务侧看来,订单取消了,库存却没有立即恢复。缓存里有一部分库存被锁定,数据库里却没有完整锁定流水,运维人员无法只通过数据库确认哪些库存可以释放。最终表现为“没有超卖,但可售库存明显少于预期”。
这个案例的关键不是缓存技术选错了,而是系统把“锁定成功”当成了一个瞬间动作,却没有为后续记录、确认和释放设计同一条可追踪链路。缓存扣减速度越快,异步落库越慢时,差异窗口反而越大。
遇到库存异常时,我通常不会先重启缓存或直接执行库存加回脚本,而是按照业务号追踪一条完整链路:锁定请求、缓存原子操作、订单创建、锁定记录、支付结果、确认或释放事件、消费者处理结果和对账结果。
如果某个业务号只存在缓存操作,没有锁定记录,应判断为“锁定后落库失败”;如果存在锁定记录但没有订单,应判断为“订单创建失败或关联丢失”;如果支付成功但仍是锁定状态,应优先排查支付事件和确认消费者,而不是修改库存数量。
其中,“锁定成功率高”不一定是好事。如果确认率低、释放延迟高,说明系统把大量库存占住了,却没有及时恢复。运维看板应该同时展示锁定成功率和锁定转化率,避免只优化入口而忽略后端消化能力。

锁定记录刚创建几秒钟没有落库,不一定是事故;但超过业务允许的确认窗口仍没有任何状态变化,就应进入异常处理。这个时间窗口不能照搬其他系统,要根据支付时长、消息延迟、数据库提交时间和用户体验共同确定。
例如,普通商品可能允许锁定 15 分钟,秒杀商品可能只允许锁定 2 分钟,票务或预约业务则可能需要更长时间。锁定时间越长,少卖风险越高;时间越短,用户支付成功但库存已经释放的冲突风险越高。
如果商品请求分散、库存量较大、支付流程短,直接使用数据库条件更新加业务唯一约束,往往比引入复杂缓存链路更稳妥。此时最重要的是事务边界、库存流水、幂等号和取消回滚,而不是盲目追求缓存吞吐。
建议采用以下方案:
这类场景的优势是事实链路短,故障恢复简单。缺点是当某个商品突然成为热点时,原有设计可能没有足够的削峰能力,需要预留升级路径。
秒杀或限量活动中,大量用户在极短时间内争抢少量库存,数据库不适合作为每次请求的第一竞争点。可以让缓存承担库存预检查和原子锁定,再通过锁定单、消息和数据库流水完成后续处理。
这个场景需要特别关注三个参数:
如果活动允许少量用户看到“暂不可售”,可以在缓存或服务层设置保护阈值,提前停止进入数据库的请求。在库存趋近于零时,主动拒绝一部分边缘请求,通常比让所有请求继续排队更安全。
一个订单包含多个 SKU 时,库存锁定难度明显上升。比如订单需要同时占用商品 A、商品 B 和一个赠品,如果 A 锁定成功而 B 锁定失败,系统必须决定是否释放 A,还是进入部分锁定状态。
如果业务要求整单成功,建议使用锁定批次号统一管理多个 SKU。所有 SKU 锁定成功后,批次才进入可支付状态;任一 SKU 失败,则释放已经成功锁定的其他 SKU。
如果业务允许部分履约,则不能简单回滚全部库存,而应在订单层明确哪些商品成功、哪些商品失败。此时库存流水必须记录到订单行,而不能只记录到订单总额。
预售和预约业务通常不是几分钟内完成支付,锁定时间可能长达数小时甚至数天。此时不适合长期依赖缓存保存所有锁定明细,应该把数据库或专用库存服务作为长期状态存储,缓存只承担查询加速和短时并发保护。
票务场景还需要处理座位资源的唯一性。一个座位被锁定后,必须确保同一场次、同一区域和同一座位号不会被重复分配。此时唯一索引、座位状态机和超时释放比单纯的数量扣减更重要。
药品、金融权益、稀缺票券和强履约约束商品,对超卖的容忍度很低。发生缓存与数据库差异时,不应立即执行“自动加库存”,因为这可能掩盖已经成交但尚未确认的订单。
建议设置库存保护开关。当对账差异超过阈值、消息积压超过阈值或确认延迟持续升高时,自动将相关 SKU 切换为保护状态,暂停新增锁定,同时保留查询、取消和已存在订单的处理能力。

直接数据库扣减的主要优势是路径短。库存判断、扣减和流水可以在同一事务中完成,开发和排查成本较低。对于请求量可控、库存分散的业务,这往往是合理的第一版方案。
它的短板也很明确:当请求集中到同一个 SKU 时,数据库行锁成为瓶颈;当支付和库存处于同一长事务时,事务持锁时间会被业务链路拖长;当接口超时触发重试时,数据库可能承受更多重复请求。
缓存原子扣减可以大幅减少数据库热点竞争,尤其适合瞬时流量高、库存量有限的活动。但它增加了缓存与数据库之间的同步问题,也让故障恢复从单机事务扩展到跨组件补偿。
如果团队没有可靠消息、库存流水、对账脚本和人工保护开关,缓存方案的高吞吐可能只是把问题从“接口超时”推迟成“库存无法解释”。因此,我不会只根据压测中的 QPS 选择缓存方案,还会把故障演练和对账耗时纳入评估。
锁定、确认和回收模型能够清晰表达订单生命周期,也能把实时入口和异步后处理拆开。它适合业务正在增长、交易链路较长、库存价值较高的系统。
代价是需要维护状态机、幂等约束、消息重试、超时扫描、库存流水和对账工具。团队如果只实现了锁定和确认,没有实现释放与补偿,系统就会在活动结束后留下大量悬挂库存。
| 方案 | 适合场景 | 主要收益 | 主要代价 | 上线前必须验证 |
|---|---|---|---|---|
| 数据库条件扣减 | 低并发、库存分散、流程短 | 链路短,事实源清晰 | 热点行竞争,扩展性有限 | 锁等待、重试上限、唯一约束 |
| 缓存原子扣减 | 瞬时高峰、热点 SKU、库存有限 | 入口响应快,减少数据库竞争 | 跨组件一致性和恢复复杂 | 缓存故障、落库失败、重建策略 |
| 锁定加确认回收 | 支付链路长、订单状态复杂 | 状态清晰,可恢复、可对账 | 开发和运维治理成本较高 | 幂等、超时释放、重复消息、对账 |
| 专用库存服务 | 多业务共享库存、库存价值高 | 职责集中,规则统一 | 服务建设和迁移成本高 | 容量隔离、降级开关、数据迁移 |
如果业务还没有明显热点,也没有跨服务库存共享,过早引入缓存、消息队列和分布式锁,可能让团队承担不必要的运维成本。系统复杂度不是越高越先进,而是要与故障代价和流量规模匹配。
我的建议是分阶段演进:第一阶段先做好数据库条件扣减和幂等;第二阶段识别热点 SKU,增加缓存预检查;第三阶段引入锁定、确认和回收状态机;第四阶段再建设自动对账、补偿和容量隔离。

并不是所有差异都需要立即电话告警。建议把告警分成提示、一般告警和紧急告警三个级别。
告警内容必须带上商品、SKU、订单范围、差异数量、首次发生时间和建议动作。只发送“库存异常”四个字,会把定位工作全部推给值班人员。
建议将请求幂等号贯穿 API 网关、库存服务、订单服务、消息和数据库流水。日志中至少要能通过订单号或锁定号查到一次操作的全部阶段。
对于异步消息,要记录生产时间、消费时间、重试次数、最终处理状态和异常原因。对于补偿任务,要记录补偿前后数量、执行人或任务实例、关联的原始流水,避免人工修复变成新的不可追溯操作。
库存压测应覆盖热点集中、库存快速耗尽、重复提交和大量取消等业务情况。单纯增加总请求量,无法模拟真实库存竞争,因为真正的瓶颈通常来自少数热门 SKU。
故障演练至少包括:
库存修复脚本不应只提供一个“库存加一”按钮。至少应要求输入业务号、库存流水号、修复原因和目标状态,并在执行前展示影响范围。
自动补偿也要设置单次最大修复量、单 SKU 频率限制和人工确认阈值。对于支付成功但库存未确认的订单,修复优先级通常高于普通超时锁定,因为它直接关系到已完成交易的履约。
一个成熟的库存服务应该支持按商品、SKU、活动和业务渠道进行保护,而不是只有全局开关。出现异常时,可以暂停新增锁定,但允许已锁定订单确认或释放,避免把正常处理链路一并切断。
保护开关的触发条件可以包括:账实差异超过阈值、确认延迟连续多个周期超标、消息堆积快速上升、缓存重建失败或数据库锁等待异常。开关触发后,要明确谁有权限解除,以及解除前必须完成哪些检查。

在没有缓存和消息队列之前,先补齐最基础的可追溯能力。每次库存变化都要有业务号、操作类型、数量、前后值、操作时间和结果状态。
数据库扣减必须使用条件更新,避免库存被减成负数。订单号和请求号应建立唯一约束,释放和确认则通过状态判断实现幂等。
UPDATE inventory SET available_quantity = available_quantity - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_quantity >= :quantity AND status = 'ON_SALE';
这条 SQL 不能解决所有高并发问题,但它提供了一个可验证的底线:库存数量不会因为一次条件更新直接变成负数。后续引入缓存时,也应保留同样的业务约束。
不是所有商品都值得放入高并发缓存。可以根据单 SKU 请求占比、失败率、锁等待、活动时间和库存价值识别热点商品,只对热点库存做入口保护。
全量缓存会增加缓存容量、重建、失效和对账范围。局部缓存虽然需要维护热点识别逻辑,但能把复杂度集中在真正需要的地方,也更容易进行灰度验证。
当支付、审核或履约流程变长时,把锁定从订单表中的一个字段升级为独立的锁定记录。锁定单应具备唯一号和过期时间,并明确锁定、确认、释放、补偿中的状态。
此时要优先完成正常流程和重复流程,再处理极端故障。很多团队一开始就设计几十种异常状态,最终却没有把最常见的支付超时和重复取消做扎实。
对账不应只在事故后运行。建议按商品、活动和业务日期生成日常对账结果,对于差异可解释的记录自动归档,对于高风险差异触发告警。
补偿任务需要有速率限制和审计记录,执行后重新对账,确认差异是否收敛。没有二次对账的补偿,只是一次新的写操作,不能证明问题已经解决。

库存系统的核心矛盾,是高并发请求希望立即得到结果,而库存事实又必须经得起重复请求、异步延迟、组件故障和事后审计。数据库适合保存事实,却不一定适合承受所有热点请求;缓存适合削峰,却不应该独自承担长期事实;消息适合解耦,却无法自动消除重复消费。
库存锁定的价值,在于把一次不可逆的“直接扣减”改造成一组可恢复的业务动作:先锁定,后确认;未完成就释放;出现差异就对账;无法自动判断时先保护库存。
真正可靠的库存方案,不是承诺永远不会发生异常,而是让每一种异常都有唯一标识、明确状态、可重试路径和最终处置方式。
如果当前系统还在直接更新数据库,先统计过去一周的热点 SKU、数据库锁等待和重复请求数量,不要急着全量引入缓存。先把条件扣减、唯一约束、库存流水和幂等补齐,建立可靠的基线。
如果已经使用缓存扣减,下一步应检查缓存扣减成功后是否一定能找到对应的锁定记录,支付成功和订单取消是否都有可重试的确认、释放路径,以及缓存与数据库是否能够按状态进行对账。
如果系统已经出现库存悬挂、少卖或无法解释的差异,先暂停异常 SKU 的新增锁定,再按业务号追踪锁定、订单、支付、消息和流水,不要直接执行批量加库存。修复之后必须进行二次对账,确认差异真正收敛。
从运维团队增长的角度看,库存锁定不是一次技术升级,而是一套随着业务规模持续演进的运营能力。先建立可解释的状态,再建立可恢复的链路,最后用指标和演练验证它。这样,数据库、缓存和消息队列才不是各自为战的组件,而会共同形成一条能够承受增长、识别风险并在故障后恢复的库存一致性闭环。
我一开始也倾向于直接执行 UPDATE stock SET available = available - 1 WHERE sku_id = ?AND available > 0,因为数据库是事实来源,逻辑看起来最简单。
后来在压测热点 SKU 时发现,真正的瓶颈不是这条 SQL 能不能正确执行,而是大量请求同时争抢同一行时产生的锁等待、连接池堆积和超时。我的疑问是:库存锁定到底解决了什么,为什么它不会只是把复杂度从数据库搬到了缓存?
库存锁定的核心价值,不是替代数据库扣减,而是把高并发请求对数据库热点行的直接竞争,改造成一次快速、可控的预占用操作。请求先在缓存或库存服务中完成原子校验和锁定,只有锁定成功的请求才进入订单确认和数据库持久化流程。我在设计这类链路时,通常会把库存拆成“可售库存、锁定库存、已确认库存”三个数字。
用户提交订单时只减少可售库存并增加锁定库存;支付成功或业务确认后,再把锁定库存转为已确认库存;取消或超时则释放锁定库存。一个可复现的热点 SKU 压测示例是:1000 个并发请求同时争抢 100 件库存。直接更新数据库时,所有请求会集中竞争同一库存记录;
采用缓存原子锁定后,只有前 100 个有效请求进入后续流程,其余请求在入口处快速失败。这里降低的是数据库无效竞争,不是凭空增加库存吞吐量。
方案主要优点主要风险 直接更新数据库实现简单,数据库是持久化事实源热点行锁竞争,容易拖垮连接池 仅缓存扣减响应快,适合削峰缓存故障、持久化失败后难以追溯 缓存锁定加数据库确认减少热点竞争,流程可恢复需要幂等、回收、补偿和对账 但不能把“缓存原子扣减”直接等同于“全链路强一致”。
缓存脚本只能保证检查库存和扣减库存这一个局部动作的原子性,数据库写入失败、订单重复提交、消息重复消费和缓存重启仍然需要额外处理。因此,我的判断是:当库存集中在少数热点 SKU,且并发峰值明显高于数据库单行更新能力时,库存锁定值得引入;
如果业务并发低、库存变化简单,直接使用数据库条件更新反而更容易维护。
我最担心的并不是系统报错,而是系统表面上返回成功,实际库存却在缓存、数据库和订单之间逐渐出现偏差。以前遇到过锁定成功但订单创建失败、订单取消后库存没有释放的场景,所以我想知道:库存一致性究竟应该以哪个系统为准,如何判断一次扣减真的完成了?
库存系统要同时关注两类错误:超卖是卖出的数量超过真实库存,少卖则是库存仍然存在,却因为异常锁定、同步失败或回收延迟而暂时无法销售。只强调“不超卖”并不代表方案完整,因为少卖会直接损失可售机会。我更倾向于把一致性拆成三个层次。第一层是入口原子性,确保同一时刻不会有两个请求同时消耗同一份可售库存;
第二层是业务状态一致,锁定、确认、取消必须按照合法状态迁移;第三层是最终可恢复,缓存、数据库和订单发生短暂差异后,能够通过流水和对账收敛。建议为每次库存操作生成唯一的库存流水号,例如 reserve_id。锁定成功后记录商品、数量、订单号、状态、创建时间和过期时间;
确认、取消和回收都更新这条流水,而不是简单地再次执行“加一”或“减一”。这样可以判断某个动作是否已经处理过。
动作库存变化必须校验的条件 锁定可售库存减少,锁定库存增加锁定单唯一,剩余库存足够 确认锁定库存减少,已确认库存增加当前状态必须为已锁定 取消或超时回收锁定库存减少,可售库存增加当前状态未确认,回收单唯一 缓存可以承担高并发下的快速判断,但数据库应保留订单状态、库存流水和对账依据。
缓存不是唯一事实来源,尤其不能依赖缓存过期来完成业务回收,因为过期事件可能丢失、延迟,或者在服务故障期间没有被及时消费。运维上,我会重点观察“可售库存加锁定库存加已确认库存”是否等于库存总量。这个等式不是所有业务模型都能直接套用,但它能作为非常有效的第一道校验。
一旦出现负数、重复流水或总量无法解释,应立即暂停异常 SKU 的继续销售并启动对账。
我在接口重试和支付回调场景中经常看到同一个订单被提交两次,或者同一条取消消息被消费两次。如果第一次请求已经锁定库存,但响应在网络中丢失,客户端再次提交会不会再次占用库存?我想知道幂等应该放在哪一层,单靠分布式锁是否足够。
单靠分布式锁通常不够。锁只能限制某一小段时间内的并发执行,不能天然识别“这个业务请求以前是否已经成功处理”。请求重试、服务重启和消息重复投递都可能在锁释放后再次发生,因此库存操作必须有持久化的业务幂等依据。
我会把订单号和库存操作类型组合成业务唯一键,例如 order_id + sku_id + action。锁定、确认、取消和回收分别拥有明确的操作记录;执行前先查询操作状态,已经成功的直接返回原结果,处理中则进入重试或查询,失败的才允许重新执行。库存状态机也必须限制非法跳转。
比如“已确认”不能再次进入“已确认”,“已释放”不能再次释放,“已取消”的订单不能重新确认。数据库唯一约束负责拦截重复写入,状态条件负责拦截重复业务动作,两者缺一不可。
异常场景错误做法更稳妥的处理 锁定成功但响应丢失客户端重试并再次扣减使用幂等键查询原锁定结果 确认消息重复到达每次消费都减少锁定库存只允许“已锁定”状态转为“已确认” 取消和定时回收同时执行两个任务都直接增加可售库存通过唯一回收记录和状态更新竞争 数据库写入超时无法判断结果就立即重试先查询流水状态,再决定是否补偿 超时回收最好采用“延迟消息加定时扫描”两条路径。
延迟消息负责及时释放,定时扫描负责兜底;但两条路径必须共享同一套幂等状态,不能把它们当成两次独立的库存增加操作。判断幂等是否有效,不要只看接口是否返回成功,还要压测这些情况:同一请求连续提交 10 次、确认消息重复投递、取消和回收并发执行、数据库提交成功但客户端超时。
最终结果应该是库存只发生一次有效变化,并且每次变化都能在流水中找到来源。
很多方案在开发环境里都能跑通,但一到大促或热点活动就暴露问题。我不想只看接口平均响应时间,因为平均值可能掩盖锁等待、库存悬挂和补偿堆积。假如我要评审一套库存锁定方案,应该用哪些指标和故障演练来判断它是真的可靠,而不是只在正常流程下表现良好?
我评审库存锁定方案时,第一步不是看缓存 QPS,而是先问清楚异常库存能否被发现、定位和恢复。一个系统即使接口 P99 很低,如果锁定库存长期不回收、对账差异没人处理,最终仍然会表现为少卖或库存错乱。
业务指标至少应包括锁定成功率、确认成功率、回收成功率、锁定超时数量、幂等冲突数量、补偿任务失败数和库存对账差异。技术指标则要覆盖缓存命令延迟、数据库锁等待、连接池使用率、消息堆积、补偿延迟以及库存接口 P95、P99 延迟。
指标观察意义异常时的判断方向 锁定成功率下降入口库存判断或热点容量可能不足检查库存初始化、限流和缓存延迟 锁定超时持续增加订单链路或支付链路变慢检查过期策略、消息堆积和订单状态 回收成功率下降少卖风险开始累积检查重复消费、补偿任务和数据库写入 对账差异增加缓存、流水和数据库出现长期偏差暂停异常 SKU 并启动修复流程 数据库锁等待升高削峰没有真正生效检查确认批量、热点隔离和事务范围 上线前至少要演练四类故障:缓存扣减成功但数据库写入失败,数据库提交成功但响应丢失,回收任务重复执行,以及缓存重启后库存重建。
演练的验收标准不应只是服务恢复,而应包括库存总量可解释、订单状态可查询、重复操作不产生二次扣减。发布策略上,我建议先选择非核心 SKU 或低风险活动进行灰度,并保留人工暂停销售开关。灰度期间同时记录缓存库存、数据库库存、锁定流水和订单状态,连续观察一个完整的锁定超时周期后,再扩大流量范围。
最终是否值得上线,可以用一个简单标准判断:系统能否回答“现在还剩多少库存、哪些库存被锁定、哪些订单已经确认、哪些锁定即将过期、差异由谁处理”。如果这些问题只能靠人工查日志回答,方案还没有达到可运营状态。


读者评论
文章把库存问题从单纯扣减扩展到锁定、确认、释放和补偿,状态机思路比较清晰。尤其是对少卖、库存悬挂的讨论,说明了只防超卖还不够。
对运维人员来说,库存流水、唯一业务号和对账机制很关键。文章没有把缓存或分布式锁包装成万能方案,这一点比较客观,但实际落地仍需要结合业务容错能力。
热点请求集中到同一 SKU 时,数据库行锁和连接池确实可能成为瓶颈。缓存原子扣减能削峰,但文章也明确指出它不能替代数据库事实记录,适合作为架构设计参考。
事件触发加定时兜底的回收方式比较实用,前提是确认、取消和超时释放都使用同一套幂等逻辑。支付回调乱序、重复消费等边界场景仍需要充分压测验证。