库存流水表从几十万行增长到数亿行之后,最先暴露的通常不是“数据库容量不够”,而是系统已经无法解释自己的库存:同一张库存表既负责实时扣减,又承担历史追溯;同一个业务请求重试两次,流水却多出两条;查询某个仓库的当前库存只需要几十毫秒,查询一次对账明细却要扫描数千万行。我的判断是,库存系统难扩展,往往不是某一条 SQL 写得不好,而是余额、流水、并发、幂等和数据生命周期从一开始就没有被分层设计。
数据库存:数据库管理员诊断清单:从库存流水排查设计难扩展
这篇文章不讨论“库存表应该有哪些字段”这种静态问题,而是站在数据库管理员的视角,反过来观察生产环境中的库存流水。通过流水增长速度、重复事件、对账差异、锁等待、查询路径和归档成本,可以判断一个库存系统究竟是局部性能问题,还是整体设计已经难以支撑业务增长。
很多库存系统出现慢查询后,团队的第一反应是加索引、扩容服务器,或者直接提出分库分表。但如果库存余额无法由流水解释,任何架构升级都只是把问题搬到更复杂的系统里。
一个可治理的库存系统,至少要能够回答四个问题:当前余额是多少;余额由哪些事件产生;某次变化对应哪个业务单据;出现异常后能否通过冲正或补偿恢复,而不是直接修改结果字段。
我通常会先选一个具体的商品、仓库和时间范围,做一次“余额反推”。如果期初库存加上入库、调拨、退货、盘点调整,再减去出库和锁定释放后的结果,无法得到当前余额,那么此时不应该优先讨论索引,而应该先定位数据链路和事务边界。
余额表的任务是快速回答“现在还有多少”;流水表的任务是回答“为什么变成这样”。前者强调低延迟读写,后者强调完整记录、顺序、来源和审计。把两者混成一张表,短期看似减少了表数量,长期却会让实时交易和历史追溯互相争抢资源。
库存余额表一般按商品、仓库、库位、批次或库存状态组织,适合定位一条或少量记录。库存流水表则会持续增长,通常包含业务单号、事件类型、数量变化、操作时间、请求编号和来源系统。它天然更接近不可变事实表,而不是一个可以随意覆盖的当前状态表。
| 数据对象 | 主要职责 | 典型访问方式 | 扩展性风险 |
|---|---|---|---|
| 库存余额表 | 提供实时库存状态、执行扣减和锁定 | 按商品、仓库、批次精确查询 | 热点行竞争、锁等待、并发超卖 |
| 库存流水表 | 记录库存变化事实、审计和对账 | 按单据、时间、商品或仓库追溯 | 持续膨胀、索引维护、历史查询拖慢交易 |
| 业务单据表 | 表达订单、出库单、调拨单等业务意图 | 按单据号查询状态和业务上下文 | 状态变化与库存事件脱节 |
| 对账结果表 | 保存核对结果、异常原因和处理状态 | 按日期、仓库、异常级别筛选 | 只发现差异却无法追踪责任链 |
这张职责划分表的价值不在于规定唯一模型,而在于帮助 DBA 判断:一条查询到底是在读当前状态、读取事实流水,还是在读取业务上下文。如果所有查询都落到同一张大表上,扩展性问题通常已经开始累积。

单表能存多少行没有一个适用于所有环境的固定答案。数据库版本、硬件配置、索引数量、字段宽度、事务大小、备份窗口、查询条件和数据分布都会改变结果。有人把“超过一千万行”当作必须拆表的标准,这种说法在实际排查中并不可靠。
我更关注四个增长曲线:流水行数增长曲线、核心查询 P95/P99 延迟曲线、索引和备份维护耗时曲线、异常对账处理耗时曲线。只要其中两到三条曲线开始同步恶化,就说明系统的结构性风险正在显现。
在简单的电商场景里,一笔订单可能经历预占、支付确认、出库、取消、释放和售后退回。仓储场景还会增加收货、上架、移库、盘点、报损、调拨和批次拆分。每个动作都可能产生一次或多次库存变化。
如果系统把这些动作都写成“数量加减”,却没有区分事件类型和业务阶段,后续对账只能看到数字,无法知道数字为什么变化。更严重的是,取消订单和库存释放可能被当作普通入库,导致业务语义丢失。
库存数据的维度通常不是只有 SKU。一个商品可能同时按仓库、库位、批次、效期、货主、渠道、库存状态和质量状态拆分。业务规模增长后,一条“商品库存”可能实际对应几十条甚至几百条库存明细。
这会带来两个容易被忽略的问题。第一,库存余额表的唯一键变长,索引占用和定位成本上升。第二,库存流水的查询条件变得不稳定,业务方今天按仓库查询,明天按批次查询,后天又要求按渠道和订单状态关联。
促销、节假日、直播活动和大批量调拨会制造短时间写入峰值。平时每天新增二十万条流水,并不意味着大促当天只需要支持二十万条。更重要的是,峰值期间往往伴随着重复请求、超时重试和补偿任务,实际写入量可能是正常日的数倍。
因此容量评估不能只看月均值。至少要同时记录日均写入量、小时峰值、分钟级峰值、单事务写入量和重试放大倍数。没有这些数据,直接决定分区或分片,很容易把平均负载当成真实压力。

