仓储系统最容易返工的地方,往往不是商品表字段少了一个,而是团队在没有定义“库存事实来源”的情况下,先把库存写进数据库、再临时加一层缓存,最后发现页面库存、可用库存、锁定库存和实际库存各说各话。《数据库存:仓储系统团队从零入门:表结构设计先掌握缓存同步》真正要解决的,不是简单列出几张表,而是把库存从产生、变更、查询到恢复的全过程设计成一条可追溯的数据链。
我参与过仓储、订单和供应链系统的建模评审,见过一种很典型的故障:数据库里的库存是 100,缓存里是 96,订单服务认为还能扣减 8 件,仓库作业系统却已经把其中 6 件标记为待拣货。最终没有任何一个服务“完全错误”,但组合起来就出现了超卖、重复锁定和人工对账。
这类问题说明,缓存同步不是数据库设计完成之后的附加功能,而是表结构设计时就必须明确的约束条件。如果团队没有先定义库存维度、数量口径、变更流水和权威数据源,后面无论使用关系型数据库、缓存、消息队列还是分布式锁,都会不断修补同一个根本问题。
很多入门教程会从商品表开始,接着创建仓库表、订单表和库存表。这种顺序对理解数据库基础有帮助,但对仓储系统并不够。仓储系统最重要的对象不是“某件商品叫什么”,而是某个 SKU 在某个仓库、库位、批次和货主维度下,当前有多少库存,以及这个数字为什么发生了变化。
因此,我更建议团队先画一条库存变化链:
这条链比字段清单更重要。因为它会直接决定是否需要库存流水表、业务单据表、状态机、版本号、幂等键以及缓存失效事件。
在大多数从零开始的仓储团队中,我建议把数据库定位为可追溯的事实账本,把缓存定位为高频访问的加速层。缓存可以帮助系统快速回答“当前大概还有多少库存”,但不能替代库存流水、出入库凭证和盘点记录。
这里的“大多数”很重要。对于极高并发的抢购、票务或限量资源场景,缓存可能会参与实时扣减;但这意味着团队必须额外建设回写、补偿、对账和恢复机制。对于普通仓储系统,直接把库存控制逻辑全部放进缓存,通常不是技术升级,而是把复杂度提前搬到了团队还没有准备好的地方。
“缓存和数据库保持一致”这句话在技术评审中往往不够具体。团队至少要回答三个问题:允许多长时间的不一致;哪些操作必须强校验;发生失败后谁负责修复。
| 数据场景 | 允许的延迟 | 建议事实来源 | 风险处理 |
|---|---|---|---|
| 商品详情页展示库存 | 秒级到分钟级 | 数据库,缓存加速 | 过期、删除缓存、读取回源 |
| 订单提交前库存校验 | 通常不接受长时间滞后 | 数据库或具备原子能力的库存服务 | 条件更新、版本控制、幂等 |
| 仓库出库扣减 | 必须可追溯 | 库存余额与库存流水 | 事务、重试、对账 |
| 报表和经营分析 | 分钟级或小时级 | 业务数据库或分析数据集 | 批量同步、口径校验 |
真正的设计原则是:不同数据可以拥有不同的实时性,但不能拥有不同的事实定义。如果一个服务把“锁定库存”算进可用库存,另一个服务又把它排除在外,那么即使同步延迟为零,系统仍然是不一致的。

