数据库存:技术负责人实操指南:围绕库存锁定解决“缓存不同步”
库存缓存不同步,最容易被误判成“缓存删除失败”。但我在排查订单系统时反复看到,真正让问题持续发生的,往往不是某一条缓存命令,而是团队没有把“可售、锁定、已售、释放”定义成一条可追踪的状态链。页面显示还有库存、下单却失败只是表象,根因通常在库存事实源、订单状态和异步补偿之间没有形成闭环。
本文不从“缓存怎么删”开始,而是从库存锁定开始重建问题。你将看到库存状态应该如何建模,数据库、缓存、消息队列分别承担什么责任,支付成功与超时释放同时到达时如何裁决,以及怎样用对账和监控把偶发不一致变成可定位、可修复的工程事件。
在需要防止超卖、少卖和重复释放的交易系统中,我通常先要求团队写出一句明确的话:数据库中的哪一组记录,代表库存的最终事实。如果这句话说不清楚,后面讨论缓存失效、延迟双删或分布式锁,基本都会变成局部修补。
大多数普通电商和零售场景,可以把数据库中的库存流水、库存汇总和订单锁定记录作为最终事实来源。缓存负责快速展示和热点读取,不能单独决定订单是否能成立。页面上的“剩余 3 件”和真正扣减成功之间,本来就不应被设计成绝对相等。
某些高并发系统会先在内存型存储中做原子预扣,这并不自动意味着内存数据就是最终事实源。只要后续仍需要数据库落单、支付确认、超时释放、库存对账和人工审计,就必须定义内存扣减与数据库事实之间的恢复关系。
库存锁定的本质是:系统先把一部分库存从“可被别人购买”变成“暂时属于某个业务单据”,但这部分库存尚未必然完成支付或履约。它是资源占用,不是最终销售。
因此,库存至少需要区分可用、锁定、已售和已释放四类状态。若系统只保存一个 stock 字段,再依赖订单状态推断库存归属,遇到支付回调延迟、订单取消或重试时,往往很难判断某一次扣减到底应该保留还是回滚。
| 库存状态 | 业务含义 | 能否再次被下单 | 典型转移条件 | 需要关注的风险 |
|---|---|---|---|---|
| 可用 | 可以被新订单占用 | 可以 | 初始化、释放成功、退货回库 | 并发扣减、负库存 |
| 锁定 | 已被订单暂时占用 | 不可以 | 下单并完成库存预占 | 长期不释放、重复释放 |
| 已售 | 交易或业务确认已完成 | 不可以 | 支付成功、履约确认 | 支付与释放竞争 |
| 已释放 | 原锁定资源已归还可用池 | 通常重新进入可用 | 取消、超时、风控拒绝 | 状态重复迁移 |
这张表有一个容易被忽略的重点:“已释放”可以是业务流水状态,不一定要作为库存汇总中的独立数量长期保存。它的价值在于记录资源为什么回到了可用池,便于审计、对账和排查重复操作。

缓存适合承载商品详情页、列表页和库存展示等高频读请求。它可以告诉用户“当前大概率还有货”,也可以在热点流量下提前拦截明显的售罄请求,但它不应该单独决定支付后是否还能履约。
我更倾向于把缓存库存称为“展示库存”或“接入层库存视图”,而不是“真实库存”。这个命名变化看似只是文字调整,却能帮助产品、开发、测试和运营统一预期:展示值允许存在短暂延迟,下单结果必须由库存锁定流程确认。
假设一个商品总库存为 100 件,页面缓存显示可售 20 件。用户 A 和用户 B 几乎同时提交订单,A 的数据库扣减先成功,但缓存删除因为网络超时没有完成。此时用户 C 仍可能看到 20 件,而不是 19 件。
这属于展示延迟,并不一定导致超卖。真正危险的是另一种顺序:线程 A 更新数据库后删除缓存,线程 B 在数据库事务提交前读取旧值,再把旧值重新写回缓存。删除动作虽然成功,缓存却重新恢复成旧数据。
还有一种更难排查的情况:订单已经创建并锁定库存,支付回调与超时释放任务在同一秒到达。若两边只根据当前订单状态做简单判断,可能出现支付成功但库存被释放,或者订单取消却没有归还库存。
技术负责人必须先区分两类异常。第一类是展示视图延迟:数据库已经正确扣减,缓存还没有刷新,用户看到的数字暂时偏大或偏小。第二类是事实链路错误:库存锁定流水、订单状态和数据库汇总无法相互解释。
第一类问题的主要影响是用户体验和无效请求增加。第二类问题会影响履约、财务、售后甚至法律责任。两者不能用同一个告警级别,也不能用同一种修复方式。
| 现象 | 数据库事实 | 缓存表现 | 风险等级 | 优先处理方式 |
|---|---|---|---|---|
| 页面多显示 1 件 | 库存已正确减少 | 仍是旧值 | 中 | 缓存失效重试、回源和监控 |
| 数据库为 0,缓存为 5 | 已售罄 | 持续展示有货 | 高 | 立即校正热点缓存并排查回写链路 |
| 订单已支付但锁定未转售出 | 状态链断裂 | 可能无法释放 | 高 | 冻结自动释放,执行订单库存对账 |
| 库存出现负数 | 并发控制失效 | 可能同步错误 | 严重 | 限制写入、保留现场、定位扣减路径 |
我在实际排障中不会先问“缓存用了什么产品”,而会先问四件事:这次库存变更的业务流水号是什么,数据库事务是否提交,缓存失效事件是否投递,订单状态是否允许这次库存迁移。四个问题都能回答,故障通常就已经缩小到一个很具体的环节。