生产环境中的慢查询不一定是数据库单独造成的。应用层可能先查库存再更新,消息消费可能重复执行,报表任务可能在交易高峰运行,归档任务可能与在线写入争抢磁盘 I/O。最终监控上看到的是 SQL 变慢,但根因分散在业务流程和运维任务中。
我在排查时会把一次库存变化拆成请求进入、业务校验、库存锁定、余额更新、流水写入、消息提交和后续对账几个时间段。只有把链路拆开,才能判断延迟究竟发生在锁等待、磁盘写入、网络调用、消息重试,还是历史流水查询。
分库分表可以降低单个物理对象的规模,但会引入路由、跨片查询、分布式事务、扩容迁移和故障恢复问题。如果当前瓶颈只是某个查询缺少合适索引,或者历史数据从未归档,分片只会增加系统复杂度,却不会解决根因。
判断是否进入分片阶段,应该先看单库资源是否接近上限、写入峰值是否已经无法通过优化和扩容承受、备份恢复是否超出业务窗口,以及主要查询能否稳定命中分片键。只要这些问题没有数据证据,就不应把分片当成默认答案。
余额与流水写在同一张表,并不会自动带来一致性。真正决定一致性的,是变更是否在正确的事务边界内完成、失败是否能够回滚、重试是否幂等,以及异步事件是否有补偿和对账机制。
一张表同时保存当前余额和所有历史变化,还会导致在线更新频繁触碰大表。随着索引增多,每次余额更新都要维护更多结构;当审计查询执行大范围扫描时,又会影响同一张表上的交易。
库存流水表最容易出现“为了满足每个查询各建一个索引”的情况。索引确实可以减少扫描,但每个索引都会增加写入、页分裂、缓存占用、统计信息维护和备份成本。低选择性字段单独建索引,往往只能制造额外负担。
组合索引的字段顺序也不能凭直觉决定。例如查询通常按仓库、商品过滤,再按发生时间倒序分页,那么索引应围绕这个访问路径验证,而不是简单把所有字段拼在一起。最终必须用真实执行计划观察扫描行数、排序方式和回表数量。
只记录“增加 10”或“减少 5”,在数据正常时看起来足够。但一旦发生并发、重试或人工调整,单靠增量很难判断某一条流水写入时系统处于什么状态。
在审计要求较高的场景,我更倾向于保留变更前数量、变更数量和变更后数量,同时明确这些值是在同一事务内生成的。这样做会增加存储空间,但能明显降低异常定位成本。是否保留前后值,应根据数据量、审计要求和存储预算做取舍,而不是一概而论。
直接删除历史数据可能导致长事务、锁竞争、I/O 峰值和主从延迟。如果流水承担审计或财务凭证作用,删除后还可能无法恢复业务事实。归档应该先定义保存范围、查询入口、校验方式和恢复路径。
更稳妥的做法通常是分批迁移、校验数量和摘要、记录归档批次,再在明确窗口内删除或交换分区。对于必须长期保留的原始流水,可以把在线查询和长期保存拆成不同存储层,但不能让“离线”变成“不可追溯”。
库存扣减属于在线交易,平均耗时很容易掩盖高峰问题。平均 50 毫秒并不代表系统稳定,如果 P99 达到 3 秒,用户仍会遇到超时、重复提交和库存状态不确定。
我会同时观察 P50、P95、P99、锁等待、死锁次数、回滚次数和重试次数。尤其要把数据库慢查询日志和应用请求日志用请求编号关联起来,否则很难区分一次真正慢查询和一次被上游重试放大的请求。

