Featured image of post 并发下库存预占与扣减的问题

并发下库存预占与扣减的问题

Views —

我翻了翻这个商城项目的库存代码,想把它改造成分布式微服务架构。

在分布式场景下,网络请求不能认为是可靠的,这包括请求超时,重复投递,消息乱序等等情况。这个项目的库存扣减防超卖并不是原子的,只维护一个lock_stock,无法解决库存预占幂等问题,库存属于哪个订单,取消请求应该释放哪一批库存。

原项目的问题

  1. 当前的锁库存是这么做的: lock_stock +quantity,SQL 没有判断剩余的库存,Service 若把 Mapper 的影响行数直接当成“预占成功”,这个返回值只能证明某一行被更新,不能证明库存充足,这非常容易超卖。无论 stock - lock_stock 是否足够,只要 SKU 存在,更新都可能成功。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<update id="updateByPrimaryKeySelective" parameterType="com.macro.mall.model.PmsSkuStock">  
  update pms_sku_stock  
  <set>  
    <if test="productId != null">  
      product_id = #{productId,jdbcType=BIGINT},  
    </if>  
    <if test="skuCode != null">  
      sku_code = #{skuCode,jdbcType=VARCHAR},  
    </if>  
    <if test="price != null">  
      price = #{price,jdbcType=DECIMAL},  
    </if>  
    <if test="stock != null">  
      stock = #{stock,jdbcType=INTEGER},  
    </if>  
    <if test="lowStock != null">  
      low_stock = #{lowStock,jdbcType=INTEGER},  
    </if>  
    <if test="pic != null">  
      pic = #{pic,jdbcType=VARCHAR},  
    </if>  
    <if test="sale != null">  
      sale = #{sale,jdbcType=INTEGER},  
    </if>  
    <if test="promotionPrice != null">  
      promotion_price = #{promotionPrice,jdbcType=DECIMAL},  
    </if>  
    <if test="lockStock != null">  
      lock_stock = #{lockStock,jdbcType=INTEGER},  
    </if>  
    <if test="spData != null">  
      sp_data = #{spData,jdbcType=VARCHAR},  
    </if>  
  </set>  
  where id = #{id,jdbcType=BIGINT}  
</update>
  1. 锁库存的时候并不能解决并发问题。整个 Java 方法不会自动变成数据库原子操作。假设 stock = 1、lock_stock = 0,请求 A 和 B 都可能读到 0,并分别把 1 写回数据库。数据库里最终甚至可能仍是 lock_stock = 1,但两个订单都收到了成功结果。这是一次丢失更新,库存数字已经无法反映真实的业务归属。如果改成没有条件的 lock_stock = lock_stock + 1,结果又会变成 lock_stock = 2,同样超过总库存。
1
2
3
4
5
6
7
8
private void lockStock(List<CartPromotionItem> cartPromotionItemList) {  
    for (CartPromotionItem cartPromotionItem : cartPromotionItemList) {  
        PmsSkuStock skuStock = skuStockMapper.selectByPrimaryKey(cartPromotionItem.getProductSkuId());  
        skuStock.setLockStock(skuStock.getLockStock() + cartPromotionItem.getQuantity());
        // 如果这时候库存没了,还会继续预扣  
        skuStockMapper.updateByPrimaryKeySelective(skuStock);  
    }  
}
1
2
3
4
<sql id="Base_Column_List">  
  id, product_id, sku_code, price, stock, low_stock, pic, sale, promotion_price, lock_stock,   
  sp_data  
</sql>
1
select <include refid="Base_Column_List" /> from pms_sku_stock where id = #{id,jdbcType=BIGINT}
  1. 没有预占流水记录,只有一个锁定库存数量字段。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