低并发时,数据库更新、删除缓存和消息通知之间即使存在几十毫秒差异,用户也可能感知不到。秒杀、直播带货、促销开场等场景会在短时间内集中放大读写竞争,任何没有定义顺序的异步动作都可能成为不一致来源。
高峰场景还有一个特点:问题往往不发生在平均值上,而发生在尾部。平均缓存失效耗时可能只有 20 毫秒,但少数网络抖动、线程池排队或消息积压会让库存视图延迟数秒甚至数分钟。因此监控不能只看平均延迟,应同时看 P95、P99、最大延迟和积压量。
“数据库更新成功,删除缓存”是旁路缓存的基础动作,不是完整的一致性方案。删除可能失败,删除后可能被旧读回写,消息可能重复,服务也可能在数据库提交与缓存操作之间突然退出。
如果团队把删除缓存当作事务的一部分,却没有记录删除失败,也没有重试和对账,那么系统实际上只是把不一致从同步链路隐藏到了后台。用户不一定马上看到问题,但库存差异会在之后变得更难还原。
更稳妥的设计是:数据库事务完成后产生库存变更事件,事件中携带库存流水号、商品编号、版本号和变更类型。缓存消费者根据版本判断是否需要失效或刷新,失败事件进入可重试队列,超过次数后进入人工可见的异常列表。
延迟双删的思路是先删除缓存,更新数据库,再延迟一段时间重新删除缓存。它可以降低旧值回写的概率,但它依赖延迟时间、读请求耗时、线程调度和数据库提交速度,不能被当作强一致保证。
如果读请求来自多个服务,或者缓存还有其他回填路径,第二次删除也未必能覆盖所有旧值来源。它更适合作为低复杂度场景的风险降低手段,而不是库存锁定系统的唯一安全措施。
我的判断标准是:如果缓存不准只影响页面展示,可以考虑延迟双删;如果缓存不准会直接影响资源分配,则必须把最终扣减放在具备原子性的库存写入流程中。
分布式锁能串行化某些代码区段,但它也引入了锁过期、续租失败、持锁服务宕机、锁粒度过大和吞吐下降等新问题。更重要的是,锁本身不能替代数据库条件更新,锁释放后仍可能有重复请求继续执行。
对于简单库存扣减,我通常优先使用数据库的条件更新,让“库存大于零”和“库存减一”在同一条原子语句中完成。只有当库存变更还涉及复杂的跨表规则、外部资源或无法用条件更新表达的业务约束时,才评估是否需要额外锁。
消息队列只是把变更通知从同步调用改成异步传递。生产端可能发送失败,消费端可能重复执行,消费者可能处理成功但确认失败,消息也可能因为顺序和重试产生延迟。
因此,库存事件消费者必须具备幂等能力。一个简单做法是建立消费记录表,以业务流水号或事件编号作为唯一键;另一种做法是保存对象版本,只接受版本更高的事件。具体选择取决于事件是否严格有序,以及业务是否允许跳过中间版本。
如果数据库扣一次、缓存也扣一次,团队很容易把两个数字都当成事实,最终出现“数据库扣成功、缓存扣失败怎么办”“缓存扣成功、数据库扣失败如何回滚”的双写困境。
更清晰的做法是给两者分工:数据库负责提交库存事实,缓存负责提供读取视图;或者明确使用缓存作为高并发预扣入口,并设计预扣流水、落库确认、恢复扫描和丢失后的重建机制。最危险的状态,是系统没有明确选择却同时依赖两套数量。