任何库存诊断都应先定义业务口径。一个简化的可用库存公式可以写成:期末可用库存等于期初可用库存,加上有效入库,减去有效出库,再加上盘点调整和冲正,最后扣除仍处于锁定状态的数量。
这不是所有业务的统一公式。对于在途库存、冻结库存、质检库存和寄售库存,必须分别建立状态口径。关键不在于公式长短,而在于每一个数量变化都能在流水中找到明确的事件来源。
期末可用库存
= 期初可用库存
+ 已确认入库
+ 释放锁定
已确认出库
新增锁定
+ 盘点调整
+ 冲正数量
如果数据库管理员无法从表结构中识别“新增锁定”和“释放锁定”的关系,通常说明状态模型还不完整。此时直接增加缓存或读副本,并不能解决对账口径问题。
数据库自增主键只能保证数据库内部某一行的唯一性,不能保证同一业务事件不会被重复写入。订单出库、支付回调、调拨确认和消息消费都应该有稳定的业务事件编号。
一个合理的幂等设计通常包括业务事件 ID、事件类型、来源系统和处理状态。数据库层可以通过唯一约束阻止重复事件,应用层则需要明确重复请求的返回结果。只有两层配合,才能避免“代码判断一次、数据库又插入一次”的竞态。
INSERT INTO inventory_flow
(
event_id,
event_type,
sku_id,
warehouse_id,
quantity_delta,
request_id,
created_at
)
VALUES
(
:event_id,
:event_type,
:sku_id,
:warehouse_id,
:quantity_delta,
:request_id,
CURRENT_TIMESTAMP
)
ON DUPLICATE KEY UPDATE
event_id = event_id;
上面的写法只是示意。不同数据库对冲突处理语法不同,真正上线前还要明确:重复事件是否应该返回原处理结果、是否允许同一事件发生冲正、唯一键范围是否需要包含仓库或业务类型。
最危险的库存扣减模式,是应用先读取可用库存,确认数量足够后,再执行普通更新。两个并发请求可能同时读到相同余额,随后分别完成扣减,最终造成超卖。
更可靠的做法是把条件放进更新语句或事务控制中,让数据库在更新时判断库存是否仍然满足条件。具体实现要结合数据库隔离级别、锁行为和业务吞吐目标验证,不能只凭代码表面判断安全性。
UPDATE inventory_balance SET available_quantity = available_quantity - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND warehouse_id = :warehouse_id AND available_quantity >= :quantity AND version = :version;
执行后必须检查受影响行数。受影响行数为零,可能表示库存不足,也可能表示版本冲突。两者要区分处理,否则应用层会把并发冲突错误地当成“没有库存”。
库存余额更新成功但流水写入失败,会出现“有结果、无证据”;流水写入成功但余额更新失败,则会出现“有证据、无结果”。这两种情况都需要通过事务、可靠消息或补偿机制明确处理。
对于必须强一致的扣减动作,余额变化和核心流水通常应在同一数据库事务内完成。对于跨系统通知、报表同步和搜索索引更新,则可以放到事务提交后的异步链路,但必须有事件状态、重试策略和对账任务。
我会收集最近一段时间的真实查询样本,按访问目的分为余额查询、单据追溯、时间范围查询、异常查询和报表聚合。每类查询分别统计过滤字段、排序字段、返回字段和结果集大小。
之后再查看执行计划。重点不是“有没有使用索引”这一项,而是索引扫描了多少行、是否发生额外排序、是否回表过多、是否出现临时表,以及数据规模增长后计划是否发生变化。
| 查询场景 | 常见条件 | 重点观察 | 潜在改进 |
|---|---|---|---|
| 查询当前余额 | 商品、仓库、批次 | 是否能精确定位唯一记录 | 优化唯一键和热点行访问 |
| 查询业务单据流水 | 单据号、事件类型 | 是否出现全表扫描 | 建立围绕单据追溯的组合索引 |
| 查询时间段变化 | 仓库、商品、发生时间 | 是否扫描过宽时间范围 | 考虑时间索引、分区和游标分页 |
| 查询异常流水 | 状态、差异类型、处理时间 | 低选择性字段是否导致无效索引 | 结合数据分布设计索引或独立异常表 |
| 报表聚合 | 时间、仓库、商品分类 | 是否影响在线事务 | 使用汇总表、读副本或分析型数据链路 |
库存流水并不是写入后永远保持同一种访问价值。最近几天的数据可能用于在线追踪,近几个月的数据可能用于运营报表,更早的数据主要服务于审计和争议处理。不同阶段的数据应有不同的存储和索引策略。
数据生命周期至少要回答:在线保留多久;历史数据如何查询;归档任务是否可重复执行;归档后如何验证完整性;删除后是否可以恢复;审计人员能否找到原始事件。没有这些答案,所谓归档只是定时删除。

