库存账本一致性:用事务、原子扣减与补偿流水守住库存
本文目录
库存数字是当前状态,出入库流水是状态变化的证据。只更新库存而没有流水,事后无法解释差异;只写流水而库存没更新,报表也会误导。可靠的库存操作要把“检查可用量、更新数量、写入流水”作为一个数据库原子工作单元,并明确面对并发、重试和纠错时的业务语义。
本文来自一次个人电商后台的本地静态代码审阅。被审阅的模型以 PostgreSQL/Sequelize 保存配件库存、阈值和入出库流水;控制器包含单件/批量入库出库、扫码调整、预警查询以及流水编辑/删除。代码已经有 before/after 数量、操作人、原因等账本字段,这是很好的可追溯基础。但在所检查的库存接口里,读取与更新、更新与创建流水是独立调用,没有看到它们共享数据库事务;批量操作逐项提交,扫码入口也需要更严格的类型和值校验。本文只描述检查到的这些源代码路径,不代表已对项目运行测试、核对数据库迁移或验证生产行为,也不会公开项目地址与运行配置。
先把不变量说清楚
对每一种库存资源,先写明不变量,再写控制器:
- 每个成功的库存变更都恰好有一条对应的账本事件。
- 账本写入失败时,库存更新必须回滚;库存更新失败时,不留下“成功”流水。
- 出库永不把库存扣成负数,多个请求并发时也一样。
- 每次操作都能从服务端身份和可信请求上下文归因,不能接受客户端自行指定的 operator。
- 重试同一业务请求不会重复入库或出库;批量 API 的整体成功/部分成功规则必须对调用方可预测。
- 已确认的历史流水不被静默改写或删除;纠错通过新的冲正事件留下完整来龙去脉。
这些不变量说明为什么“先查 stock,再算 afterStock,最后保存”还不够。类型声明不能替代运行时验证,控制器层的库存不足检查也不能独自解决两个并发请求同时通过检查的问题。
读改写会丢失并发更新
假设库存是 5,两个出库请求几乎同时各取 4。它们都可能先读到 5,通过“库存不足”检查,再各自写入 1;两个请求都返回成功,但系统记录的结果与实际发货不一致。即使每个请求单独测试都正确,这类交错仍会发生。
一种做法是在同一事务中对库存行加行级锁,读到锁定后的最新值,再更新库存并插入流水:
async function applyStockOut(itemId, quantity, authenticatedUser) {
await sequelize.transaction(async (transaction) => {
const part = await Part.findByPk(itemId, {
transaction,
lock: true,
});
if (!part) throw new Error('PART_NOT_FOUND');
if (part.stock < quantity) throw new Error('INSUFFICIENT_STOCK');
const beforeStock = part.stock;
const afterStock = beforeStock - quantity;
await part.update({ stock: afterStock }, { transaction });
await StockLog.create({
id: createId(),
itemType: 'part',
itemId: part.id,
itemName: part.name,
type: 'out',
quantity,
beforeStock,
afterStock,
operator: authenticatedUser.id,
}, { transaction });
return { beforeStock, afterStock };
});
}
这里假设 quantity 已经被验证为有限的正安全整数,错误会让托管事务回滚,且所有查询都显式传入同一个 transaction。Sequelize 文档说明它不会默认替应用把所有查询包进事务;事务内的查询可以使用锁。PostgreSQL 的行级锁会协调对同一行的并发修改。也可以用条件 UPDATE 把“只在库存足够时扣减”做成数据库原子条件,并通过 RETURNING 读取更新结果;流水仍要在同一事务内写入。
UPDATE parts
SET stock = stock - $1
WHERE id = $2
AND stock >= $1
RETURNING id, stock, stock + $1 AS before_stock;
如果 UPDATE 返回零行,要区分记录不存在与库存不足,并给出稳定的业务错误。不能把 SQL 文本与用户输入拼接;使用驱动的 bind/replacements 参数。需要通过 ORM 传递 RETURNING 的字段时,核对当前 Sequelize 版本和 PostgreSQL 方言的返回类型,不要假定每种数据库行为相同。
库存与流水必须同进退
若先更新 Part、再调用 StockLog.create,第二步可能因连接中断、约束失败或数据库故障而失败。此时库存已经变了,流水却不存在。反过来先写流水也会留下没有真实库存变化的记录。
把更新和流水写入放在同一事务后,只有两步都成功才提交。StockLog 的 beforeStock/afterStock 应由锁内读取或数据库实际返回值计算,不能来自可能陈旧的请求前快照。若一笔业务还要扣减订单明细、生成发货记录或发布消息,也要区分同一数据库事务能保证的部分和跨系统的部分;跨系统通知可在事务中写 outbox,再由后台可靠投递。
数据库约束是最后一道防线。库存列应有 NOT NULL;如果业务禁止负数,可在核对和修复历史异常数据后加 CHECK (stock >= 0)。CHECK 约束不替代事务或并发控制,但可阻止其他代码路径意外写入非法状态。不要只根据 ORM 模型定义推断实际生产表一定具有相同约束,应检查迁移和数据库 schema。
批量操作先定义原子性
批量入库和批量出库有两类合理语义,但不能混在一起:
- 整批原子:订单、盘点单或一次采购单要求所有行一起成功。先规范化并校验所有行,在一个事务里按固定顺序锁定资源、复核库存、更新并写流水;任何一行失败就整体回滚。
- 逐行部分成功:每行彼此独立,允许有些成功、有些失败。响应要逐行返回稳定的结果码和请求标识;每行的库存与流水仍必须在自己的事务里原子提交,不能用静默跳过来表达失败。
一次批量锁多个库存行时,按稳定的 ID 顺序获取锁,可减少两个批次以相反顺序锁行造成死锁的机会。批次大小也要设上限,控制事务持锁时间、请求体大小和锁竞争。若某项不存在、数量格式错误或库存不足,应明确返回对应条目,而不是一律略过。
输入、重试和条码扫描
入库、出库数量通常应是有限的正整数:拒绝字符串、零、负数、小数、NaN、Infinity 和超出安全范围的值。库存模型使用整数列时,不要让 JavaScript 的隐式类型转换决定业务结果。操作类型用明确的 in/out 枚举校验,不能因为字段非空就把所有未知值当成出库。
网络超时并不能证明数据库事务失败。客户端可能在服务端提交成功后没收到响应并自动重试,导致同一发货单重复扣减。可为每个业务动作生成 idempotency key,并在账本表或专用请求表上加唯一约束;重复请求返回第一次已提交的结果,而不是再次执行。幂等记录、库存变更与流水应通过同一事务协调。
条码只是输入线索,不是资源授权。服务器要用条码在允许范围内唯一映射商品,再执行当前用户有权操作的库存资源。限制条码长度和字符集,记录经过验证的商品 ID,不把任意客户端 itemId、operator 或库存前值当成可信事实。
不要静默改写账本
删除一条旧流水后,按它的数量反向修改“当前库存”,并不能自动修正之后所有流水里的 before/after。修改历史数量也有类似问题:过去事件变了,后续状态快照却仍然按旧事件计算。库存流水因此更适合追加而非覆盖。
发现差错时,追加一条反向冲正事件,记录原流水 ID、冲正理由、操作者、请求 ID 和当前库存变更;再追加正确事件。唯一约束可防止同一原事件被重复冲正。若法律或业务要求允许作废,保留原记录并追加带原因的作废/冲正关系,不要物理删除审计证据。页面可以提供“纠正”操作,但底层应是可追踪的新事件。
生产测试矩阵
- 同一行的并发出库:库存 5、两个数量 4 的请求只能有一个成功,最终库存、流水和接口结果一致。
- 人为让流水插入失败,确认对应库存更新被回滚;再让库存更新失败,确认没有成功流水。
- 同一 idempotency key 重放并发请求,验证只产生一个库存变化和一个业务事件。
- 整批模式下第二行失败后,第一行也回滚;部分成功模式下逐行响应与持久状态完全对应。
- 扫码入口拒绝未知类型、字符串/小数/极大数量、超长条码和无权限资源。
- 冲正后原流水仍可查,冲正关系明确,当前库存与从有效事件重放得到的结果一致。
- 从真实 PostgreSQL 而不是只从 SQLite/mock 测试行锁、隔离级别、死锁重试和唯一约束竞争。
参考资料与审阅边界
- Sequelize v6 Transactions:托管事务、事务传递、锁和隔离级别。
- PostgreSQL Explicit Locking:表级与行级锁。
- PostgreSQL UPDATE:条件更新与 RETURNING。
- PostgreSQL Transaction Isolation:并发事务可见性和隔离级别。
- PostgreSQL Constraints:CHECK、NOT NULL、唯一约束等数据不变量。
本文中的实现建议是对个人项目库存接口静态审阅后的通用化总结;未修改该项目代码、未读取其环境文件、未运行其生产服务,也未声称这些并发问题已经在真实流量中发生。落地前应结合现有迁移、数据库版本、批量业务语义与真实库存数据制定修复和回填方案。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。