如何设计库存系统
一、问题描述
1.1 业务背景
库存系统是电商的核心系统,直接关系到交易的准确性和用户体验。
典型场景:
- 电商库存:淘宝、京东商品库存
- 秒杀库存:限量商品抢购
- 票务库存:电影票、演唱会门票
- 酒店库存:房间预订
核心挑战:防止超卖
1.2 核心功能
基础功能:
- 库存查询:查询商品库存数量
- 库存扣减:下单时扣减库存
- 库存释放:取消订单时恢复库存
- 库存预警:库存不足时告警
进阶功能:
- 预扣库存:下单时先预扣,支付后确认
- 库存锁定:限时锁定库存(30分钟)
- 库存回补:定时任务恢复超时未支付的库存
1.3 技术挑战
防超卖:
库存:100件
并发下单:1000人
如何保证只有100人成功?数据一致性:
Redis扣减成功,MySQL写入失败 → 数据不一致
MySQL扣减成功,Redis未同步 → 缓存穿透高并发:
双十一:每秒10万订单
如何快速扣减库存?1.4 面试考察点
- 防超卖方案:行锁、Redis原子、分布式锁
- 库存状态机:预扣、锁定、确认、释放
- 数据一致性:Redis和MySQL同步
- 性能优化:异步扣减、分库分表
二、库存状态机
2.1 库存状态
mermaid
stateDiagram-v2
[*] --> 可用库存
可用库存 --> 预扣库存: 下单
预扣库存 --> 锁定库存: 创建订单
锁定库存 --> 已售库存: 支付成功
锁定库存 --> 可用库存: 取消/超时
预扣库存 --> 可用库存: 下单失败库存字段设计:
go
type Inventory struct {
ProductID int64 // 商品ID
TotalStock int // 总库存
AvailableStock int // 可用库存
PreStock int // 预扣库存
LockedStock int // 锁定库存
SoldStock int // 已售库存
}
// 恒等式
TotalStock = AvailableStock + PreStock + LockedStock + SoldStock2.2 状态转换
1. 预扣库存(下单):
sql
UPDATE inventory
SET available_stock = available_stock - 1,
pre_stock = pre_stock + 1
WHERE product_id = ? AND available_stock >= 1;2. 锁定库存(创建订单):
sql
UPDATE inventory
SET pre_stock = pre_stock - 1,
locked_stock = locked_stock + 1
WHERE product_id = ?;3. 确认扣减(支付成功):
sql
UPDATE inventory
SET locked_stock = locked_stock - 1,
sold_stock = sold_stock + 1
WHERE product_id = ?;4. 释放库存(取消/超时):
sql
UPDATE inventory
SET locked_stock = locked_stock - 1,
available_stock = available_stock + 1
WHERE product_id = ?;三、防超卖方案
3.1 方案对比
| 方案 | 原理 | 性能 | 可靠性 | 复杂度 | 推荐 |
|---|---|---|---|---|---|
| 数据库行锁 | SELECT FOR UPDATE | 低 | 高 | 低 | ⭐⭐ |
| 乐观锁 | 版本号 | 中 | 中 | 中 | ⭐⭐⭐ |
| Redis原子 | DECR/Lua | 高 | 高 | 中 | ⭐⭐⭐⭐⭐ |
| 分布式锁 | Redis/ZK锁 | 中 | 高 | 高 | ⭐⭐⭐⭐ |
3.2 数据库行锁(方案1)
原理:使用SELECT ... FOR UPDATE锁行
go
func (s *InventoryService) DeductByRowLock(productID int64, quantity int) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 1. 锁定行
var inv Inventory
err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("product_id = ?", productID).
First(&inv).Error
if err != nil {
return err
}
// 2. 检查库存
if inv.AvailableStock < quantity {
return errors.New("库存不足")
}
// 3. 扣减库存
err = tx.Model(&inv).Updates(map[string]interface{}{
"available_stock": gorm.Expr("available_stock - ?", quantity),
"sold_stock": gorm.Expr("sold_stock + ?", quantity),
}).Error
return err
})
}优点:
- ✅ 可靠:数据库事务ACID保证
- ✅ 简单:代码实现简单
缺点:
- ❌ 性能差:锁等待,并发低
- ❌ 死锁风险:多商品同时购买
3.3 Redis原子操作(方案2,推荐)
原理:使用Redis DECR/Lua脚本保证原子性
Lua脚本:
lua
-- KEYS[1]: inventory:{product_id}
-- ARGV[1]: quantity
local stock = redis.call('GET', KEYS[1])
if not stock then
return -1 -- 库存不存在
end
stock = tonumber(stock)
local quantity = tonumber(ARGV[1])
if stock < quantity then
return -2 -- 库存不足
end
redis.call('DECRBY', KEYS[1], quantity)
return stock - quantity -- 返回剩余库存Go实现:
go
func (s *InventoryService) DeductByRedis(productID int64, quantity int) error {
script := `
local stock = redis.call('GET', KEYS[1])
if not stock then return -1 end
stock = tonumber(stock)
if stock < tonumber(ARGV[1]) then return -2 end
redis.call('DECRBY', KEYS[1], ARGV[1])
return stock - tonumber(ARGV[1])
`
key := fmt.Sprintf("inventory:%d", productID)
result, err := s.redis.Eval(ctx, script, []string{key}, quantity).Result()
if err != nil {
return err
}
remaining := result.(int64)
if remaining == -1 {
return errors.New("库存不存在")
}
if remaining == -2 {
return errors.New("库存不足")
}
// 异步写MySQL
s.asyncUpdateDB(productID, quantity)
return nil
}优点:
- ✅ 性能高:Redis内存操作,毫秒级
- ✅ 原子性:Lua脚本保证
- ✅ 高并发:支持万级QPS
缺点:
- ❌ 需要Redis和MySQL数据一致性保证
四、数据一致性
4.1 问题
场景1:Redis扣减成功,MySQL写入失败
Redis: 99
MySQL: 100
结果:少卖了1件(丢失订单)场景2:MySQL扣减成功,Redis未同步
MySQL: 99
Redis: 100
结果:可能超卖4.2 解决方案
方案1:延迟双删
go
func (s *InventoryService) DeductWithDoubleDelete(productID int64, quantity int) error {
// 1. 删除Redis缓存
key := fmt.Sprintf("inventory:%d", productID)
s.redis.Del(ctx, key)
// 2. 更新MySQL
err := s.db.Model(&Inventory{}).
Where("product_id = ? AND available_stock >= ?", productID, quantity).
Updates(map[string]interface{}{
"available_stock": gorm.Expr("available_stock - ?", quantity),
"sold_stock": gorm.Expr("sold_stock + ?", quantity),
}).Error
if err != nil {
return err
}
// 3. 延迟删除Redis(500ms后)
time.AfterFunc(500*time.Millisecond, func() {
s.redis.Del(context.Background(), key)
})
return nil
}方案2:Canal实时同步
MySQL Binlog → Canal → 解析 → 更新Redis优点:
- ✅ 实时性高
- ✅ 解耦业务代码
方案3:定时对账
go
// 每小时对账一次
func (s *InventoryService) Reconcile() error {
// 1. 查询所有商品
var products []int64
s.db.Model(&Inventory{}).Pluck("product_id", &products)
for _, pid := range products {
// 2. 从MySQL获取库存
var inv Inventory
s.db.Where("product_id = ?", pid).First(&inv)
// 3. 从Redis获取库存
key := fmt.Sprintf("inventory:%d", pid)
redisStock, _ := s.redis.Get(ctx, key).Int()
// 4. 对比差异
if inv.AvailableStock != redisStock {
// 以MySQL为准,更新Redis
s.redis.Set(ctx, key, inv.AvailableStock, 0)
// 告警
log.Printf("库存不一致 productID=%d mysql=%d redis=%d",
pid, inv.AvailableStock, redisStock)
}
}
return nil
}五、核心实现
5.1 Go实现
点击查看完整实现
go
package inventory
import (
"context"
"fmt"
"time"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
)
type InventoryService struct {
db *gorm.DB
redis *redis.Client
}
type Inventory struct {
ProductID int64 `gorm:"primary_key"`
TotalStock int `gorm:"column:total_stock"`
AvailableStock int `gorm:"column:available_stock"`
PreStock int `gorm:"column:pre_stock"`
LockedStock int `gorm:"column:locked_stock"`
SoldStock int `gorm:"column:sold_stock"`
}
type Order struct {
ID int64
ProductID int64
Quantity int
Status int8 // 1待支付 2已支付 3已取消
ExpireTime time.Time
}
// PreDeduct 预扣库存
func (s *InventoryService) PreDeduct(ctx context.Context, productID int64, quantity int) error {
// 1. Redis预扣(快速失败)
script := `
local key = KEYS[1]
local quantity = tonumber(ARGV[1])
local stock = tonumber(redis.call('GET', key) or 0)
if stock < quantity then
return 0 -- 库存不足
end
redis.call('DECRBY', key, quantity)
return 1 -- 成功
`
key := fmt.Sprintf("inventory:available:%d", productID)
result, err := s.redis.Eval(ctx, script, []string{key}, quantity).Int()
if err != nil {
return err
}
if result == 0 {
return fmt.Errorf("库存不足")
}
// 2. MySQL预扣
affected := s.db.Model(&Inventory{}).
Where("product_id = ? AND available_stock >= ?", productID, quantity).
Updates(map[string]interface{}{
"available_stock": gorm.Expr("available_stock - ?", quantity),
"pre_stock": gorm.Expr("pre_stock + ?", quantity),
}).RowsAffected
if affected == 0 {
// 回滚Redis
s.redis.IncrBy(ctx, key, int64(quantity))
return fmt.Errorf("数据库扣减失败")
}
return nil
}
// Lock 锁定库存(创建订单成功后)
func (s *InventoryService) Lock(ctx context.Context, orderID, productID int64, quantity int) error {
// MySQL状态转换:pre_stock -> locked_stock
return s.db.Model(&Inventory{}).
Where("product_id = ?", productID).
Updates(map[string]interface{}{
"pre_stock": gorm.Expr("pre_stock - ?", quantity),
"locked_stock": gorm.Expr("locked_stock + ?", quantity),
}).Error
}
// Confirm 确认扣减(支付成功后)
func (s *InventoryService) Confirm(ctx context.Context, orderID, productID int64, quantity int) error {
// MySQL状态转换:locked_stock -> sold_stock
return s.db.Model(&Inventory{}).
Where("product_id = ?", productID).
Updates(map[string]interface{}{
"locked_stock": gorm.Expr("locked_stock - ?", quantity),
"sold_stock": gorm.Expr("sold_stock + ?", quantity),
}).Error
}
// Release 释放库存(取消订单/超时)
func (s *InventoryService) Release(ctx context.Context, orderID, productID int64, quantity int) error {
// 1. MySQL状态转换:locked_stock -> available_stock
err := s.db.Model(&Inventory{}).
Where("product_id = ?", productID).
Updates(map[string]interface{}{
"locked_stock": gorm.Expr("locked_stock - ?", quantity),
"available_stock": gorm.Expr("available_stock + ?", quantity),
}).Error
if err != nil {
return err
}
// 2. Redis回补
key := fmt.Sprintf("inventory:available:%d", productID)
s.redis.IncrBy(ctx, key, int64(quantity))
return nil
}
// CheckExpiredOrders 检查超时订单(定时任务)
func (s *InventoryService) CheckExpiredOrders(ctx context.Context) error {
// 查询超时未支付订单
var orders []Order
s.db.Where("status = ? AND expire_time < ?", 1, time.Now()).
Find(&orders)
for _, order := range orders {
// 释放库存
err := s.Release(ctx, order.ID, order.ProductID, order.Quantity)
if err != nil {
continue
}
// 更新订单状态为已取消
s.db.Model(&order).Update("status", 3)
}
return nil
}
// LoadToRedis 库存预热(活动前)
func (s *InventoryService) LoadToRedis(ctx context.Context, productIDs []int64) error {
for _, pid := range productIDs {
var inv Inventory
err := s.db.Where("product_id = ?", pid).First(&inv).Error
if err != nil {
continue
}
// 写入Redis
key := fmt.Sprintf("inventory:available:%d", pid)
s.redis.Set(ctx, key, inv.AvailableStock, 0)
}
return nil
}5.2 Java实现
java
@Service
public class InventoryService {
@Autowired
private InventoryMapper inventoryMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 预扣库存
*/
@Transactional
public void preDeduct(Long productId, Integer quantity) {
// 1. Redis预扣
String script =
"local stock = tonumber(redis.call('GET', KEYS[1]) or 0) " +
"if stock < tonumber(ARGV[1]) then return 0 end " +
"redis.call('DECRBY', KEYS[1], ARGV[1]) " +
"return 1";
String key = "inventory:available:" + productId;
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList(key),
quantity
);
if (result == 0) {
throw new BusinessException("库存不足");
}
// 2. MySQL预扣
int affected = inventoryMapper.preDeduct(productId, quantity);
if (affected == 0) {
// 回滚Redis
redisTemplate.opsForValue().increment(key, quantity);
throw new BusinessException("数据库扣减失败");
}
}
/**
* 锁定库存
*/
@Transactional
public void lock(Long orderId, Long productId, Integer quantity) {
inventoryMapper.lockStock(productId, quantity);
}
/**
* 确认扣减
*/
@Transactional
public void confirm(Long orderId, Long productId, Integer quantity) {
inventoryMapper.confirmDeduct(productId, quantity);
}
/**
* 释放库存
*/
@Transactional
public void release(Long orderId, Long productId, Integer quantity) {
// MySQL释放
inventoryMapper.releaseStock(productId, quantity);
// Redis回补
String key = "inventory:available:" + productId;
redisTemplate.opsForValue().increment(key, quantity);
}
/**
* 检查超时订单
*/
@Scheduled(cron = "0 */5 * * * ?") // 每5分钟执行
public void checkExpiredOrders() {
List<Order> orders = orderMapper.findExpiredOrders();
for (Order order : orders) {
try {
release(order.getId(), order.getProductId(), order.getQuantity());
orderMapper.updateStatus(order.getId(), 3); // 取消
} catch (Exception e) {
log.error("释放库存失败", e);
}
}
}
}六、性能优化
6.1 异步扣减
go
// Redis同步扣减 + MySQL异步写入
func (s *InventoryService) DeductAsync(productID int64, quantity int) error {
// 1. Redis扣减(同步)
err := s.deductRedis(productID, quantity)
if err != nil {
return err
}
// 2. 发送到Kafka(异步)
msg := InventoryMessage{
ProductID: productID,
Quantity: quantity,
Action: "deduct",
}
s.kafka.Send("inventory_topic", msg)
return nil
}
// Kafka Consumer
func (s *InventoryService) ConsumeInventoryMessage(msg InventoryMessage) {
// 异步写MySQL
s.db.Model(&Inventory{}).
Where("product_id = ?", msg.ProductID).
Update("sold_stock", gorm.Expr("sold_stock + ?", msg.Quantity))
}6.2 分库分表
sql
-- 按商品ID分表(256张表)
CREATE TABLE inventory_0 LIKE inventory;
CREATE TABLE inventory_1 LIKE inventory;
...
CREATE TABLE inventory_255 LIKE inventory;
-- 路由规则
table_index = product_id % 2566.3 性能数据
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 扣减响应时间 | 100ms | 5ms | 20x |
| 支持QPS | 500 | 1万 | 20x |
| 数据一致性 | 95% | 99.9% | - |
七、面试要点
7.1 常见追问
Q1: 如何防止超卖?
A: 三种方案:
- 数据库行锁:
SELECT FOR UPDATE - Redis原子操作:Lua脚本(推荐)
- 分布式锁:Redis/Zookeeper锁
Q2: Redis和MySQL数据如何一致?
A:
- 延迟双删:更新MySQL前后删除缓存
- Canal同步:监听binlog实时同步
- 定时对账:每小时对账一次
Q3: 库存状态机如何设计?
A: 四个状态:
- 可用库存 → 预扣库存(下单)
- 预扣库存 → 锁定库存(创建订单)
- 锁定库存 → 已售库存(支付成功)
- 锁定库存 → 可用库存(取消/超时)
Q4: 如何处理超时未支付订单?
A: 定时任务(每5分钟):
- 查询超时订单
- 释放锁定库存
- 更新订单状态为已取消
7.2 扩展知识点
相关场景题:
八、总结
库存系统设计要点:
- 防超卖:Redis Lua脚本(推荐)
- 状态机:预扣→锁定→确认/释放
- 数据一致性:Canal同步 + 定时对账
- 性能优化:异步写入 + 分库分表
面试重点:
- 能画出库存状态机图
- 能说出3种防超卖方案及对比
- 能解释Redis和MySQL一致性保证
- 能设计超时订单处理机制
参考资料:
- 淘宝库存架构演进
- 京东库存系统设计
- 《大型网站技术架构》