下面使用一个脱敏的多仓零售系统作为案例。该系统包含 12 个仓库、约 80 万个商品与仓库组合,每天正常产生约 30 万条库存流水,高峰日达到 100 万条左右。数据为样本推演,用于展示排查方法,不代表某一家企业的真实生产数据。
系统初期采用一张库存余额表和一张库存流水表。余额表按商品和仓库查询,流水表记录入库、出库、锁定、释放和盘点调整。最初表中只有约 500 万条记录,查询和对账都没有明显问题。
一年后,流水表增长到约 2.4 亿行。业务方开始反馈三个现象:查询某张出库单的流水偶尔超过 2 秒;库存扣减高峰期出现超时重试;每天凌晨执行对账后,数据库磁盘 I/O 和主从延迟明显升高。
排查后发现,出库单追溯查询使用了单据号过滤,但返回结果还要按发生时间排序。原有索引只覆盖商品和仓库,没有覆盖单据号,导致部分请求扫描大量流水。
第二个问题是库存扣减采用“先查询,再更新”的两步操作。正常负载下很少触发问题,但在高峰期,多个请求同时读到相同的可用库存,失败请求又自动重试,进一步增加锁竞争。
第三个问题来自对账任务。对账程序每天按商品和仓库聚合全量流水,再与余额表比较。它的计算逻辑没有错误,但把历史数据和当日交易放在了同一条查询链路中,导致交易高峰和对账任务争抢磁盘与缓冲池。

团队没有马上分库分表,而是先做了四项低风险调整。第一,给库存事件增加业务事件编号,并在数据库层建立唯一约束。第二,把库存扣减改为带条件的原子更新,并区分库存不足和版本冲突。
第三,针对出库单追溯建立符合真实查询路径的组合索引,并把深分页改为基于最后一条记录的游标分页。第四,把对账任务改为按日汇总,不再每天重新聚合全部历史流水。
这些调整没有改变数据库部署拓扑,却显著减少了无效扫描和重复写入。样本环境中,出库单追溯 P95 从 2200 毫秒降到 260 毫秒,高峰期重复流水从每小时约 900 条降到 70 条,对账耗时从 146 分钟降到 31 分钟。这里的数字是情景模拟,实际收益必须以目标系统的执行计划和监控结果为准。
完成上述优化后,流水表仍然持续增长。由于查询大多带有发生时间,且在线查询只需要最近 90 天数据,团队进一步评估按月分区和历史归档。
分区的主要收益不是让每条查询自动变快,而是让维护、删除和归档具备更清晰的边界。如果查询条件无法使用分区键,或者大量请求仍然需要扫描全部分区,分区的收益会受到限制。
最终的改造顺序是:保留最近 90 天在线流水;按月建立历史边界;将更早的数据归档到独立存储;保留按单据号检索历史记录的入口;对归档批次执行数量、金额和摘要校验。只有当单库写入峰值和恢复窗口仍然无法满足目标时,才继续评估分库分表。

不要一上来就在生产库里随机执行大量分析 SQL。第一步应建立固定采样窗口,例如最近 7 天和最近一次业务高峰,统一收集表规模、写入量、查询延迟、锁等待和对账结果。
表结构检查的重点不是字段越多越好,而是关键业务事实是否有数据库层面的保护。至少应核对主键、业务唯一键、数量字段、时间字段、事件类型、来源单据和状态字段。
| 检查项目 | 需要回答的问题 | 发现异常后的风险 |
|---|---|---|
| 业务事件唯一键 | 同一个出库或锁定事件能否重复写入 | 重复扣减、重复流水、对账困难 |
| 数量字段 | 是否支持业务所需精度,是否可能溢出 | 数量截断、精度误差、库存负数 |
| 事件类型 | 是否能区分入库、出库、锁定、释放、冲正 | 流水可见但业务语义不可见 |
| 请求编号 | 能否把一条流水关联到一次接口调用 | 无法定位重试和异常来源 |
| 变更前后值 | 是否能还原写入时的库存状态 | 并发异常和人工修正难以追踪 |
建议把 SQL 检查分为四类,而不是只搜索执行时间最长的语句。库存余额查询反映实时读路径,扣减语句反映并发控制,流水追溯反映历史查询,聚合对账反映数据治理能力。
执行计划检查应结合真实数据分布。测试库里只有几万行时看起来高效的查询,放到生产数据规模上可能完全不同。对于高频 SQL,我建议保存基线,包括扫描行数、返回行数、执行时间和锁等待情况,后续每次索引或分区变更都进行对比。
库存一致性问题经常发生在数据库事务之外。比如余额更新已经提交,但发送出库成功事件失败;或者消息已经发出,消费者处理成功后响应超时,消息系统再次投递。数据库管理员需要与应用日志、消息日志一起核对,而不是只看流水表。
重点检查以下链路:业务事件是否有唯一编号;数据库事务提交和消息确认的先后顺序是什么;消费者重复处理时如何返回;失败补偿是否会重新扣减;补偿任务是否有最大重试次数和人工介入状态。
归档检查不能只看“归档任务是否成功”。还要验证归档前后数据数量、关键字段摘要、按单据查询结果、历史查询权限和恢复演练结果。归档数据如果无法在争议发生时被准确取回,就不算真正完成了治理。
还要记录备份时长、恢复时长和恢复点目标。流水表增长后,备份窗口通常会持续扩大;如果恢复演练从未做过,团队实际上并不知道拆表或归档会不会影响故障恢复。