库存系统通常至少有三种不同的一致性要求。商品详情页的库存数字可以允许短暂延迟;下单锁定必须保证不能重复占用;支付完成后的库存归属则需要可审计、可追溯、可恢复。
如果把这三种要求都称为“库存一致性”,方案就会变得过重或过轻。技术负责人应先把读展示、资源锁定、交易确认和履约同步拆开,再分别定义允许的延迟和失败处理方式。
| 业务环节 | 可接受延迟 | 必须保证的条件 | 推荐事实依据 |
|---|---|---|---|
| 详情页展示 | 通常允许秒级 | 不能长期显示已售罄商品有货 | 缓存视图,必要时回源 |
| 提交订单 | 通常要求同步确认 | 库存条件判断与扣减不可分离 | 数据库条件更新或可靠原子预扣 |
| 支付确认 | 可异步,但必须可追踪 | 支付和释放只能有一个合法终态 | 订单状态机与库存流水 |
| 履约分配 | 依业务时效确定 | 仓库、批次和订单数量可对账 | 库存服务或数据库事实表 |
普通标准商品的库存通常是可替代的,100 件中的任意 1 件都可以满足订单。这类场景可以使用汇总库存加锁定流水的模式。票务、座位、房间、设备档期等资源不可随意替代,则必须给每一个资源建立唯一状态。
可替代资源关注的是数量原子性,不可替代资源关注的是唯一占用权。两者都叫“库存”,但数据库约束完全不同。前者重点是条件扣减,后者重点是唯一索引、状态转移和冲突裁决。
如果缓存短暂错误不会造成实际损失,系统可以选择异步失效、定时校准和请求回源。若一次错误就可能造成不可逆的超卖、重复售票或无法履约,关键写路径就不能依赖无法确认结果的异步动作。
我会要求方案评审者回答三个问题:失败后谁发现,发现后谁修复,修复后如何证明已经恢复。如果答案只是“定时任务会处理”,却没有任务范围、幂等条件和失败告警,这个方案还没有真正落地。

最基础、也最容易被忽视的原则是:不要先查询库存,再在应用层判断,最后执行扣减。两个请求都可能读到库存为 1,然后同时通过判断,造成最终库存为负数或订单数量超过真实库存。
对于可替代库存,可以使用条件更新。下面的示例只表达核心思路,实际字段还需要结合仓库、批次、商品规格和租户等维度设计。
UPDATE sku_stock SET available_stock = available_stock - :quantity, locked_stock = locked_stock + :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_stock >= :quantity;
执行后必须检查受影响行数。受影响行数为 1,说明条件满足且扣减已执行;为 0,则可能是库存不足、商品不存在或版本条件不匹配。不要只依赖接口返回的“成功”字段,更不要在受影响行数为 0 时继续创建“库存已锁定”的订单。
汇总表适合快速判断当前数量,流水表适合解释数量为什么变化。只保留汇总字段,系统可以知道现在是 73 件,却不知道这 27 件是被哪些订单锁定、哪些已经支付、哪些释放失败。
建议每次库存变更都写入不可变的库存流水。流水至少包括业务单号、商品或资源编号、变更前数量、变更数量、变更后数量、操作类型、请求幂等键、服务实例、事件编号和创建时间。
| 字段类别 | 建议字段 | 排查价值 |
|---|---|---|
| 业务识别 | 订单号、商品编号、规格编号 | 确认库存变更属于哪张业务单据 |
| 数量变化 | 变更前、变更量、变更后 | 还原是否发生重复扣减或重复释放 |
| 状态信息 | 锁定、确认、释放、取消 | 判断是否走了非法状态路径 |
| 幂等信息 | 请求号、事件号、操作号 | 识别重试是否重复生效 |
| 时间信息 | 创建时间、提交时间、消费时间 | 定位事务、消息和缓存之间的延迟 |
一个可审计的订单库存流程,建议把库存动作拆成三个阶段。创建订单时只做锁定,不直接标记为已售;支付成功后把锁定转为已售;取消或超时后才把锁定释放回可用。
这种拆分的好处是,支付失败和支付超时不再需要“反向猜测”数据库库存,而是针对明确的锁定记录执行状态迁移。每一个动作都有前置状态,重复回调也不会随意改变最终数量。
UPDATE inventory_reservation SET status = 'CONFIRMED', confirmed_at = CURRENT_TIMESTAMP WHERE reservation_id = :reservation_id AND status = 'LOCKED';
如果受影响行数为 0,不能直接认为支付失败。它可能表示已经确认过、已经释放过,或者记录不存在。系统应根据当前状态做幂等响应,并把非法状态转移记录到异常队列,而不是继续修改汇总库存。
定时任务不能简单地扫描“创建时间超过 15 分钟的订单”然后全部回加库存。订单可能已经支付,只是支付状态同步延迟;也可能正在退款、风控审核或人工介入。
更安全的释放条件是:锁定记录到期、订单仍处于待支付、没有成功支付流水、锁定记录尚未释放。释放动作成功后,再通过同一事务或可靠事件更新库存汇总。