SPU 更接近商品族,例如“某型号 500 毫升保温杯”;SKU 则是可以独立采购、销售、拣货和计数的具体规格,例如“黑色、500 毫升、单只装”。在仓储系统中,库存通常绑定 SKU,而不是绑定商品名称。
如果团队把库存直接挂在商品表上,初期看起来很简单,但一旦出现颜色、尺寸、包装、组合装或不同条码,库存就会被迫拆分。更麻烦的是,历史库存已经按商品汇总保存,后续很难准确还原每个 SKU 的变更过程。
仓库回答“货在哪里管理”,库区回答“货属于哪一类作业区域”,库位回答“货具体放在哪里”。例如,一个仓库可以包含收货区、存储区、拣选区和不良品区;存储区再包含具体货架和库位。
并不是所有系统都需要把层级建到库位。若企业只关心仓库级库存,不涉及拣货路径、上架策略和盘点定位,过早引入复杂库位模型会增加维护成本。但一旦仓库作业需要指导叉车或拣货员,库位就不再是展示字段,而是库存唯一维度的一部分。
我在评审库存模型时,会要求团队先写出自己的库存公式,而不是直接复制其他项目的字段。一个常见的基础模型是:
可用库存 = 实际库存 − 锁定库存 − 冻结库存
但这只是一个起点。有些企业还要扣除安全库存、待质检库存或已分配未拣货库存;有些企业把冻结库存计入实际库存,有些企业则将其单独归入不可用库存。公式本身没有统一答案,关键在于同一套口径必须被订单、仓库、报表和接口共同使用。
| 库存字段 | 回答的问题 | 是否建议直接存储 | 常见风险 |
|---|---|---|---|
| 实际库存 | 账面上总共有多少件 | 建议存储 | 只改余额不写流水,无法追溯 |
| 锁定库存 | 已经被订单或任务占用多少件 | 通常建议存储 | 订单取消后未释放,造成库存“假性不足” |
| 冻结库存 | 有多少件暂时不能销售或作业 | 视业务而定 | 把质检、报损和普通锁定混为一谈 |
| 可用库存 | 当前还能被业务承诺多少件 | 可计算,也可冗余存储 | 计算公式在不同服务中不一致 |
| 在途库存 | 已发出但尚未进入目标仓库多少件 | 建议单独建模 | 把在途库存误当成可售库存 |
如果企业经营食品、药品、化妆品或需要先进先出的物料,仅用“SKU + 仓库”作为库存唯一维度是不够的。系统还可能需要纳入批次号、生产日期、失效日期、货主和质量状态。
我建议团队不要为了追求所谓通用,把所有可能字段都塞进唯一键。应先判断一个维度是否会影响库存扣减、拣货、盘点或责任归属。只有会影响这些业务动作的维度,才应该进入库存余额的核心维度。

商品表和 SKU 表应描述货品身份、规格和基础属性,不应把出入库过程混在里面。一个 SKU 表可以包括 SKU 编码、条码、名称、规格、计量单位、启用状态、是否批次管理和是否效期管理等字段。
需要特别注意的是,SKU 编码和条码的唯一性不一定相同。一个 SKU 可能对应多个包装条码,一个条码也可能在不同货主或不同组织中有不同含义。团队应先确定编码的组织范围,再决定唯一约束放在数据库层还是应用层。
仓库和库位不是永远可用的静态字典。库位可能因为维修、盘点、满载、禁用或安全原因暂时不能使用,因此建议至少保留状态字段、启用时间和停用原因。
如果系统支持多组织或多货主,仓库编码、库位编码和 SKU 编码的唯一范围也要明确。一个简单但有效的做法,是在表中显式增加组织或租户标识,并把它纳入唯一索引,避免不同业务主体之间发生编码碰撞。
库存余额表通常用于快速回答当前库存数量。它的设计重点不是字段越多越好,而是必须确定一行记录究竟代表什么。一个典型的库存唯一维度可能是:
货主 + SKU + 仓库 + 库位 + 批次 + 质量状态
如果业务不启用批次,可以不把批次作为有效维度;如果库存只管理到仓库级,则可以不纳入库位。但这个决定必须写进建模文档,因为它会同时影响数据库唯一索引、缓存 Key、库存流水和对账逻辑。
CREATE TABLE inventory_balance (
id BIGINT PRIMARY KEY,
owner_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
location_id BIGINT NULL,
batch_no VARCHAR(64) NULL,
actual_qty DECIMAL(18, 3) NOT NULL DEFAULT 0,
locked_qty DECIMAL(18, 3) NOT NULL DEFAULT 0,
frozen_qty DECIMAL(18, 3) NOT NULL DEFAULT 0,
version_no BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_inventory_dimension
(owner_id, sku_id, warehouse_id, location_id, batch_no)
);
上面的结构只是示例,不应直接复制到生产环境。是否使用小数数量、是否允许库位为空、是否需要质量状态,都取决于具体仓储业务。示例中最值得保留的不是字段名称,而是库存维度和版本号要能够被数据库约束。
余额表适合查询,流水表适合审计和恢复。库存流水至少应记录变更类型、业务单号、变更前数量、变更数量、变更后数量、仓库、库位、批次、操作人和发生时间。
如果只记录“本次增加 10 件”,不记录变更前和变更后数量,后续排查并发问题会非常困难。因为你无法判断这条流水是在什么基础上产生,也无法快速发现同一库存维度是否出现了数量跳变。
CREATE TABLE inventory_transaction (
id BIGINT PRIMARY KEY,
owner_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
location_id BIGINT NULL,
batch_no VARCHAR(64) NULL,
biz_type VARCHAR(32) NOT NULL,
biz_no VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
before_actual_qty DECIMAL(18, 3) NOT NULL,
delta_actual_qty DECIMAL(18, 3) NOT NULL,
after_actual_qty DECIMAL(18, 3) NOT NULL,
operator_id BIGINT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_inventory_idempotency (idempotency_key)
);流水表不一定要替代所有业务单据。采购入库单、销售出库单和盘点单描述业务过程,库存流水描述数量变化,两者应通过业务单号或业务明细行建立关联。
最小可靠模型通常是:先校验幂等键,再在事务中锁定或条件更新余额,写入流水,最后提交事务。缓存删除、消息发送和异步通知可以根据架构选择放在事务之后,但不能让余额已经改变而流水没有记录。
如果数据库支持事务消息或可靠事件表,可以在本地事务中同时写入库存变化和待发送事件。这样即使消息中间件暂时不可用,后续也能通过任务重试把缓存刷新事件发送出去。