高质量诊断报告不应只写“建议优化索引”。我会要求每个问题都采用四列记录:观察到的症状、支持结论的证据、根因判断、下一步动作。这样可以避免不同团队对同一个问题反复争论。
| 症状 | 证据 | 判断 | 建议动作 |
|---|---|---|---|
| 出库单查询变慢 | 扫描行数从 2万升至 800万,排序耗时增加 | 查询路径与索引不匹配 | 调整组合索引,改用游标分页 |
| 高峰期偶发超卖 | 同一商品同一仓库存在并发读取记录 | 先查后改产生竞态 | 条件更新,检查受影响行数,补充并发测试 |
| 流水重复 | 相同事件编号出现多条有效扣减 | 缺少数据库级幂等约束 | 增加唯一键,明确重复事件处理结果 |
| 对账拖慢交易 | 对账期间磁盘读取上升,交易 P99 同步升高 | 分析负载与交易负载未隔离 | 增量汇总、错峰运行或迁移分析负载 |
先从真实查询样本入手,确认过滤、排序和分页方式。清理重复索引,补充真正匹配访问路径的组合索引,并评估是否存在隐式类型转换、函数包裹字段和大字段回表。
流水查询尽量避免深分页。传统的“跳过前面很多页再取几十条”会让数据库重复扫描前面的结果。对于按时间倒序查询的场景,可以携带上一页最后一条记录的时间和唯一 ID,以稳定条件继续向后查询。
SELECT flow_id, event_id, sku_id, warehouse_id, quantity_delta, event_type, created_at FROM inventory_flow WHERE warehouse_id = :warehouse_id AND sku_id = :sku_id AND ( created_at < :last_created_at OR ( created_at = :last_created_at AND flow_id < :last_flow_id ) ) ORDER BY created_at DESC, flow_id DESC LIMIT 100;
这段查询仍需结合索引和数据库类型验证,但它表达了一个重要原则:分页条件必须与排序顺序一致,并且要有稳定的唯一字段作为并列排序的补充。
不要先重算余额,也不要直接用人工 SQL 覆盖库存。先保存异常快照,包括余额值、最近一次有效流水、相关业务单据、请求编号和处理状态。
然后把差异分为四类:事务未完成、事件重复、异步延迟和人工调整。每一类的修复路径不同。事务未完成需要查回滚和异常日志;事件重复需要查幂等;异步延迟需要查消息状态;人工调整则需要审批和审计依据。
如果系统长期依赖人工修正,建议增加“调整事件”和“冲正事件”,不要修改历史流水。历史流水一旦被覆盖,后续即使库存重新正确,也很难解释曾经发生过什么。
先确定库存一致性目标。部分业务允许短暂最终一致,部分业务要求扣减成功即不能超卖。不同目标会影响事务设计、锁粒度、缓存策略和异步化边界。
对必须强一致的核心扣减,优先检查条件更新、行锁范围和事务持续时间。对热点商品,可以评估按仓库拆分库存、库存预分配或串行化热点事件,但这些方案会牺牲部分灵活性,不能只看吞吐量。
并发测试不能只模拟平均流量。应重点模拟同一 SKU、同一仓库、同一批次被大量请求同时扣减的情况,同时注入超时、重试和消费者重复投递,观察最终余额、流水数量和异常恢复结果。
先统计在线查询的时间范围。如果绝大多数查询只需要最近一段时间,说明历史流水和在线流水可能不需要承载相同的访问压力。可以先建立归档边界,再考虑按时间分区、冷热分层或独立历史库。
如果查询经常跨多年、跨仓库、跨商品进行复杂聚合,单纯分区未必足够。此时应评估增量汇总表、分析型存储或独立报表链路,避免在线交易数据库成为数据仓库。
最先做的不是增加报表索引,而是确认报表是否真的需要明细级数据。很多运营报表只需要按日、仓库、商品类别汇总,完全可以由增量汇总表提供。
如果必须访问明细,可以考虑读副本或独立分析链路,但要明确同步延迟和数据一致性边界。库存交易页面不能因为报表延迟就读取错误余额,报表也不应为了追求实时而拖慢扣减事务。

