商品库存管理是电商系统的核心模块,MySQL作为最常用的事务型数据库,在库存表设计上既要保证数据准确,又要应对高并发扣减。很多看似简单的库存表在真实流量下会暴露出超卖、死锁、热点更新等问题。本文从底层存储结构、并发控制手段以及一致性保障三个角度,详细说明如何设计一套健壮的商品库存管理表。

一、基础表结构与字段选型
最基础的库存表至少应包含商品标识、可用库存、锁定库存和版本号。商品标识建议使用 BIGINT 类型存储商品ID,避免后期分库分表时长度不足。可用库存与锁定库存分离是常见的做法:可用库存表示还能对外售卖的数量,锁定库存表示已下单但未支付占用的数量。这样在用户下单时只操作锁定库存,支付成功后再扣减可用库存,能有效支持取消订单回滚。
版本号字段(version)用于乐观锁控制,每次更新库存时校验版本,避免丢失更新。除此之外,建议增加 warehouse_id 支持多仓库存,以及 last_update_time 便于排查数据漂移。建表时必须为商品ID建立唯一索引,这是防止重复创建库存记录的第一道防线。下面的示例展示了基础表结构:
CREATE TABLE `product_stock` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `product_id` BIGINT NOT NULL COMMENT '商品ID', `warehouse_id` INT NOT NULL DEFAULT 1 COMMENT '仓库ID', `available_num` INT NOT NULL DEFAULT 0 COMMENT '可用库存', `locked_num` INT NOT NULL DEFAULT 0 COMMENT '锁定库存', `version` INT NOT NULL DEFAULT 0 COMMENT '版本号', `last_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_product_warehouse` (`product_id`, `warehouse_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
上述结构中,uk_product_warehouse 唯一索引保证了同一个商品在同一个仓库只有一条记录。InnoDB 引擎下的聚簇索引让按主键更新效率最高,而唯一索引的隐式行锁能在高并发插入时避免脏数据。如果业务只需要单仓库,可以去掉 warehouse_id,但唯一索引依然要保留在 product_id 上。
二、高并发扣减与超卖防护方案
超卖的本质是多个事务同时读取到相同的库存值,然后都判断有余量并写入,导致实际扣减超过真实库存。最常见防护手段是数据库层面的原子更新:将判断与扣减写在一条 UPDATE 语句中,利用 MySQL 的行锁让并发串行化。例如扣减可用库存时,使用 UPDATE product_stock SET available_num = available_num - 1 WHERE product_id = ? AND available_num >= 1,这条语句在 InnoDB 中会对匹配行加排他锁,其他事务必须等待。
当流量极高时,单行热点会成为瓶颈。此时可以引入库存分桶设计,把一件商品的库存拆成多个子记录,比如 product_id 为 100 的商品拆成 10 个 bucket,每个 bucket 存一部分库存。扣减时通过取模或随机选桶,将写压力分散到不同行,显著降低锁冲突。分桶表结构与基础表类似,只是增加 bucket_no 字段并纳入唯一索引。代码层先随机选桶,扣减失败再尝试其他桶,逻辑如下:
int bucketCount = 10;
int tryBucket = new Random().nextInt(bucketCount);
for (int i = 0; i < bucketCount; i++) {
int bucket = (tryBucket + i) % bucketCount;
int rows = jdbcTemplate.update(
"UPDATE product_stock_bucket SET available_num = available_num - ? " +
"WHERE product_id = ? AND bucket_no = ? AND available_num >= ?",
count, productId, bucket, count);
if (rows > 0) {
return true;
}
}
return false;
除了分桶,还可以结合 Redis 做前置库存计数器,在 Redis 中扣减成功后再异步落库,但这种方式会带来缓存与数据库一致性问题。如果坚持使用纯 MySQL 方案,乐观锁版本号适合冲突较少的场景,而 WHERE 条件原子更新适合冲突密集的场景。在秒杀系统中,建议优先采用 WHERE 原子更新加分桶,并在应用层做限流,避免数据库连接被耗尽。
三、事务一致性与对账补偿机制
库存数据往往要和订单、支付系统联动,单靠一张表无法保证跨系统一致。通常采用本地消息表或事务消息,在扣减库存的事务中同时写入一条出库消息,由下游异步消费。若支付超时,需要通过定时任务将锁定库存回滚到可用库存。回滚操作同样要使用原子更新,防止并发回滚导致可用库存异常增大。
日常运行中,库存记录可能因bug或手动操作出现偏差,因此必须建立对账机制。可以每天低峰期用商品维度的真实订单汇总数量对比库存表的(初始库存 - 可用 - 锁定),不一致则告警并触发修复脚本。修复时禁止直接 SET 赋值,而应计算差值用增减语句处理,保留操作日志。如下是一个简单的对账校验查询:
SELECT s.product_id,
s.available_num,
s.locked_num,
(s.available_num + s.locked_num) AS remain,
i.init_num
FROM product_stock s
JOIN product_stock_init i ON s.product_id = i.product_id
WHERE (s.available_num + s.locked_num) <> i.init_num;
对于多机房部署,MySQL 主从延迟可能造成读库看到旧库存,因此扣减前务必读主库或使用读写分离Hint。在极端情况下,可引入影子库存表记录每一次变更流水,便于追溯和复盘。只有将表结构、并发控制与补偿对账三者结合,才能真正构建出不怕超卖、能扛并发的库存管理体系。