数据库库存记录如果按“货主、SKU、仓库、库位、批次”区分,而缓存只用 SKU 作为 Key,那么缓存就无法准确表示数据库中的每一条库存记录。它可能保存总库存,也可能保存某个仓库的库存,但系统必须明确这个 Key 的统计范围。
例如,下面两种 Key 代表完全不同的业务含义:
inventory:sku:10086
inventory:owner:7:warehouse:3:sku:10086:location:201:batch:B202609
第一个 Key 适合商品详情页展示汇总库存,但不适合仓库作业扣减。第二个 Key 更接近仓库库存余额维度,可以用于指定仓库和库位的查询,但它的数量更新规则、过期策略和重建方式也更复杂。
缓存 Key 不是命名问题,而是数据模型的外显。一旦 Key 的维度少于数据库的事实维度,缓存同步就不可能做到精确,只能依靠汇总、回源或定期校准弥补。
缓存不是因为“数据重要”才使用,而是因为“读取频繁、计算成本高、短暂延迟可接受”。商品详情页的库存展示、仓库库存汇总、热点 SKU 的可用数量,通常比库存流水更适合进入缓存。
库存流水的访问频率可能没有那么高,但它具有审计和恢复价值。把流水只写入缓存会导致服务重启、缓存淘汰或节点故障后无法恢复历史变化,因此不建议这样设计。
| 数据对象 | 典型访问方式 | 缓存建议 | 必须保留的数据库能力 |
|---|---|---|---|
| 库存余额 | 按 SKU、仓库高频读取 | 可缓存,需定义回源 | 唯一维度、事务更新、版本校验 |
| 库存流水 | 按单据、时间范围查询 | 一般不缓存为事实数据 | 持久化、审计、幂等约束 |
| 商品基础信息 | 详情页和搜索反复读取 | 适合缓存 | 状态变更和版本管理 |
| 临时锁定状态 | 短时间内高频读写 | 可采用带过期时间的缓存 | 最终落账、超时释放和对账 |
团队在评估缓存方案时,常常只看命中率、响应时间和吞吐量。我会额外追问:如果凌晨把缓存集群清空,系统能否在半小时内恢复?如果某个库存 Key 因数据损坏被删除,谁来重新生成?如果缓存中有一条旧数据,系统能否识别它的版本?
一个健康的库存缓存应当具备“可删除、可回源、可重建”的特征。数据库库存余额和库存流水是重建基础,缓存只是它们的派生视图。若缓存无法从数据库重新生成,说明缓存实际上已经承担了不可替代的事实职责,团队就应该承认这是另一种架构,而不是把它称作普通缓存。