CREATE TABLE `pms_sku_stock` (
 `id` bigint NOT NULL AUTO_INCREMENT,
 `product_id` bigint DEFAULT NULL,
 `sku_code` varchar(64) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT 'sku编码',
 `price` decimal(10,2) DEFAULT NULL,
 `stock` int DEFAULT '0' COMMENT '库存',
 `low_stock` int DEFAULT NULL COMMENT '预警库存',
 `pic` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '展示图片',
 `sale` int DEFAULT NULL COMMENT '销量',
 `promotion_price` decimal(10,2) DEFAULT NULL COMMENT '单品促销价格',
 `lock_stock` int DEFAULT '0' COMMENT '锁定库存',
 `sp_data` varchar(500) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '商品销售属性,json格式',
 PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=243 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC COMMENT='sku的库存';

订单A取消时,只知道要释放两件,但无法确认这两件是否属于订单A,这也会带来幂等问题,订单A的取消请求发了两次,就会增加四件,虚增库存。这也导致无法审计,难以实现超时释放库存。

为什么要预占库存

订单业务流程是先下单,再支付/取消,库存会发生变化。如果下单时直接扣库存,取消订单恢复库存的时候不知道扣了多少库存,增加一个预扣字段,就能解决,方便了订单状态管理,这就是预占库存。 而且,下单和支付不是一个瞬时事务。如果不预占,用户下单后库存可能被其他人抢走。

但是,预占库存会带来一些问题: 在秒杀或热门商品场景下,用户下单而不支付,会让很多库存锁定,正常买家无法正常下单。 如果扣库存成功,订单服务挂了,可能出现库存减少但是没有订单的情况。

库存原子更新

这个项目目前是单体项目,一条SQL语句的执行是原子的,所以用条件更新即可防止超卖。

1
2
UPDATE ims_sku_stock SET lock_stock = lock_stock + #{quantity}  
WHERE sku_id = #{skuId} AND stock - lock_stock &gt;= #{quantity}

InnoDB 执行这条 UPDATE 时会对目标库存行加排他锁。并发请求会在这一行上串行,后到的请求会基于更新后的值再次判断条件。因此,“判断库存够不够”和“增加锁定数量”不会被其他事务插入,这一条SQL是原子的。

但是这原子更新就意味着有行锁,秒杀情况下会有大量请求竞争同一行的行锁,库存扣减与数据库交互,性能会受到数据库连接数,IO,锁等等的性能限制。

这条 SQL 解决了超卖,却没有解决幂等。相同请求执行两次,仍会把 lock_stock 增加两次。要识别相同请求,库存预占必须有业务身份。

库存预占幂等

幂等就是同一个业务执行很多次,但是业务效果和执行一次相同。前端的防连点更不能替代服务端幂等设计。

原项目有三个库存量:

  • stock:当前库存总量;
  • lock_stock:已预占、尚未确认成交的数量;
  • stock - lock_stock:当前可售数量。

下单时只增加 lock_stock。支付成功后,同时减少 stock 和 lock_stock;订单取消时只减少 lock_stock。已经确认成交后的退款或退货属于另一条入库流程,不能再调用预占释放接口。

只增加一个 lock_stock 字段仍然不够。它只能表示锁了多少库存,不知道是哪个订单锁的。因此需要一张预占单记录请求和状态,再用预占明细记录每个 SKU 的数量:

还需要设置一个过期字段,再加上扫描主动过期,防止库存被无限占用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
CREATE TABLE ims_reservation (
    reservation_id VARCHAR(64) PRIMARY KEY,
    order_id BIGINT NOT NULL,
    request_hash CHAR(64),
    state VARCHAR(16) NOT NULL,
    expires_at DATETIME(3) NULL COMMENT '预占过期时间',
    created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    UNIQUE KEY uk_reservation_order(order_id)
) ENGINE=InnoDB;

CREATE TABLE ims_reservation_item (
    reservation_id VARCHAR(64) NOT NULL,
    sku_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    PRIMARY KEY(reservation_id, sku_id)
) ENGINE=InnoDB;

reservation_id 由订单服务生成。同一个业务请求重试时必须继续使用原 ID,不能每次重试都生成新值。order_id 上的唯一索引进一步限制一个订单只能对应一张预占单。

request_hash 是发现幂等键被误用。服务先合并重复 SKU、按 sku_id 排序,再对订单号和明细计算 SHA-256。同一个 reservation_id 如果带着不同商品或数量再次请求,服务会返回参数冲突,而不是把它当成正常重试。

两张预占表属于同一个库存库时可以加外键,但在分库、归档或高并发写入场景中,外键也会增加迁移和写入约束。

库存预占与释放

预占单有状态迁移:

操作允许的当前状态结果库存变化
reserveINITRESERVEDlock_stock += quantity
重复 reserveRESERVED返回 RESERVED无
confirmRESERVEDCONFIRMEDstock -= quantity,lock_stock -= quantity
重复 confirmCONFIRMED返回 CONFIRMED无
releaseRESERVEDRELEASEDlock_stock -= quantity
重复 releaseRELEASED返回 RELEASED无
INIT 是事务内部的中间状态。正常情况下,事务提交后外部只能看到 RESERVED;任一 SKU 库存不足时,整个事务回滚,已经增加的其他 SKU 锁定量和预占明细也会一起撤销。

confirm 和 release 都先执行:

1
2
3
4
SELECT order_id, request_hash, state
FROM ims_reservation
WHERE reservation_id = ?
FOR UPDATE;

FOR UPDATE把同一张预占单上的一行加悲观锁。支付确认和取消同时到达时,只有先拿到行锁的一方能完成终态迁移,另一方会看到新的状态并停止修改库存。

当支付和超时释放请求同时到达,如果业务要求按支付发生时间裁决,就要引入支付时间或订单状态作为额外依据,不能只依赖任务执行先后。

超时库存释放

预占成功时写入 expires_at,后端定时任务扫描:

1
2
3
4
5
6
SELECT reservation_id, order_id
FROM ims_reservation
WHERE state = 'RESERVED'
  AND expires_at <= NOW(3)
ORDER BY expires_at
LIMIT 100;

任务要逐条调用同一个 release 方法。这样人工取消、订单超时和任务重试共用一套幂等逻辑。多个库存服务实例可能同时扫到同一张预占单,但 SELECT ... FOR UPDATE 会将释放串行化,后到的实例只会读到 RELEASED。

定时任务需要从另一个 Spring Bean 调用 ReservationService.release,让调用经过 Spring 事务代理;不要在同一个类里用 this.release(...) 自调用。项目还需要启用 @EnableScheduling。

扫描任务解决的是库存服务内部的兜底释放。订单服务仍应在订单取消或支付超时时主动发送释放命令,定时扫描只负责处理漏发、延迟和服务故障留下的预占。

扫描时间要根据支付时限和数据库负载调整。

代码示例

预占库存

INSERT ... ON DUPLICATE KEY UPDATE 让重复请求失败,随后再锁定并检查已存在的预占单。多 SKU 请求使用 TreeMap 按 sku_id 排序,让不同事务尽量以相同顺序锁库存行,从而降低死锁概率。死锁仍可能由其他更新路径引入,生产环境还需要对数据库死锁异常做有限次数重试。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Transactional
public String reserve(String reservationId, long orderId, List<Line> lines) {
    validateIdentity(reservationId, orderId);
    Map<Long, Integer> quantities = normalize(lines);
    String hash = fingerprint(orderId, quantities);

    jdbc.update("""
            INSERT INTO ims_reservation(
                reservation_id, order_id, request_hash, state, expires_at
            )
            VALUES (?, ?, ?, 'INIT', TIMESTAMPADD(SECOND, ?, NOW(3)))
            ON DUPLICATE KEY UPDATE reservation_id = reservation_id
            """, reservationId, orderId, hash, reservationTtlSeconds);

    Header header = lockedHeader(reservationId, orderId);
    if ("RELEASED".equals(header.state())) {
        throw new ReservationStateException("RESERVATION_ALREADY_RELEASED");
    }
    if (!hash.equals(header.hash())) {
        throw new IllegalArgumentException("IDEMPOTENCY_PARAMETER_CONFLICT");
    }
    if (!"INIT".equals(header.state())) {
        return header.state();
    }

    for (Map.Entry<Long, Integer> entry : quantities.entrySet()) {
        int quantity = entry.getValue();
        int updated = jdbc.update("""
                UPDATE pms_sku_stock
                SET lock_stock = lock_stock + ?
                WHERE id = ? AND stock - lock_stock >= ?
                """, quantity, entry.getKey(), quantity);
        if (updated != 1) {
            throw new StockUnavailableException(entry.getKey());
        }

        jdbc.update("""
                INSERT INTO ims_reservation_item(reservation_id, sku_id, quantity)
                VALUES (?, ?, ?)
                """, reservationId, entry.getKey(), quantity);
    }

    jdbc.update("""
            UPDATE ims_reservation
            SET state = 'RESERVED', updated_at = NOW(3)
            WHERE reservation_id = ? AND state = 'INIT'
            """, reservationId);
    return "RESERVED";
}

支付后确认

确认时同时减少 stock 和 lock_stock。例如确认前 stock = 10、lock_stock = 2,可售库存是 8;确认两件后变为 stock = 8、lock_stock = 0,可售库存仍是 8。支付只是把两件商品从RESERVED转成CONFIRMED,不会再次占用可售库存。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Transactional
public String confirm(String reservationId, long orderId) {
    Header header = lockedHeader(reservationId, orderId);
    if ("CONFIRMED".equals(header.state())) {
        return "CONFIRMED";
    }
    if (!"RESERVED".equals(header.state())) {
        throw new ReservationStateException("RESERVATION_NOT_RESERVED");
    }

    for (Line line : items(reservationId)) {
        int updated = jdbc.update("""
                UPDATE pms_sku_stock
                SET stock = stock - ?, lock_stock = lock_stock - ?
                WHERE id = ? AND stock >= ? AND lock_stock >= ?
                """, line.quantity(), line.quantity(), line.skuId(),
                line.quantity(), line.quantity());
        if (updated != 1) {
            throw new IllegalStateException("INVENTORY_INVARIANT_BROKEN");
        }
    }

    jdbc.update("""
            UPDATE ims_reservation
            SET state = 'CONFIRMED', updated_at = NOW(3)
            WHERE reservation_id = ? AND state = 'RESERVED'
            """, reservationId);
    return "CONFIRMED";
}

取消后释放

释放数量来自 ims_reservation_item,而不是取消请求传入的数量。客户端因此无法多释放库存,同一取消请求重复执行也不会再次减少 lock_stock。

方法开头插入 RELEASED 还有一个用途:处理消息乱序。如果取消消息先到、预占消息后到,取消请求会先写入一张没有明细的 RELEASED 记录。迟到的 reserve 看到该状态后直接拒绝,不会在订单已经取消后重新锁库存。

这段逻辑只处理取消和预占之间的乱序。confirm 早于 reserve 时会被拒绝,因为没有预占明细可供确认。订单服务应在确认预占成功后才允许进入支付流程,并让同一 reservation_id 的命令按顺序投递。如果消息通道不能保证顺序,确认命令需要在预占完成后重试,不能跳过状态检查直接扣库存。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Transactional
public String release(String reservationId, long orderId) {
    jdbc.update("""
            INSERT INTO ims_reservation(
                reservation_id, order_id, request_hash, state, expires_at
            )
            VALUES (?, ?, NULL, 'RELEASED', NULL)
            ON DUPLICATE KEY UPDATE reservation_id = reservation_id
            """, reservationId, orderId);

    Header header = lockedHeader(reservationId, orderId);
    if ("RELEASED".equals(header.state())) {
        return "RELEASED";
    }
    if (!"RESERVED".equals(header.state())) {
        throw new ReservationStateException("RESERVATION_CANNOT_RELEASE");
    }

    for (Line line : items(reservationId)) {
        int updated = jdbc.update("""
                UPDATE pms_sku_stock
                SET lock_stock = lock_stock - ?
                WHERE id = ? AND lock_stock >= ?
                """, line.quantity(), line.skuId(), line.quantity());
        if (updated != 1) {
            throw new IllegalStateException("INVENTORY_INVARIANT_BROKEN");
        }
    }

    jdbc.update("""
            UPDATE ims_reservation
            SET state = 'RELEASED', updated_at = NOW(3)
            WHERE reservation_id = ? AND state = 'RESERVED'
            """, reservationId);
    return "RELEASED";
}

超时库存释放

示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public List<ExpiredReservation> findExpired(int limit) {
	if (limit <= 0 || limit > 1000) {
		throw new IllegalArgumentException("INVALID_BATCH_SIZE");
	}
	return jdbc.query("""
			SELECT reservation_id, order_id
			FROM ims_reservation
			WHERE state = 'RESERVED' AND expires_at <= NOW(3)
			ORDER BY expires_at
			LIMIT ?
			""", (rs, rowNum) -> new ExpiredReservation(
			rs.getString("reservation_id"), rs.getLong("order_id")), limit);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Component
public class ReservationExpiryJob {
    private static final Logger LOGGER = LoggerFactory.getLogger(ReservationExpiryJob.class);

    private final ReservationService reservationService;
    private final int batchSize;

    public ReservationExpiryJob(
            ReservationService reservationService,
            @Value("${inventory.reservation-expiry-batch-size:100}") int batchSize) {
        this.reservationService = reservationService;
        this.batchSize = batchSize;
    }

    @Scheduled(fixedDelayString = "${inventory.reservation-expiry-scan-ms:5000}")
    public void releaseExpiredReservations() {
        for (ExpiredReservation reservation : reservationService.findExpired(batchSize)) {
            try {
                // release 通过 Spring 代理进入独立事务,多实例重复扫描也不会重复释放。
                reservationService.release(
                        reservation.reservationId(), reservation.orderId());
            } catch (IllegalStateException ex) {
                // 支付确认可能与超时释放同时发生,最终状态由预占单行锁串行决定。
                LOGGER.info("Skip expired reservation {}, reason={}",
                        reservation.reservationId(), ex.getMessage());
            } catch (RuntimeException ex) {
                LOGGER.warn("Failed to release expired reservation {}",
                        reservation.reservationId(), ex);
            }
        }
    }
}

超时重试可靠性

微服务调用会出现响应超时,调用方如果没收到成功信息,不知道库存服务有没有提交事务。

有了幂等设计,也就是reservation_id,订单服务可以直接重试。

  1. 第一次请求没提交,重试正常预占
  2. 第一次已经提交没收到成功消息,重试发现RESERVED,直接返回当前状态
  3. 同一个 ID 被错误用到另一组商品,request_hash不一致返回冲突
  4. 订单已经取消,重试读到RELEASED,不会重新预占

确认和释放逻辑相同,调用方如果超时,继续用reservation_id重试,库存变化最多发生一次。

没有解决分布式事务

@Transactional 只覆盖库存服务自己的数据库,不能同时回滚订单库。以下故障仍然可能发生:

  • 库存预占成功,订单服务在记录结果前挂了
  • 订单取消成功,释放请求没有发出去
  • 支付成功,确认库存的消息重复或延迟到达

一种常见做法是让订单服务在本地事务中同时写订单和 outbox 事件,再由后台任务可靠投递。库存服务按至少一次语义消费命令,并依靠 reservation_id 去重。库存服务处理完成后也可以用 outbox 发布预占结果,订单服务再幂等更新订单状态。

如果仍使用同步 HTTP,原则也不变:订单先落库,再调用库存;遇到超时就使用相同幂等键重试。预占成功但订单流程没有继续时,由主动补偿和 expires_at 兜底释放。

只有在调用方可靠地驱动状态变化、失败命令可以持续重试时,完整业务流程才真正闭环。库存库里的一个本地事务不能代替跨服务一致性方案。

行锁性能瓶颈

条件更新保证了正确性,也意味着热门 SKU 的请求会竞争同一行锁。数据库连接数、事务耗时、磁盘 IO 和锁等待都会限制吞吐量。这个问题不能靠优化 SQL 来消除。

不同的业务可以采用不同的方案。

普通商品场景通常先采用数据库条件更新,因为实现简单、数据源单一,也容易核对。秒杀场景可以考虑请求排队、Redis Lua 原子预扣、库存分段或令牌化,但这些方案会引入缓存和数据库之间的一致性、补偿及对账问题。优化前应先压测确认瓶颈,不能用一套尚未闭环的双写方案替换已经正确的数据库事务。

无论是否引入缓存,预占身份、状态机和幂等语义仍然需要保留。Redis 可以改变竞争发生的位置,不能替代“这批库存属于哪个订单”的业务记录。


先把数据库的库存,预占对账做得可靠,后续的消息可靠投递,缓存预扣和秒杀优化才有可核对和补偿的基础。

Page views — Unique visitors —
Built with Hugo
Theme Stack designed by Jimmy