如果应用在数据库事务提交前就删除缓存,其他请求可能在这段时间读取数据库旧值并重新回填缓存。此时,缓存删除虽然发生过,但事务失败后会留下“数据库未变、缓存已空”的另一种状态,后续行为变得不可预测。
更稳妥的顺序是先完成数据库事务,再发布库存变更事件,最后由缓存侧执行失效或刷新。这里的“事务之后”不是简单地把代码放在 commit() 后面,而是要考虑进程在提交成功后立即宕机的情况。
常见错误是:数据库事务提交成功后,应用调用消息发送接口;如果应用刚提交就崩溃,消息可能根本没有发送。数据库已经变了,缓存却没有收到任何通知。
一种常见改进是使用事务消息、可靠事件表或本地消息表。事务内同时写入库存变更和待发送事件,后台投递程序持续扫描未发送事件,发送成功后更新事件状态。这样即使主请求进程退出,后续任务仍有机会继续投递。
本地消息表不是免费方案,它会增加数据库写入量、事件清理和投递监控。但对于库存这类需要审计的业务,增加一张可靠事件表,通常比依赖人工找回缓存更容易控制。
删除缓存意味着下一次读取时重新加载;刷新缓存意味着由事件消费者直接把新值写入缓存。两者各有边界:删除简单、对旧值依赖少,但高峰期可能引发缓存击穿;刷新读取效率高,但要处理事件乱序和旧事件覆盖新值。
如果使用刷新模式,建议在缓存值中携带库存版本。只有事件版本大于当前缓存版本时才允许覆盖。不能只比较事件到达时间,因为不同机器的时钟可能存在偏差,消息重试也可能让旧事件晚于新事件到达。
if event.version > cache.version:
cache.set(
key=event.sku_id,
value=event.available_stock,
version=event.version
)
else:
ignore(event)
如果使用删除模式,也要记录删除失败。缓存删除接口的成功返回,只能说明本次调用没有报错,不代表所有副本、所有回填路径和所有下游视图都已经完成更新。
旁路缓存常见流程是缓存未命中后查数据库,再把结果写回缓存。问题在于,查询开始时数据库可能还是旧值,查询结束时数据库已经更新。如果没有版本检查,旧查询结果就可能覆盖新缓存。
在库存场景中,可以通过短暂延迟二次失效、版本号校验、写入时比较版本或让读请求在关键窗口直接回源,降低旧值回写风险。方案不需要全部叠加,但必须根据业务风险选择,而不是把所有缓存技巧机械组合。

订单创建后锁定库存,系统设置 15 分钟支付窗口。第 15 分钟时,超时任务发现订单仍待支付,准备释放;同一时间,支付渠道的成功回调到达,准备确认订单。两个动作都合理,但最终只能有一个动作改变锁定记录。
如果支付回调先执行,锁定应转为已售,释放任务随后发现状态已经不是待支付,于是幂等退出。如果释放先执行,支付回调到达后不能直接把已释放库存标记为已售,而应进入支付异常处理和人工或自动退款流程。
不要先查询订单状态,再在应用层决定执行确认或释放。两个线程都可能查到待支付,然后分别执行不同动作。更好的方式是让状态转移本身带有前置条件,只有一个操作能成功更新记录。
UPDATE inventory_reservation SET status = 'RELEASED', released_at = CURRENT_TIMESTAMP, release_reason = 'PAYMENT_TIMEOUT' WHERE reservation_id = :reservation_id AND status = 'LOCKED' AND expire_at <= CURRENT_TIMESTAMP;
支付确认也使用相同原则。两个操作谁先成功,谁就完成状态占用;另一个操作根据受影响行数和当前状态做幂等判断。这样裁决权落在数据库的原子更新上,而不是落在应用线程的执行先后上。
有些系统把订单表作为一套状态机,把库存表作为另一套状态机,却没有记录两者之间的关联事件。最终出现订单已取消、库存仍锁定,或者订单已支付、库存已经释放。
每次库存状态迁移都应绑定订单号和库存锁定号。对账时至少要能回答:这个订单当前是什么状态,这个锁定记录当前是什么状态,这两者最后一次变更分别由哪个事件触发。
支付平台或上游系统可能重复通知成功,也可能先通知订单查询结果,再发送异步回调。库存确认接口不能把“收到成功回调”理解为可以无条件加已售库存,而应根据锁定记录当前状态判断是否已经确认。
如果订单已经释放,系统应保留支付成功事实,并进入异常分支。根据业务规则,可以执行退款、重新锁定同类库存或转人工处理,但不能为了让数据看起来平衡而直接把已释放数量再次扣掉。