“数据库更新成功后,再把新数量写入缓存”听起来很直观,但在并发环境下可能出现旧请求覆盖新请求。假设请求 A 先更新数据库为 90,请求 B 随后更新数据库为 80;如果 A 的缓存写入因为网络延迟晚于 B,缓存就可能最终停留在 90。
这不是简单的“执行顺序错误”,而是写入事件缺少版本约束。缓存更新至少应携带版本号、更新时间或单调递增的变更序列,消费者发现收到旧版本时不能覆盖新版本。
延迟双删通常指先删除缓存,再更新数据库,提交后等待一段时间再次删除缓存。它可以降低并发读请求把旧值重新写入缓存的概率,但不能消除删除失败、消息乱序、长事务和多数据源写入带来的问题。
如果数据库事务执行时间有时是 20 毫秒、有时是 800 毫秒,固定延迟 500 毫秒就没有稳定的理论保障。延迟参数只能通过实际链路观察和压测调整,不能被当成一致性证明。
如果缓存承担库存预扣减,业务成功至少需要包含库存校验、业务单据状态、持久化流水和失败补偿。缓存中的数字减少了,只能说明某个缓存操作成功,不代表出库单已经生效,更不代表仓库作业已经完成。
尤其在网络超时场景中,客户端可能不知道请求是否成功而再次重试。没有幂等键时,第二次请求会再次扣减缓存库存,之后即使数据库回写失败,也很难判断应该恢复多少数量。
过期时间只能限制旧数据的最长存活周期,不能保证过期前没有错误。对于库存这种有明确业务风险的数据,单纯依靠过期时间可能让错误持续几十秒甚至几分钟。
更稳妥的做法是把过期作为兜底机制,同时搭配主动删除、事件刷新、读取回源、版本校验和定时对账。过期时间解决的是“最终会重新读取”,而不是“当前读取一定正确”。
分布式锁可以减少同一资源的并发写入,但它不能替代库存流水,也不能自动解决锁丢失、锁超时、服务宕机和重复消费。很多团队一看到超卖问题就加锁,结果锁的粒度过大,吞吐下降;锁的粒度过小,又没有覆盖真正的库存维度。
在多数关系型数据库场景下,先尝试“带条件的原子更新”往往比全局分布式锁更简单。例如只允许可用库存大于等于扣减数量时更新,并检查受影响行数。只有当数据库承载能力、热点冲突和业务吞吐确实达到瓶颈,才考虑更复杂的锁或缓存扣减方案。

如果一个中小仓储系统每秒只有几十次库存变更,数据库条件更新加库存流水通常足够。此时优先保证模型清晰、事务可靠和问题可追溯,比引入缓存原子扣减更重要。
当系统出现明显的热点 SKU、大量并发预占或多个业务入口同时写入同一库存维度时,才需要评估数据库行锁竞争、事务耗时、缓存原子操作和消息串行化。技术选型应由写入压力和冲突模式驱动,而不是由“别人都用了某组件”驱动。
普通商品详情页显示少几件库存,可能只影响用户体验;但药品、危险品、贵重物料或跨仓调拨中的库存错误,可能直接造成合规、财务和履约风险。
因此,同一套系统也可能对不同库存类型采用不同策略。低风险展示库存允许缓存短暂滞后,高风险扣减库存则必须回到可校验的事实源。不要因为某个字段都叫“库存”,就要求它们拥有完全相同的实时性。
缓存参与扣减的方案需要持续观察缓存命中率、扣减失败、回写延迟、消息积压、重复消费、库存负数和数据库对账差异。没有监控和演练,这种架构在正常流量下可能表现很好,一旦出现故障就很难恢复。
我通常会把“能否恢复”作为比“峰值吞吐量”更早的评审问题。团队如果还没有定时对账、库存重建、死信处理和人工校正机制,就不建议直接采用缓存主导的库存账本。
| 判断维度 | 数据库主导 | 缓存参与扣减 | 消息驱动同步 |
|---|---|---|---|
| 适合的写入压力 | 低到中等 | 高并发、热点明显 | 写入峰值需要削峰 |
| 实现难度 | 低 | 高 | 中到高 |
| 数据恢复 | 相对直接 | 必须建设回写和对账 | 依赖事件完整性 |
| 主要风险 | 数据库热点和锁竞争 | 缓存与账本分叉 | 重复、乱序、积压和丢失 |
| 从零团队建议 | 优先考虑 | 压测证明必要后再用 | 先从可靠事件表开始演进 |
对于新团队,我更推荐以下演进路径:
这条路径的优点是每一步都能单独验证。团队不会因为一次性引入太多组件,而失去判断问题来源的能力。