归档适合历史数据价值下降、在线查询范围明确的系统。它通常能直接降低在线表规模、索引维护成本和备份压力,实施风险相对可控。
代价是历史查询不再只访问一个地方。系统需要提供统一查询入口,或者明确告诉使用者如何检索历史数据。归档过程还要处理断点续传、重复执行、数据校验和恢复,不能把数据搬走后就宣布完成。
当流水天然按时间增长,且大量查询带有时间范围条件时,分区可以帮助管理历史数据、缩短归档操作并减少部分扫描范围。分区边界应根据数据增长速度和维护窗口设计,不能只按数据库默认示例照搬。
分区的短板是业务查询必须真正利用分区键。若大量查询只按商品和仓库过滤、不带时间条件,仍可能访问多个分区。此外,分区数量、索引结构和数据库版本能力都需要提前验证。
可以将当前流水、历史流水、异常流水或汇总数据拆开,但拆表前必须定义数据迁移规则和查询路由。拆表不是简单复制表结构,而是重新确定哪些数据在线、哪些数据只读、哪些数据需要聚合。
如果业务仍然频繁查询全量历史,拆表可能只是把一个大查询变成多个表的联合查询。只有当访问模式确实存在边界时,拆表才有明显收益。
分库分表适用于单库资源、写入峰值、存储规模或恢复窗口已经达到实际上限的系统。它需要稳定的分片键、清晰的路由规则、跨片查询方案和完整的运维体系。
库存系统的分片键并不容易选择。按商品分片,可能方便同一商品查询,却让跨商品、跨仓库报表变复杂;按仓库分片,适合仓内业务,却可能让跨仓调拨和全局库存统计产生跨片请求;按业务单据分片,则不一定适合商品维度的库存扣减。
| 方案 | 主要收益 | 主要代价 | 适合触发条件 |
|---|---|---|---|
| 归档 | 降低在线表规模和维护压力 | 历史查询需要额外路由 | 在线查询有明确时间边界 |
| 分区 | 改善生命周期管理和部分范围扫描 | 需要合理分区键和维护策略 | 流水按时间增长且查询常带时间条件 |
| 拆表 | 隔离不同职责和访问负载 | 数据迁移与查询路由更复杂 | 当前、历史、异常或汇总负载边界清晰 |
| 分库分表 | 扩大写入、容量和故障隔离能力 | 跨片事务、聚合、扩容和恢复复杂 | 单库资源或恢复窗口已成为硬约束 |

读写分离适合报表、追溯和部分查询负载已经影响主库的场景。但库存实时页面如果对延迟敏感,必须明确副本延迟可能导致读到旧值。
如果业务先写主库,再立即从副本读取库存,可能出现“刚扣减却显示未扣减”的体验。对此可以采用关键路径读主库、带版本号读取或等待副本追平等方案,但每种方案都有额外成本。
业务团队常说“库存不对”,但这个“不对”可能指可用库存、物理库存、锁定库存、在途库存或可销售库存。数据库管理员如果不先确认口径,容易把正确的数据误判为异常。
建议为每一种库存状态建立明确的定义、来源事件和计算关系,并把这些定义写进数据字典。数据字典不是文档装饰,它直接影响查询条件、索引设计、对账规则和异常处理。
“系统要高并发”无法指导设计。更有用的目标是:高峰期每秒处理多少次扣减,余额查询 P99 不能超过多少毫秒,出库单追溯允许多长时间,库存对账必须在多少分钟内完成,故障后最多允许丢失多少分钟的数据。
只有把目标量化,才知道应该优化 SQL、增加缓存、拆分负载,还是更换存储架构。否则团队很容易在“数据库已经很慢”和“其实还够用”之间反复争论。
任何结构改造都应该保留前后对比。至少记录核心 SQL 的扫描行数、P95、P99、锁等待、错误率、重试率、流水重复率和对账耗时。
如果改造后只看到 CPU 降低,却没有观察库存一致性和恢复能力,可能只是把问题从性能层转移到了数据质量层。库存系统的成功标准必须同时包含速度、正确性和可恢复性。
对账不应只在每天凌晨执行一次。高价值库存系统可以按小时、按仓库或按事件批次进行增量核对,及时发现流水缺失、重复事件和状态不一致。
对账结果应包含异常数量、异常金额、影响仓库、最近事件、责任链和处理状态。只有保存异常上下文,业务团队才可以在问题扩大前采取动作。