下面使用一个示例商品进行推演:总库存 100 件,单笔订单最多购买 2 件,支付有效期 15 分钟。缓存只用于详情页展示和下单前的快速拦截,数据库保存库存汇总、锁定记录和库存流水。
这些数字是情景模拟,不代表某个企业的实际经营数据。它们的作用是把状态变化具体化,帮助团队在设计评审时检查每一步的数量是否能够解释。
| 时点 | 业务动作 | 可用库存 | 锁定库存 | 已售库存 | 缓存展示 |
|---|---|---|---|---|---|
| T0 | 商品开始销售 | 100 | 0 | 0 | 100 |
| T1 | 订单 A 锁定 2 件 | 98 | 2 | 0 | 可能仍为 100 |
| T2 | 缓存失效事件完成 | 98 | 2 | 0 | 98 |
| T3 | 订单 A 支付成功 | 98 | 0 | 2 | 可能仍为 98 |
| T4 | 订单 B 超时释放 1 件 | 99 | 0 | 2 | 等待失效或刷新 |
T1 到 T2 之间缓存仍显示 100,并不自动意味着库存系统错误。关键在于下单接口不能只读取缓存后就返回成功,而要以数据库条件锁定结果作为最终判断。缓存延迟期间,最多是多放进来一些无效请求,不能突破数据库的库存约束。
订单 A 提交 2 件购买请求。服务首先检查请求幂等号,避免客户端超时重试造成两次锁定。随后在事务中执行库存条件扣减,写入 2 件锁定记录和一条库存变更事件。
事务提交成功后,订单进入待支付状态。缓存消费者根据商品编号和库存版本执行失效或刷新。即使消费者暂时失败,数据库中的 98 件可用库存和 2 件锁定库存仍然能够支撑后续对账。
订单 A 支付成功时,系统不再做一次“库存减 2”。因为创建订单时已经完成锁定,支付阶段只需要把锁定记录从 LOCKED 转为 CONFIRMED,并把 locked_stock 转移到 sold_stock。
订单 B 锁定 1 件后未支付。定时任务扫描到期记录,先使用条件更新把锁定状态改为 RELEASED,再在同一事务中将可用库存加回 1、锁定库存减 1,并写入释放流水。
如果释放任务在加回库存后崩溃,必须依靠事务保证汇总更新和释放状态同时提交或同时回滚。如果使用异步释放,则需要通过释放操作号保证重试不会重复回加。
订单 C 的支付成功回调晚于超时释放。此时库存锁定已经释放,订单却收到支付成功通知。系统不能把订单 C 简单改成已支付并继续履约,因为那会形成没有对应库存占用的订单。
正确处理是保留支付成功流水,标记库存异常,触发退款或人工审核。对于不能接受自动退款的业务,可以进入“支付成功待库存处理”状态,由运营或履约人员根据库存情况决策。

上图中订单 B 的数字提醒了一个实际设计问题:任何案例表都必须完整记录所有订单的锁定和释放,否则单看局部流水会出现数量无法闭合。生产系统不能只记录成功扣减,还必须记录每一次锁定、确认、释放和异常终止。
如果商品库存规模较大、并发压力可控,而且下单必须同步确认,我建议优先采用数据库条件扣减加锁定流水。事务内完成库存汇总、锁定记录和可靠事件记录,提交后异步失效缓存。
这套方案的优点是链路短、故障边界清楚、开发和运维成本可控。缺点是热点商品写入集中时,数据库可能出现行锁竞争,需要从索引、事务长度、连接池和分片方式逐步优化。
当单个商品在短时间内承受大量请求,直接让所有请求竞争同一条数据库库存记录,容易形成锁等待和连接池耗尽。此时可以增加限流、排队、预扣或分片库存,但要接受系统复杂度上升。
如果业务允许“抢购资格先排队,最终订单稍后确认”,队列方案通常比在每个请求上加分布式锁更容易控制。如果业务必须在几毫秒内同步返回成功,就需要投入更多精力设计预扣、恢复、补偿和限流。
座位、房间、车辆档期等资源不能只用一个商品库存数字表示。每个资源都应有唯一编号和明确状态,数据库应通过唯一约束或条件更新确保同一个资源不能被两个订单同时锁定。
这类业务不适合单纯依赖缓存数量。缓存可以展示剩余座位,但座位分配必须回到资源明细表确认。支付超时释放时,也只能释放仍然属于该订单的座位,不能根据商品总量做批量回加。
如果库存按仓库、批次、销售区域或渠道拆分,缓存键不能只使用商品编号。否则一个区域的扣减可能覆盖另一个区域的视图,最终产生“总库存对了、可配送库存错了”的问题。
建议先定义库存维度,再决定缓存粒度。缓存中的值必须能解释它代表哪个仓库、哪个批次、哪个渠道和哪个时间点。对于涉及批次有效期的商品,还要把锁定与批次分配绑定,不能只锁总量。
如果库存错误会直接带来高额赔付、监管风险或不可逆损失,应该优先选择可审计方案,而不是只追求吞吐。库存流水、订单状态、支付流水、事件投递和人工修复记录都应保留完整关联。
这类系统可以接受更长的确认时间,但不能接受“最终不知道发生了什么”。在方案评审时,应该把人工介入路径作为正式流程设计,而不是假设所有异常都会自动恢复。