下面用一个抽象的家居用品仓储场景说明。某 SKU 在华东一号仓实际库存为 100 件,其中 12 件已经被未完成订单锁定,8 件处于质检冻结状态,因此当前可用库存为 80 件。
这里的 80 件不能直接写死在商品表中。它至少需要能够由库存余额表解释,并且能够通过库存流水追溯 12 件锁定和 8 件冻结分别由什么业务动作产生。
| 库存状态 | 数量 | 来源 | 是否允许新订单使用 |
|---|---|---|---|
| 实际库存 | 100 件 | 已入库并登记的账面数量 | 不是直接判断标准 |
| 锁定库存 | 12 件 | 订单预占或出库任务 | 不允许再次分配 |
| 冻结库存 | 8 件 | 质检、破损或异常处理 | 不允许销售或拣货 |
| 可用库存 | 80 件 | 100 − 12 − 8 | 可以进入承诺校验 |
订单提交时,系统不能只读取缓存中的 80 件然后返回成功。正确的处理至少包括业务请求幂等校验、库存维度确认、可用库存判断、锁定数量更新和锁定流水记录。
如果选择数据库主导,可以使用条件更新:
UPDATE inventory_balance SET locked_qty = locked_qty + :lock_qty, version_no = version_no + 1, updated_at = CURRENT_TIMESTAMP WHERE owner_id = :owner_id AND sku_id = :sku_id AND warehouse_id = :warehouse_id AND actual_qty - locked_qty - frozen_qty >= :lock_qty;
执行后必须检查受影响行数。受影响行数为 1,说明锁定成功;为 0,说明库存不足、维度不存在或条件不满足。不能只看 SQL 是否执行成功,因为“SQL 执行成功但没有更新任何行”仍然代表业务失败。
订单锁定和实际出库是两个不同动作。锁定阶段通常改变锁定数量,出库阶段才减少实际库存并释放相应锁定数量。如果团队在出库时再次按完整数量扣减,而没有释放锁定库存,就会出现双重扣减。
出库成功后的数量变化可以表示为:
这个结果看起来可用库存没有变化,但它是合理的:8 件原本已经被订单锁定,出库完成后只是从“锁定”转成了“已出库”,没有再次占用新的可用库存。
如果数据库是事实来源,推荐在库存余额和流水事务提交成功后删除对应缓存,或者写入一个可靠的库存变更事件。读请求发现缓存不存在时,再从数据库加载新值。
如果采用消息异步刷新,事件中不能只传一个 SKU。至少要携带库存维度、业务单号、变更序号和事件时间。否则消费者无法判断它应该刷新哪个仓库、哪个库位,也无法防止旧事件覆盖新事件。
{
"eventType": "InventoryChanged",
"ownerId": 7,
"skuId": 10086,
"warehouseId": 3,
"locationId": 201,
"batchNo": "B202609",
"bizNo": "OUT202609160001",
"sequence": 18425,
"changedAt": "2026-09-16T10:30:12Z"
}
缓存刷新失败时,不应回滚已经成功的出库事务。因为出库事实已经发生,正确做法是将缓存刷新视为待补偿任务,并通过重试、回源和对账恢复派生数据。
很多团队会把“缓存刷新失败”当成技术异常,却没有定义库存业务的最终状态。实际上,库存扣减成功、流水写入成功、缓存刷新成功是三个不同的结果。接口响应可以按照业务需要返回,但监控必须分别记录这三个状态。
如果库存扣减成功而缓存刷新失败,订单不能被标记为失败,否则重试可能导致重复出库。系统应返回业务成功,同时将缓存同步标记为待处理,并在后台完成修复。

这是数据库主导方案最常见的异常之一。库存余额和流水已经提交,但缓存操作因为网络抖动、连接池耗尽或缓存节点故障而失败。
处理方式可以按复杂度逐步增加:
这里不建议用无限重试。无限重试可能在缓存故障期间制造更多线程、消息和日志压力,最终拖垮业务服务。重试需要有最大次数、退避时间和死信处理。
这类异常比缓存删除失败更危险,因为缓存已经产生了业务影响。系统必须能够回答:这次扣减是否已经被业务接受;如果没有,缓存数量如何恢复;如果恢复失败,下一次请求如何避免继续使用错误数量。
一种可行做法是为缓存扣减设置业务流水号,并把扣减结果写入可靠队列或本地事件表。数据库回写成功后标记事件完成,失败则重试。对于超过重试上限的事件,应进入人工或自动对账流程,而不是静默丢弃。
消息系统通常至少一次投递,因此消费者必须接受重复消息。幂等键可以防止同一业务动作重复落账,但它不能自动解决事件乱序。例如先产生的“库存变为 90”可能晚于“库存变为 80”到达消费者。
解决乱序需要在事件中携带单调递增的版本号或变更序列。消费者只接受大于当前缓存版本的事件;如果收到更小版本,则丢弃或记录,不允许覆盖新值。
库存负数是一个结果,不一定是根因。直接把负数修正为零,可能掩盖了重复扣减、错误回滚、库存维度不一致或流水缺失。
正确的排查顺序通常是:
只要系统存在数据库、缓存、消息和异步任务,就应该设计对账。对账不是简单地比较两个总数,而是要按照同一库存维度、同一时间点和同一口径进行比较。
对账任务可以先从低频开始,例如每小时扫描最近 24 小时有变更的库存记录。对于差异较大的 SKU,再进入实时告警或人工复核。对账结果应保留差异数量、发现时间、修复动作和修复人,避免同一个问题反复发生却没有历史记录。

