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 事务处理:把多个操作绑在一起

入库操作通常涉及多个表的修改:

  1. 创建入库单主表记录
  2. 创建入库单明细记录
  3. 更新库存表(增加数量)
  4. 记录库存流水(用于审计)

这四个步骤,必须在一个数据库事务里完成。为什么?因为如果第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));
    }
}
小技巧: 事务里尽量不要做远程调用(比如发消息、调外部API),因为远程调用失败会导致事务回滚,但远程操作可能已经生效了。如果必须做,建议用「事务消息」或者「本地消息表」来解耦。

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 模式

好了,入库逻辑就聊到这。下一章咱们讲出库,那又是另一番天地了。