库存接口返回成功,只能说明请求当时通过了某个服务节点。它不能说明缓存已经更新、消息已经消费、订单状态已经同步,也不能说明库存流水和汇总数量已经一致。
我建议把监控分成写入结果、异步链路、状态异常和数据对账四组。每组指标都要有业务含义,不能只监控 CPU、内存和接口平均耗时。
| 监控组 | 关键指标 | 异常含义 | 建议动作 |
|---|---|---|---|
| 写入结果 | 库存条件更新失败率、负库存次数 | 库存不足或并发控制异常 | 检查商品热点、SQL 条件和索引 |
| 异步链路 | 事件积压、缓存失效失败、重试次数 | 数据库与视图之间延迟扩大 | 扩容消费者、修复失败事件 |
| 状态异常 | 锁定超时未释放、支付后仍锁定 | 订单与库存状态链断裂 | 暂停自动释放,进入异常处理 |
| 数据对账 | 总库存差异、流水汇总差异 | 出现不可解释的数量变化 | 按商品、订单和流水号定位 |
最简单的数量关系是:总库存等于可用库存加锁定库存加已售库存。但在退货、报损、调拨、冻结、质检和预售场景中,这个公式需要扩展,否则对账程序可能把合法的业务状态误报成差异。
建议先定义库存口径,再写对账规则。例如,仓内总库存可以拆成可用、锁定、已售待出库、冻结、质检和报损。每一种状态都要说明是否属于可销售总量、是否属于实物总量,以及状态变化由什么事件触发。
可销售库存
= 可用库存
+ 符合规则的可释放库存
风控冻结库存
已过期或不可售库存
实物库存
= 可用库存
+ 锁定库存
+ 已售待出库库存
+ 冻结库存
+ 质检库存
+ 其他业务定义的在库状态
对账不能只给出“差 3 件”。它还应该给出差异来源,例如某三个订单仍为待支付但锁定记录已过期,某一条释放事件投递失败,或者某一次重试使用了相同流水号却重复修改了汇总表。
自动修复最危险的写法,是发现数据库数量和流水不一致后,直接把汇总表改成流水计算结果。流水本身也可能不完整,外部仓储或人工盘点也可能存在更高优先级事实。
更安全的做法是先生成差异单,按照商品、仓库、批次、订单和事件号分组,判断差异类型,再对可确定的故障执行幂等修复。无法确定来源的差异,保留人工审核,并限制相关库存继续自动销售。

遇到库存异常时,不要先清缓存、重启服务或直接改库存。先保存订单号、商品编号、库存版本、库存流水、事件编号、缓存值、数据库值、请求时间和服务日志,确保修复前后仍能还原原始状态。
我通常按照“订单查库存、库存查流水、流水查事件、事件查缓存”的方向反向追踪。这样比从某一台缓存服务器的当前值开始猜测更可靠,因为当前缓存值可能已经被后续请求覆盖。
这是最容易理解的方案。数据库同步完成库存锁定,可靠事件通知缓存失效,读请求在缓存未命中时回源数据库。它的核心优势是事实链条短,适合大多数普通商品和中等并发场景。
这种方案能快速承接热点请求,减少数据库在高峰时直接承受的写竞争。它适合抢购资格、库存门票或允许排队确认的业务,但必须把预扣当成一个有生命周期的业务对象,而不是一个孤立的数字。
分布式锁适用于一个库存操作确实无法通过单条条件更新表达的情况,例如需要同时校验多个资源、跨多个库存分区或调用外部分配系统。但锁粒度必须足够小,锁内不能放置长时间网络调用。
很多团队遇到库存、订单和支付问题时,第一反应是引入更强的分布式事务。强一致事务可以解决一部分跨服务提交问题,但也会带来锁持有时间、协调器故障、吞吐下降和运维复杂度。
如果真正需要的是“数据库提交后缓存最终失效”,不一定需要把缓存纳入强一致事务。将缓存作为可重建视图,配合可靠事件、版本校验和对账,往往更符合缓存的技术属性。