第一阶段不需要一开始就建设复杂的缓存集群和消息拓扑。建议先让以下对象能够独立跑通:
阶段目标不是页面看起来完整,而是任何一次库存变化都能回答“谁在什么时候,因为哪张单据,改变了哪个库存维度,变化前后分别是多少”。如果这个问题回答不了,继续优化缓存只会让排查更困难。
数据库约束是低成本的防线。库存余额表要有明确的唯一索引,幂等记录要有唯一键,数量字段要有非负约束或应用层校验,状态字段要限制合法流转。
如果数据库版本或业务原因不适合直接使用复杂约束,也要在应用层实现同等规则,并通过自动化测试验证。不要把所有正确性都寄托在开发人员“记得先检查”的习惯上。
缓存应该从读多写少、错误容忍度较高的场景开始。例如商品详情页库存展示、仓库库存汇总和热门 SKU 查询。此时建议采用数据库更新成功后删除缓存,读请求在缓存未命中时回源数据库并重新写入。
为了避免缓存击穿,团队可以增加短暂的互斥加载、随机过期时间和空值保护。但这些优化都应建立在事实源清晰的前提下,不能用缓存技巧掩盖库存模型问题。
当缓存删除、异步刷新和多服务同步逐渐增加后,单纯依靠业务代码中的一次调用就不够了。可以在数据库事务中记录库存变更事件,再由后台任务投递和重试。
事件表至少要记录事件类型、业务单号、库存维度、版本号、状态、重试次数、下次重试时间和最后错误信息。这样运维人员才能知道事件是否发送、是否消费以及卡在哪个节点。
如果压测显示数据库条件更新已经成为明确瓶颈,并且业务确实需要更高并发,再评估缓存原子扣减、库存分段、消息串行化或独立库存服务。
这一阶段要同时建设:

这类团队优先采用关系型数据库作为事实来源,库存余额和库存流水使用事务维护,缓存只服务于查询加速。数据库条件更新足以解决大部分并发扣减问题,不必为了架构先进而提前引入缓存主导库存。
取舍是峰值吞吐量不会无限提升,但系统更容易部署、排查和交接。对于刚组建的团队,这种可维护性往往比理论上的极限性能更有价值。
这类系统应优先把库存唯一维度设计完整,至少确认货主、SKU、仓库、库位和批次是否参与库存核算。缓存 Key 必须与作业查询维度匹配,不能只按 SKU 汇总。
取舍是表结构和查询条件会更复杂,但换来的是库存责任边界清晰。若为了简化查询而提前汇总,后续盘点、调拨和货主结算会承担更高的数据还原成本。
可以先在数据库层面做索引优化、减少事务范围、拆分热点库存记录,并通过压测确认瓶颈。如果仍然存在严重行锁竞争,再考虑缓存原子预占。
取舍是缓存方案能提高峰值吞吐,但会引入异步落账和补偿成本。此时不要只比较每秒处理次数,还要比较一次故障演练需要多少人时才能恢复。
药品、食品、贵重物料和高价值工业部件,应把批次、效期、质量状态、操作人和业务凭证放在核心模型中。缓存可以辅助查询,但不能成为唯一库存依据。
取舍是实时性能可能不如纯缓存扣减,但出现争议时能够还原责任链。对于高审计要求的业务,少量性能损失通常比账目无法解释更容易接受。
不要直接增加一个缓存层来“统一”旧系统。应先盘点各系统的库存定义、更新时间、写入权限和差异处理方式,确定谁负责实际库存、谁负责订单锁定、谁负责仓库作业。
可以建立统一库存服务或库存快照,但必须保留来源系统、同步版本和原始业务单号。否则,新系统只是把多个不一致的数据源再次汇总,问题不会因为接口变少而消失。
| 业务条件 | 优先方案 | 暂时不要做的事 | 主要取舍 |
|---|---|---|---|
| 低并发、单仓库 | 数据库余额加流水,缓存读加速 | 直接缓存主导扣减 | 性能上限较低,但恢复简单 |
| 多仓库、多库位 | 先明确库存唯一维度和索引 | 只按 SKU 做全局库存 | 模型更复杂,但责任边界清楚 |
| 热点 SKU、高并发 | 先压测,再引入原子预占 | 没有对账就做异步落账 | 吞吐提高,但运维和恢复成本增加 |
| 强审计行业 | 数据库流水和批次效期优先 | 只保存缓存数量 | 实时性让位于可追溯性 |
| 多套旧系统并存 | 先梳理权威源和同步责任 | 直接再加一层汇总缓存 | 治理周期更长,但避免重复制造差异 |