如果余额与流水能够互相解释,核心查询扫描量可控,P99 在目标范围内,锁等待和重复事件已经稳定,归档与恢复也有明确方案,那么即使流水表很大,也不必因为“行数看起来吓人”就立即分库分表。
数据库容量评估应该服务于业务目标,而不是服务于技术焦虑。只要当前架构还能稳定满足峰值、备份和恢复要求,优先保持系统简单,往往比提前引入分布式复杂度更安全。
如果单库写入峰值已持续接近硬件上限,备份或恢复窗口超过业务要求,历史数据无法在线治理,核心查询必须跨越巨大数据范围,或者高峰期锁等待已经无法通过事务和数据模型优化解决,就应当认真评估分区、拆表、读写分离或分库分表。
此时要先做小规模验证。选择一类仓库、一段时间数据或一组典型商品进行迁移,验证查询路由、事务边界、对账结果、故障恢复和扩容流程。不要直接在全量生产环境中一次性完成架构切换。
更高的吞吐量通常意味着更复杂的路由和运维;更强的实时性通常意味着更高的事务成本;更长的历史保留时间通常意味着更高的存储与审计成本;更严格的一致性通常意味着更低的并发自由度。
库存系统没有“性能、成本、正确性、可追溯性全部最高”的免费方案。真正专业的设计,是明确哪些业务动作必须强一致,哪些查询可以延迟,哪些历史数据必须在线,哪些数据可以转移到低成本存储。
我对库存数据库扩展性的核心判断只有一句话:先看流水能不能解释余额,再看查询能不能承受增长,最后才看数据库要不要拆。库存流水不是单纯的历史明细,它是系统正确性、并发行为和架构寿命的记录。能够从流水中建立完整证据链的系统,才有资格讨论下一步扩容;连异常来源都无法还原的系统,越早引入复杂架构,越可能把一个可定位的问题变成长期不可控的问题。
我负责的库存系统刚上线时,流水表只有几十万行,查询和对账都很快。现在每天新增约 180 万条流水,按商品和仓库查询开始变慢,但团队意见不一:有人建议马上分库分表,也有人认为只是索引没建好。我应该用哪些证据判断问题究竟出在表结构、查询,还是数据规模?
不能用“超过多少行”作为唯一标准判断库存流水表是否需要拆分。相同的行数,在不同硬件、索引数量、查询模式和事务压力下,表现可能完全不同。我更看重四组证据:单日增长量、核心查询的扫描行数、维护任务耗时,以及备份恢复窗口是否已经影响业务。
例如,下面是一组脱敏后的排查数据,数字仅用于说明判断方法: 指标优化前需要关注的信号 流水表总行数4.8 亿不是单独的拆分依据 日新增流水180 万条持续增长且无归档计划 商品仓库查询扫描行数620 万行明显高于返回行数 P99 查询耗时2.8 秒已影响库存接口超时率 备份窗口从 2 小时增至 6 小时开始挤压维护窗口 这个案例里,真正的第一步不是分库分表,而是检查查询是否把“当前余额查询”和“历史流水追溯”混在了一起。
余额查询应该命中小而稳定的库存余额表,流水查询则应限制时间范围,并使用适合过滤和排序的索引。如果核心查询只返回几十行,却扫描数百万行,优先修复访问路径通常比立即拆库更稳妥。我的判断顺序是:先确认余额与流水能否对账,再看执行计划和锁等待,然后评估归档、分区和读写隔离,最后才讨论分库分表。
只有当单库资源、写入峰值、索引维护或恢复窗口已经成为硬瓶颈,并且能够设计稳定的分片键时,分库分表才值得承担额外复杂度。
我遇到过订单重试后库存被扣了两次的情况:业务日志显示只提交了一次订单,但流水表里出现了两条出库记录。开发团队说数据库已经加了事务,运维团队则怀疑是消息重复投递。我想知道,事务、锁和幂等分别解决什么问题,排查时应该按什么顺序进行?
事务不能自动解决重复请求,行锁也不能替代幂等。事务解决的是一组数据库操作是否一起提交;锁主要处理并发访问;幂等则回答“同一个业务事件重复到达时,能不能只生效一次”。库存系统把这三件事混为一谈,是重复扣减最常见的根因。我通常先从流水表反查同一业务事件,而不是先看应用日志。
重点检查订单号、出库单号、消息 ID 或请求 ID 是否具备唯一约束,并比较两条流水的创建时间、事务提交时间和来源系统。如果两条记录的业务事件 ID相同,优先怀疑重试缺少幂等;如果事件 ID不同但库存余额被并发覆盖,则更像是扣减逻辑存在竞态。
安全扣减至少应让“判断库存足够”和“减少库存”形成一个不可分割的数据库操作,例如:
UPDATE stock_balance SET available_qty = available_qty - :qty WHERE sku_id = :sku AND warehouse_id = :warehouse AND available_qty >= :qty;执行后必须检查受影响行数。受影响行数为 1,才说明扣减成功;为 0,则可能是库存不足、记录不存在或条件不匹配。应用层先查询可用库存、再单独执行更新,即使两条语句都放在业务代码里,也可能在并发下产生竞态。但条件更新仍然不等于完整方案。流水写入应使用业务事件唯一键,余额变更和流水记录应明确事务边界;
如果采用消息队列,还要定义消费重试、失败补偿和重复消息处理规则。一次实际排查中,表面上是“数据库偶发超卖”,最后发现是消费者超时后自动重试,而原事务其实已经提交,缺失唯一事件约束才让问题变成了重复扣减。
我检查过一张库存流水表,商品、仓库、订单号、流水类型、创建时间几乎每个字段都有单列索引。查询看起来都能命中索引,但高峰期写入延迟明显上升,索引空间也快接近数据空间。我该如何根据真实查询设计组合索引,而不是凭经验不断加索引?
库存流水表的索引设计,核心不是“每个查询字段都建一个索引”,而是让高频查询尽量沿着同一条稳定访问路径完成。每增加一个索引,插入和更新都要额外维护 B+ 树;当流水以每秒数千条的速度写入时,冗余索引会直接转化为 I/O、页分裂和缓存压力。我会先把慢查询按业务场景分组,而不是按 SQL 文本逐条优化。
常见的三类查询其实完全不同:实时查询某商品某仓库的变更,按业务单据追溯完整链路,以及按时间范围做审计或报表。它们不应共用一套“看起来全面”的索引。
查询场景典型条件优先验证 商品仓库流水sku_id、warehouse_id、event_time组合过滤与时间排序是否匹配 单据追溯business_no、event_time业务单号是否高选择性 时间审计event_time、event_type是否误扫全表、是否应旁路查询 索引字段顺序不能只看字段名称,还要看过滤方式和数据分布。
例如查询总是先限定仓库和商品,再按时间倒序取最近记录,那么组合索引应围绕这条访问路径验证;如果查询只按时间筛选,却要求返回几个月的数据,单纯加时间索引也可能只是把全表扫描变成大范围索引扫描,收益有限。判断索引是否有效,至少要同时看执行计划、实际扫描行数、返回行数、排序方式和锁等待。
一次模拟压测中,删除 6 个重复单列索引后,写入吞吐提升约 19%,但某条报表查询变慢了。最后采用“交易库保留少量高频索引、报表数据异步同步”的方式,比继续给交易表堆索引更适合库存场景。这里的性能数字是测试环境结果,不能直接当作所有系统的承诺。
我的库存流水已经有几亿行,团队提出了四种方案:删除旧数据、按月分区、冷热表拆分、按仓库分库。问题是历史流水又不能完全删除,跨仓库对账也经常发生。我不想为了追求架构“升级”而引入跨库事务和复杂运维,应该怎样做决策?
这四种方案解决的不是同一个问题。归档解决在线数据生命周期,分区改善特定范围的数据维护和访问,拆表用于隔离不同访问负载,分库分表才是把容量和写入压力分散到多个数据库节点。把它们当成同一级别的性能开关,往往会在没有定位瓶颈前先制造新问题。我的建议是先做“在线数据边界”而不是先选技术方案。
比如在线交易只需要查询近 12 个月流水,审计要求保留 7 年,那么可以把近 12 个月放在在线库,较早数据进入归档库或对象存储,同时保留业务单号、原始事件 ID 和可检索的索引。归档必须能被验证、追溯和恢复,不能简单理解为定期 DELETE。
方案更适合解决主要代价 归档历史数据占用在线空间历史查询链路变复杂 分区按时间维护和范围查询依赖数据库能力与正确分区裁剪 冷热表拆分交易与审计负载冲突需要同步和数据一致性治理 分库分表单库资源或写入上限跨分片查询、扩容和恢复更复杂 分库分表至少要满足三个条件:单库资源已经成为明确瓶颈;
归档、索引和查询治理无法继续缓解;业务能够接受稳定的路由规则。按仓库分库看似自然,但跨仓库盘点、总部报表和调拨对账都会变成跨库聚合。如果仓库数量还会频繁变化,分片键设计不稳定,后续迁移成本可能高于当前性能收益。
最终决策应建立在可量化指标上:写入峰值、P99 延迟、磁盘增长率、索引维护耗时、备份恢复窗口和跨库查询比例。只要瓶颈主要来自历史数据、深分页或报表抢占资源,优先归档、限制查询范围和隔离读负载;只有当单库承载能力确实触顶时,才进入分库分表评估。


读者评论
文章把库存余额与流水的职责分开讲得比较清楚,尤其是通过余额反推流水来判断一致性,比单纯讨论分库分表更有诊断价值。不过实际落地还需结合业务对审计、实时性和存储成本的要求。
对幂等、重试和补偿造成流水膨胀的分析很贴近生产场景。很多系统只关注正常写入量,却忽略促销期间的重复请求和对账补偿,容量评估确实应重点关注峰值及尾部延迟。
归档部分给出的建议较稳妥,分批迁移、校验和保留恢复路径都比直接删除更安全。文章如果能进一步补充分区表、冷热数据存储的实施示例,会更方便数据库管理员参考。