4. 商品入库逻辑:入库单创建、库存增加、事务处理与回滚
好,咱们今天聊入库。这是仓库系统里最核心的操作之一,说白了就是「货进来了,系统得认」。我见过不少新手,觉得入库不就是 insert 一条记录嘛,有什么难的?嗯,等你在生产环境里遇到库存对不上、单据丢了、或者并发写爆了的时候,你就知道这里面的门道有多深了。
4.1 入库单的创建:不只是记一笔账
入库单,我习惯叫它「入库凭证」。它不只是记录「进了什么货」,更重要的是记录了「谁、什么时候、从哪来、放到哪」。你想想看,如果哪天仓库盘点发现少了东西,你靠什么追溯?靠的就是这张单子。
入库单的核心字段,我列一下:
| 字段 | 类型 | 说明 |
|---|---|---|
| 入库单号 | 字符串 | 全局唯一,建议用时间戳+随机数 |
| 供应商ID | 整数 | 关联供应商表 |
| 仓库ID | 整数 | 入库的目标仓库 |
| 入库类型 | 枚举 | 采购入库、退货入库、调拨入库等 |
| 状态 | 枚举 | 草稿、已确认、已取消 |
| 创建人 | 字符串 | 操作员ID |
| 创建时间 | 时间戳 | 精确到毫秒 |
这里有个坑,我踩过。入库单号千万别用自增ID,为什么?因为业务上经常需要「预生成单号」,比如打印出来贴在货上,但实际还没入库。自增ID在预生成时就会占用,后面如果取消了,ID就断了,财务对账时会疯掉。我建议用「日期+流水号」或者「UUID短码」。
4.2 库存增加:原子操作才是王道
入库单确认之后,就要真正加库存了。这里我要强调一个原则:库存操作必须是原子性的。
什么叫原子性?就是「要么全部成功,要么全部失败」。你不能出现「入库单创建了,但库存没加上」的情况。我在项目中遇到过,因为网络超时,库存更新语句执行了两次,结果库存翻倍了。嗯,那天的盘点数据简直没法看。
正确的做法是使用数据库的乐观锁或悲观锁。我个人更推荐乐观锁,因为性能好。看个例子:
-- 乐观锁更新库存
UPDATE inventory
SET quantity = quantity + #{increment},
version = version + 1
WHERE sku_id = #{skuId}
AND warehouse_id = #{warehouseId}
AND version = #{oldVersion};
-- 如果影响行数为0,说明版本冲突,重试或报错
你可能会问,为什么不直接用 quantity = quantity + 1?嗯,在高并发下,两个请求同时读到旧值,然后同时加1,结果只加了1次。这就是经典的「丢失更新」问题。乐观锁通过版本号,保证了每次更新都是基于最新的数据。
4.3 事务处理:把多个操作绑在一起
入库操作通常涉及多个表的修改:
- 创建入库单主表记录
- 创建入库单明细记录
- 更新库存表(增加数量)
- 记录库存流水(用于审计)
这四个步骤,必须在一个数据库事务里完成。为什么?因为如果第3步成功了,第4步失败了,你查库存流水时会发现「库存多了,但不知道是谁加的」。审计的时候这就是个大问题。
我用 Spring 的 @Transactional 注解来管理事务,但要注意传播级别。我习惯用 REQUIRED,意思是「如果当前有事务就用它,没有就新建一个」。这样入库服务内部的方法调用,都会在同一个事务里。
@Transactional(rollbackFor = Exception.class)
public void createInboundOrder(InboundOrderDTO dto) {
// 1. 创建入库单
inboundOrderMapper.insert(order);
// 2. 创建明细
for (InboundOrderItem item : dto.getItems()) {
inboundOrderItemMapper.insert(item);
// 3. 更新库存
inventoryMapper.increaseStock(item.getSkuId(), item.getQuantity());
// 4. 记录流水
inventoryLogMapper.insert(buildLog(item));
}
}
4.4 回滚机制:出了错怎么办?
事务回滚是数据库自带的特性,但我们要考虑的是「业务回滚」。比如,入库单创建到一半,发现商品条码扫错了,这时候不能只靠数据库回滚,还要通知上游系统「刚才的入库作废了」。
我设计了一个「回滚日志表」,专门记录每次入库操作的上下文:
CREATE TABLE inbound_rollback_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id VARCHAR(64) NOT NULL,
action VARCHAR(32) NOT NULL, -- 'CREATE', 'UPDATE', 'DELETE'
before_snapshot JSON, -- 操作前的数据快照
after_snapshot JSON, -- 操作后的数据快照
created_at DATETIME NOT NULL
);
这样,如果业务上需要回滚,我可以根据 before_snapshot 恢复数据。注意,这不是数据库的回滚,而是业务层面的补偿操作。比如,库存加多了,就执行一次减库存操作。
4.5 实战中的避坑指南
我曾经在一个项目里,因为没处理好事务边界,导致入库单创建成功了,但库存没加上。原因是库存服务单独部署,用了不同的数据源,跨库事务没处理好。后来我改用「TCC 模式」(Try-Confirm-Cancel)来解决。
简单说,TCC 分三步:
- Try: 预占库存,比如把库存从「可用」变成「预占」
- Confirm: 确认入库,把预占的库存变成「实际库存」
- Cancel: 取消入库,释放预占的库存
这种模式的好处是,即使 Confirm 失败了,也可以重试。Cancel 失败了,也可以人工介入。嗯,说白了就是给系统留了「后悔药」。
最后总结一下入库逻辑的核心要点:
- 入库单要设计好状态机,不能随意跳转
- 库存更新必须原子操作,推荐乐观锁
- 多表操作一定要事务包裹,注意传播级别
- 业务回滚要有日志,方便人工补偿
- 跨服务操作考虑 TCC 或 Saga 模式
好了,入库逻辑就聊到这。下一章咱们讲出库,那又是另一番天地了。