准备一组会改变库存维度的测试数据,包括同一 SKU 在不同仓库、不同库位、不同批次和不同货主下的库存记录。检查唯一索引是否允许合法记录同时存在,是否会错误合并本应独立的库存。
还要检查移库业务。移库通常是一个库位减少、另一个库位增加,若两边没有在同一业务事务或可恢复流程中完成,就可能出现总库存短暂减少或重复增加。
至少模拟两个请求同时扣减同一库存维度,测试库存不足、数量刚好相等和重复请求三种情况。不要只观察接口返回码,还要检查余额、流水、订单状态和缓存版本是否一致。
一个合格的并发测试应能证明:库存不会低于业务允许的下限;同一幂等键不会产生两条有效扣减;失败请求不会留下无法解释的流水;缓存不会被旧版本覆盖。
可以在测试环境中模拟缓存不可用、消息延迟、消息重复、数据库短暂连接失败和消费者重启。观察系统是否会自动重试,重试是否有上限,超过上限后是否进入可见的异常队列。
如果一次缓存故障只能通过开发人员临时执行 SQL 修复,说明系统的补偿设计还不完整。人工修复可以作为最后手段,但不能成为主要恢复流程。
随机删除部分缓存 Key,再发起查询,确认系统能否从正确的数据库维度加载并重建。接着制造一条缓存旧版本,验证新版本事件到达后能否覆盖旧值,旧事件晚到时能否被拒绝。
建议记录以下观察指标:

最后还要检查三个经常被忽略的动作:重复请求是否幂等,库存调整是否需要审批,异常库存是否有明确的人工校正权限。仓储系统不是单纯的 CRUD 系统,库存数量一旦进入订单、财务或仓库作业流程,就必须同时具备技术约束和业务责任链。
我建议团队把这份清单变成评审模板,放进每次库存相关需求的设计文档中。需求新增一个库存字段、一个缓存 Key 或一种单据状态,都必须说明它会改变哪个事实、影响哪条流水以及如何补偿失败。
仓储系统表结构设计最容易陷入两个极端:一种是只关注字段完整,画出很多表却没有库存变更闭环;另一种是只关注吞吐量,先把库存放进缓存,等出现差异后再补数据库。
我的判断是,从零团队应先建立“余额可查询、流水可追溯、请求可幂等、缓存可重建、异常可补偿”的最小可靠系统。只有当真实压测证明数据库成为瓶颈,再把缓存推进到预占或扣减链路。
下一步可以按这个顺序行动:先选一个 SKU、一个仓库和一条完整出库流程,画出库存维度;再设计余额表和流水表;接着写两个并发扣减测试;最后增加缓存删除失败和缓存重建测试。不要一开始就讨论要不要使用某种复杂组件,先确认任何一个库存数字都能被清楚解释。
当团队能够回答“这个数字从哪里来、为什么变化、缓存为什么这样存、失败后如何恢复”时,表结构才真正开始服务于仓储业务,而不是停留在数据库设计图上。


读者评论
文章把库存事实、缓存加速和库存流水的职责区分得比较清楚,尤其是“可用库存不等于实际库存”的说明,对刚做仓储系统的团队很有参考价值。
库存唯一维度的讨论比较实用。批次、库位、货主和质量状态是否纳入唯一键,确实不能照搬模板,应该结合拣货、盘点和责任归属来决定。
内容覆盖了表结构、幂等、版本号和缓存同步,但实际落地时还需要补充并发扣减、消息失败重试及对账任务的代码示例,入门读者会更容易操作。