Skip to content

如何设计库存系统

一、问题描述

1.1 业务背景

库存系统是电商的核心系统,直接关系到交易的准确性和用户体验。

典型场景

  • 电商库存:淘宝、京东商品库存
  • 秒杀库存:限量商品抢购
  • 票务库存:电影票、演唱会门票
  • 酒店库存:房间预订

核心挑战:防止超卖

1.2 核心功能

基础功能

  1. 库存查询:查询商品库存数量
  2. 库存扣减:下单时扣减库存
  3. 库存释放:取消订单时恢复库存
  4. 库存预警:库存不足时告警

进阶功能

  1. 预扣库存:下单时先预扣,支付后确认
  2. 库存锁定:限时锁定库存(30分钟)
  3. 库存回补:定时任务恢复超时未支付的库存

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 + SoldStock

2.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 % 256

6.3 性能数据

指标优化前优化后提升
扣减响应时间100ms5ms20x
支持QPS5001万20x
数据一致性95%99.9%-

七、面试要点

7.1 常见追问

Q1: 如何防止超卖?

A: 三种方案:

  1. 数据库行锁SELECT FOR UPDATE
  2. Redis原子操作:Lua脚本(推荐)
  3. 分布式锁:Redis/Zookeeper锁

Q2: Redis和MySQL数据如何一致?

A:

  1. 延迟双删:更新MySQL前后删除缓存
  2. Canal同步:监听binlog实时同步
  3. 定时对账:每小时对账一次

Q3: 库存状态机如何设计?

A: 四个状态:

  • 可用库存 → 预扣库存(下单)
  • 预扣库存 → 锁定库存(创建订单)
  • 锁定库存 → 已售库存(支付成功)
  • 锁定库存 → 可用库存(取消/超时)

Q4: 如何处理超时未支付订单?

A: 定时任务(每5分钟):

  1. 查询超时订单
  2. 释放锁定库存
  3. 更新订单状态为已取消

7.2 扩展知识点

相关场景题

八、总结

库存系统设计要点:

  1. 防超卖:Redis Lua脚本(推荐)
  2. 状态机:预扣→锁定→确认/释放
  3. 数据一致性:Canal同步 + 定时对账
  4. 性能优化:异步写入 + 分库分表

面试重点

  • 能画出库存状态机图
  • 能说出3种防超卖方案及对比
  • 能解释Redis和MySQL一致性保证
  • 能设计超时订单处理机制

参考资料

  • 淘宝库存架构演进
  • 京东库存系统设计
  • 《大型网站技术架构》

正在精进