发生库存异常时,第一反应往往是清空缓存、重启消费者或手工修改库存。这样做可能快速降低用户投诉,却会破坏现场,导致后续无法判断到底是旧值回写、重复消费还是错误脚本造成了差异。
正确做法是先限制高风险写入,保留数据库汇总、库存流水、订单状态、事件状态和缓存快照。对于已出现负库存或支付后库存不明的商品,可以暂时切换为数据库回源或暂停销售,优先保护履约和财务结果。
每次排查都应从具体订单或锁定记录开始,而不是从抽象的系统指标开始。通过业务流水号找到库存锁定,再找到对应的事件投递、消费日志和缓存操作,最后确认是否存在重复、丢失或乱序。
如果日志只有“更新成功”“删除成功”“消费成功”这类技术状态,而没有订单号、商品编号、库存版本和事件编号,排查就只能依赖时间猜测。日志字段的设计本身,就是库存一致性方案的一部分。
缓存旧值可以补偿缓存;事件未投递可以重试事件;库存流水缺失需要查事务边界;支付成功但库存已释放则可能需要退款。不同异常的修复动作完全不同,不能用“重新扣一次库存”解决所有问题。
| 异常类型 | 不可直接做的动作 | 优先核验 | 可能的修复 |
|---|---|---|---|
| 缓存仍为旧值 | 直接修改数据库数量 | 数据库版本与缓存版本 | 按版本失效或刷新缓存 |
| 事件未投递 | 绕过事件表手工删缓存 | 事务是否写入待投递事件 | 重试投递并记录结果 |
| 释放重复执行 | 再次扣减库存抵消 | 释放操作号和流水状态 | 停止重复任务,修复幂等逻辑 |
| 支付成功但已释放 | 直接把订单改成已售 | 支付流水、释放时间和库存占用 | 退款、重新分配或人工审核 |
不要等到促销活动才验证消息积压和缓存故障。测试环境至少应模拟数据库提交后进程退出、事件消费者处理一半宕机、缓存刷新乱序、支付回调重复和释放任务重复执行。
演练结果不能只记录“服务恢复”。还要验证库存数量是否守恒,订单最终状态是否合理,异常记录是否可追踪,补偿任务是否具备幂等性,以及运营人员是否知道何时可以恢复销售。
数据库、缓存、搜索索引、前端页面和下游仓储系统很难在每一毫秒都显示相同数字。真正可行的目标不是让所有副本永远同时变化,而是让关键写入有唯一事实源,让非关键视图有明确延迟边界。
页面库存可以允许短暂滞后,但下单锁定必须有原子结果;缓存可以异步刷新,但缓存失败必须可发现、可重试;支付与释放可以异步到达,但状态迁移必须只有一个合法终态。
如果系统没有锁定记录,先补状态模型;如果没有库存流水,先补审计链路;如果支付和释放没有条件迁移,先补并发裁决;如果事件失败无人发现,先补可靠投递和监控。
当这些基础能力具备之后,缓存同步反而会变成一个边界清晰的问题:它是展示视图的失效与重建,而不是整个库存系统的事实争夺。
我对这类问题的最终判断是:缓存不同步不是一个单点故障,而是库存状态设计是否完整的压力测试。只要库存有唯一事实来源,锁定、确认和释放能够形成闭环,事件能够追踪和补偿,异常能够对账和审计,缓存即使短暂落后,也不会轻易演变成超卖和无法履约。
技术负责人下一步不应继续收集更多“删除缓存技巧”,而应拿一笔真实订单做全链路复盘:从请求幂等开始,经过数据库锁定、事件投递、缓存变化、支付确认、超时释放,直到最终对账。能把这条链路完整跑通,才算真正解决了库存缓存不同步。
我遇到过一个很典型的故障:商品详情页显示还剩 7 件,但用户连续下单都提示库存不足。排查时数据库里的可用库存是 0,缓存却停留在 7。团队第一反应是补一次删缓存,但我更想知道,为什么这种问题会反复出现,缓存和数据库到底应该谁说了算?
先不要急着修缓存。库存不同步通常只是表象,真正的问题往往是库存状态没有被拆开:系统把可售、已锁定、已售出和已释放都压缩成了一个数字,缓存又承担了展示、下单判断甚至扣减职责,最后任何一个链路失败都会被归咎于缓存。我的判断是:数据库或专门的库存服务必须保存最终库存事实,缓存只负责加速读取。
这里的关键不是让数据库和缓存每一毫秒都相同,而是让最终扣减、锁定和释放都有唯一写入入口,并且每次变更都能追踪、重试和对账。
数据对象建议职责是否作为最终依据 数据库库存记录保存可用、锁定、已售和释放状态是 缓存库存详情页展示、热点查询、售罄预判否 库存流水记录订单号、数量、操作类型和时间用于审计与对账 消息事件通知缓存失效、释放库存和下游系统否 在一次并发测试中,我把初始库存设为 100,使用 500 个并发请求同时购买,每次购买 1 件。
如果采用先查询库存、再在应用层判断、最后更新的方式,压测结果出现过可用库存被扣成负数的情况。改成带条件的原子更新后,数据库受影响行数始终不超过 100,剩余请求统一返回库存不足。因此,技术负责人应该先回答三个问题:库存最终由谁确认、锁定是否独立于售出、缓存失败后如何补偿。
只有这三个问题明确,删缓存、消息通知或延迟双删才有实际意义。
我曾经测试过一种常见流程:先查数据库库存,再写入缓存,随后扣减数据库库存。低并发时看起来完全正常,但把请求量提高后,多个线程读到了同一个旧库存,缓存还把这个旧值重新写了回来。库存锁定究竟应该放在查询前、数据库更新时,还是订单创建后?
库存锁定不应该依赖应用层的先查后改,而应该在数据库中完成判断与扣减的原子动作。推荐把一次锁定视为一个明确的状态迁移:可用库存减少,锁定库存增加,同时写入一条带订单号和过期时间的锁定流水。
一个简化的数据变更可以是: UPDATE sku_stock SET available_stock = available_stock – 1, locked_stock = locked_stock + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ?
AND available_stock >= 1;执行后必须检查受影响行数。返回 1,说明锁定成功;返回 0,说明库存不足或商品状态不允许购买。不要只依赖应用层返回值,也不要在扣减失败时继续创建待支付订单。锁定记录至少需要包含订单号、商品编号、锁定数量、幂等键、锁定时间、过期时间和当前状态。
状态建议使用待支付、已支付、已释放、释放处理中和异常待对账等明确值,而不是只保存一个是否锁定的布尔字段。我更倾向于在数据库事务内完成库存变更和锁定记录写入,事务提交成功后再发送库存变更事件。缓存事件中携带版本号或更新时间,消费端只接受不早于当前版本的数据,这比单纯依赖删除缓存更能防止旧值回写。
需要特别注意,锁定成功不等于售出。支付成功后,锁定库存才转为已售;支付超时或订单取消后,锁定库存才回到可用库存。把这两个动作混成一次扣减,后面一定会在退款、取消和超时场景里出现少卖或重复释放。
我排查过一次缓存删除失败的问题,数据库库存已经从 20 变成 19,但缓存仍然是 20。更麻烦的是,补偿任务重试时,另一个读请求又把旧数据写回缓存。很多方案只说更新数据库后删除缓存,却没有解释删除失败、旧值回写和重复消费应该怎样组合处理。
数据库更新成功、缓存删除失败时,不要回滚库存,也不要把缓存当成第二个事务参与者。正确做法是保留数据库已经提交的事实,把缓存失效当成一个可重试的异步动作,并为这次库存变更留下可追踪的事件或流水。我建议把处理链路拆成四层:第一层是数据库事务,负责库存和库存流水;
第二层是可靠事件记录,至少要能知道哪一次变更需要同步;第三层是缓存失效或刷新消费者;第四层是定时对账,用于发现前面三层没有解决的差异。
故障点可能结果推荐处理 数据库提交成功,事件发送失败缓存长期保留旧值事务内记录待发送事件,后台重试 事件发送成功,消费者超时缓存未失效消费重试与死信告警 缓存删除成功,旧读请求回写旧库存重新进入缓存版本号校验或延迟再次失效 消费者重复执行缓存刷新次数增加按事件编号幂等处理 “先更新数据库,再删缓存”可以作为低复杂度场景的起点,但它不是完整的一致性方案。
尤其在旁路缓存模式下,旧读请求可能已经拿到数据库旧值,等缓存删除后才完成回写。此时延迟双删只能降低概率,不能证明强一致。更稳妥的做法是给缓存值绑定库存版本。例如数据库每次库存变更都递增版本号,缓存刷新时只接受更高版本;
如果当前缓存版本是 108,迟到的版本 107 事件即使重复消费,也不能覆盖现有数据。对账任务也不能只比较一个库存数字。至少要检查可用库存、锁定库存、已售库存之和是否符合总库存,并关联订单状态、库存流水和缓存版本。
我的经验是,缓存差异本身通常不是最高风险,无法定位差异来源、无法自动修复,才是事故扩大的原因。
我在测试支付回调和超时任务并发执行时,发现两条线程都读到订单状态为待支付:一条准备把库存转为已售,另一条准备释放库存。若只依赖先查状态、再更新状态,库存可能被释放一次后又确认售出,甚至出现重复加库存。我想知道这种竞争应该在订单层解决,还是在库存层解决?
这个问题不能只在订单层解决,也不能只靠库存表加一把锁。支付成功和超时释放本质上是同一条锁定记录的两个竞争性状态迁移,必须让其中只有一个动作能够成功改变状态,另一个动作识别到状态已变化后直接幂等返回。
例如释放时可以增加条件: UPDATE stock_lock SET status = 'RELEASED', released_at = CURRENT_TIMESTAMP WHERE lock_id = ?
AND status = 'PENDING_PAYMENT' AND expire_at 支付确认也应使用类似的条件更新,把待支付改为已支付。两个操作谁先成功,取决于数据库的原子条件更新;后到的操作如果受影响行数为 0,就必须重新读取当前状态,而不是再次执行加库存或扣库存。
先成功的动作后到的动作正确结果 支付确认超时释放释放失败,库存保持已售状态 超时释放支付确认支付进入异常处理,不得直接重复扣库存 释放任务重试释放任务重试只有第一次状态迁移成功,后续幂等返回 支付回调重复已支付状态不重复增加已售数量或写流水 这里有一个容易被忽略的业务决策:如果支付成功回调晚于库存释放,系统到底接受支付、退款,还是进入人工审核?
这不是技术实现细节,而是业务必须提前定义的终态规则。票务、稀缺资源和普通实物商品的处理方式通常不同,不能直接套用同一套逻辑。上线前我会重点验证四组指标:重复释放次数、支付与释放冲突次数、锁定超时未处理数量、订单库存状态不一致数量。
再配合订单号、库存流水号和消息事件号串联日志,才能判断一次差异究竟是重复回调、消息重试,还是状态迁移条件写错。最终要形成的不是一个“释放库存接口”,而是一套可审计的状态机:每个锁定只能进入一个合法终态,每次状态迁移都有唯一幂等键,每个异常结果都能被重试、告警和对账。


读者评论
文章把“缓存不同步”和“库存事实错误”区分得比较清楚,这一点对排查很有帮助。尤其是先确认数据库事实源,再检查库存流水、订单状态和事件投递,比单纯反复删除缓存更具操作性。
对库存状态拆分为可用、锁定、已售和已释放的建议比较实用。不过实际落地时,还需要结合订单超时扫描、支付回调幂等和异常人工处理,否则状态设计完整也可能出现长期锁定。
文中对延迟双删、分布式锁和消息队列的边界说明较客观,没有把某一种方案包装成万能解法。对于高并发场景,补充数据库条件更新、事件版本控制以及P95/P99监控,确实更接近生产排障。