Files
interview/questions/07-系统设计/限购库存系统设计.md

944 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 限购库存系统设计
## 题目描述
设计一个限购库存系统,用于处理商品秒杀、限购等场景。
**业务场景**
- 某商品库存有限如1000件
- 用户抢购热情高涨,短时间内大量请求
- 每个用户有购买数量限制如每人最多买3件
- 需要防止超卖、刷单、恶意攻击
**核心需求**
1. 保证库存准确性,不能超卖
2. 支持高并发QPS 达到 10万+
3. 防止用户刷单、恶意抢购
4. 保证系统可用性和数据一致性
5. 提供良好的用户体验
**问题**
1. 如何设计库存扣减方案?
2. 如何防止超卖?
3. 如何处理高并发?
4. 如何防止刷单?
5. 如何保证数据一致性?
---
## 思路推导
### 问题分析
**核心挑战**
1. **超卖问题**库存1000件卖了1200件
2. **性能问题**10万QPS下系统不崩溃
3. **公平性问题**:防止机器刷单
4. **一致性问题**:分布式环境下的数据一致性
**为什么难?**
- 高并发 + 库存扣减 = 严重的锁竞争
- 分布式环境 + 数据一致性 = CAP权衡
- 用户体验 + 安全防护 = 难以平衡
### 为什么这样思考?
**核心原则****性能 > 一致性 > 用户体验**
就像演唱会抢票一样:
- 先到先得(性能优先)
- 不允许卖出比座位多的票(准确性)
- 防止黄牛刷票(安全性)
---
## 解题思路
### 核心思想
**多级缓存 + 异步扣减 + 分布式锁 + 限流防护**
```
┌─────────────────────────────────────────────────┐
│ 用户请求 │
└──────────────────┬──────────────────────────────┘
┌─────────────────┐
│ 1. 限流防护层 │ ← 防止刷单、恶意攻击
│ - 用户限流 │
│ - IP限流 │
│ - 验证码 │
└────────┬─────────┘
┌─────────────────┐
│ 2. 预热层 │ ← 减轻数据库压力
│ - Redis库存 │
│ - 本地缓存 │
└────────┬─────────┘
┌─────────────────┐
│ 3. 防刷层 │ ← 防止机器刷单
│ - 用户行为分析 │
│ - 风控规则 │
└────────┬─────────┘
┌─────────────────┐
│ 4. 库存扣减层 │ ← 核心逻辑
│ - Redis原子操作│
│ - 分布式锁 │
│ - 数据库乐观锁 │
└────────┬─────────┘
┌─────────────────┐
│ 5. 订单处理层 │ ← 异步处理
│ - 消息队列 │
│ - 订单落地 │
└─────────────────┘
```
---
## 详细方案
### 方案一Redis + Lua 脚本(推荐)
**核心思路**:利用 Redis 的原子性操作,保证库存扣减的准确性
#### 1.1 数据结构设计
```go
// 库存信息
type StockInfo struct {
ProductID string // 商品ID
TotalStock int64 // 总库存
AvailableStock int64 // 可用库存
SoldStock int64 // 已售库存
Price float64 // 价格
LimitPerUser int // 每人限购
}
// 用户购买记录
type UserPurchase struct {
UserID string
ProductID string
Quantity int
OrderTime time.Time
}
```
#### 1.2 Redis 数据结构
```redis
# 商品库存
product:{productID}:stock = 1000
# 用户购买数量
user:{userID}:product:{productID}:qty = 2
# 购买记录(用于防刷)
product:{productID}:purchase_set = {userID1, userID2, ...}
# 分布式锁
lock:product:{productID}
```
#### 1.3 Lua 脚本实现
```lua
-- 扣减库存的 Lua 脚本
local function deduct_stock(product_id, user_id, quantity)
-- 1. 获取商品库存
local stock_key = "product:" .. product_id .. ":stock"
local stock = tonumber(redis.call('GET', stock_key))
-- 2. 检查库存是否充足
if stock < quantity then
return {err = "insufficient_stock", remaining = stock}
end
-- 3. 获取用户已购买数量
local user_qty_key = "user:" .. user_id .. ":product:" .. product_id .. ":qty"
local user_qty = tonumber(redis.call('GET', user_qty_key)) or 0
-- 4. 检查是否超过限购
local limit_key = "product:" .. product_id .. ":limit"
local limit_per_user = tonumber(redis.call('GET', limit_key)) or 1
if user_qty + quantity > limit_per_user then
return {err = "exceed_limit", user_qty = user_qty, limit = limit_per_user}
end
-- 5. 扣减库存(原子操作)
redis.call('DECRBY', stock_key, quantity)
-- 6. 更新用户购买数量
redis.call('INCRBY', user_qty_key, quantity)
redis.call('EXPIRE', user_qty_key, 3600) -- 1小时过期
-- 7. 记录购买用户
local purchase_set = "product:" .. product_id .. ":purchase_set"
redis.call('SADD', purchase_set, user_id)
redis.call('EXPIRE', purchase_set, 86400) -- 24小时过期
return {ok = true, remaining = stock - quantity}
end
-- 执行脚本
return deduct_stock(KEYS[1], ARGV[1], ARGV[2])
```
#### 1.4 Go 实现
```go
type StockService struct {
redis *redis.Client
script *redis.Script
}
func NewStockService(redis *redis.Client) *StockService {
script := redis.NewScript(`
local product_id = KEYS[1]
local user_id = ARGV[1]
local quantity = tonumber(ARGV[2])
local stock_key = "product:" .. product_id .. ":stock"
local stock = tonumber(redis.call("GET", stock_key))
if stock == nil then
return {err = "product_not_found"}
end
if stock < quantity then
return {err = "insufficient_stock", remaining = stock}
end
local user_qty_key = "user:" .. user_id .. ":product:" .. product_id .. ":qty"
local user_qty = tonumber(redis.call("GET", user_qty_key)) or 0
local limit_key = "product:" .. product_id .. ":limit"
local limit_per_user = tonumber(redis.call("GET", limit_key)) or 1
if user_qty + quantity > limit_per_user then
return {err = "exceed_limit", user_qty = user_qty, limit = limit_per_user}
end
redis.call("DECRBY", stock_key, quantity)
redis.call("INCRBY", user_qty_key, quantity)
redis.call("EXPIRE", user_qty_key, 3600)
local purchase_set = "product:" .. product_id .. ":purchase_set"
redis.call("SADD", purchase_set, user_id)
redis.call("EXPIRE", purchase_set, 86400)
return {ok = true, remaining = stock - quantity}
`)
return &StockService{
redis: redis,
script: script,
}
}
func (s *StockService) DeductStock(ctx context.Context, productID, userID string, quantity int) (*StockResult, error) {
keys := []string{productID}
values := []interface{}{userID, quantity}
result, err := s.script.Run(ctx, s.redis, keys, values).Result()
if err != nil {
return nil, err
}
// 解析结果
resultMap, ok := result.(map[string]interface{})
if !ok {
return nil, errors.New("invalid result type")
}
stockResult := &StockResult{}
if errMsg, exists := resultMap["err"]; exists {
stockResult.Error = errMsg.(string)
if errMsg == "insufficient_stock" {
stockResult.Remaining = int(resultMap["remaining"].(int64))
}
} else if _, exists := resultMap["ok"]; exists {
stockResult.Success = true
stockResult.Remaining = int(resultMap["remaining"].(int64))
}
return stockResult, nil
}
type StockResult struct {
Success bool
Error string
Remaining int
}
```
---
### 方案二:数据库乐观锁
**核心思路**:利用数据库的原子性和行锁,保证数据一致性
#### 2.1 表设计
```sql
-- 商品库存表
CREATE TABLE products (
product_id BIGINT PRIMARY KEY,
total_stock INT NOT NULL,
available_stock INT NOT NULL,
version INT NOT NULL DEFAULT 0, -- 乐观锁版本号
limit_per_user INT NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_available_stock (available_stock)
) ENGINE=InnoDB;
-- 用户购买记录表
CREATE TABLE user_purchases (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_product (user_id, product_id), -- 防止重复购买
INDEX idx_product (product_id)
) ENGINE=InnoDB;
-- 订单表
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0:待支付, 1:已支付, 2:已取消
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_product (user_id, product_id),
INDEX idx_status (status)
) ENGINE=InnoDB;
```
#### 2.2 乐观锁实现
```go
func (s *StockService) DeductStockWithOptimisticLock(ctx context.Context, productID, userID int64, quantity int) error {
return s.db.Transaction(ctx, func(tx *gorm.DB) error {
// 1. 查询商品信息(带锁)
var product Product
err := tx.Set("gorm:query_option", "FOR UPDATE"). // 行锁
Where("product_id = ?", productID).
First(&product).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("product not found")
}
return err
}
// 2. 检查库存
if product.AvailableStock < quantity {
return errors.New("insufficient stock")
}
// 3. 检查用户购买数量
var totalPurchase int64
err = tx.Model(&UserPurchase{}).
Where("user_id = ? AND product_id = ?", userID, productID).
Select("COALESCE(SUM(quantity), 0)").
Scan(&totalPurchase).Error
if err != nil {
return err
}
if int(totalPurchase)+quantity > product.LimitPerUser {
return errors.New("exceed purchase limit")
}
// 4. 扣减库存(乐观锁)
result := tx.Model(&Product{}).
Where("product_id = ? AND available_stock >= ? AND version = ?",
productID, quantity, product.Version).
Updates(map[string]interface{}{
"available_stock": gorm.Expr("available_stock - ?", quantity),
"version": gorm.Expr("version + 1"),
})
if result.RowsAffected == 0 {
return errors.New("stock update failed, please retry")
}
// 5. 记录购买信息
purchase := &UserPurchase{
UserID: userID,
ProductID: productID,
Quantity: quantity,
}
if err := tx.Create(purchase).Error; err != nil {
return err
}
return nil
})
}
```
**问题**:数据库压力大,不适合超高并发场景
---
### 方案三:分布式锁 + Redis
**核心思路**:使用分布式锁保证并发安全
#### 3.1 Redis 分布式锁
```go
type DistributedLock struct {
redis *redis.Client
key string
value string
expire time.Duration
}
func (d *DistributedLock) Lock(ctx context.Context) error {
// 使用 SETNX 实现
ok, err := d.redis.SetNX(ctx, d.key, d.value, d.expire).Result()
if err != nil {
return err
}
if !ok {
return errors.New("lock already held")
}
return nil
}
func (d *DistributedLock) Unlock(ctx context.Context) error {
// 使用 Lua 脚本保证原子性
script := `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`
_, err := d.redis.Eval(ctx, script, []string{d.key}, []string{d.value}).Result()
return err
}
// 使用分布式锁扣减库存
func (s *StockService) DeductStockWithLock(ctx context.Context, productID, userID string, quantity int) error {
lockKey := fmt.Sprintf("lock:product:%s", productID)
lock := &DistributedLock{
redis: s.redis,
key: lockKey,
value: userID,
expire: 5 * time.Second,
}
// 获取锁
if err := lock.Lock(ctx); err != nil {
return err
}
defer lock.Unlock(ctx)
// 扣减库存逻辑
stockKey := fmt.Sprintf("product:%s:stock", productID)
result, err := s.redis.DecrBy(ctx, stockKey, quantity).Result()
if err != nil {
return err
}
if result < 0 {
return errors.New("insufficient stock")
}
return nil
}
```
---
### 方案四:限流防护
**核心思路**:在扣减库存前进行多层限流
#### 4.1 用户级限流
```go
type RateLimiter struct {
redis *redis.Client
}
// 滑动窗口限流
func (r *RateLimiter) AllowUser(ctx context.Context, userID string, maxRequests int, window time.Duration) bool {
key := fmt.Sprintf("ratelimit:user:%s", userID)
now := time.Now().UnixNano()
windowStart := now - window.Nanoseconds()
// 使用 Redis Sorted Set 实现滑动窗口
pipe := r.redis.Pipeline()
// 移除窗口外的记录
pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(windowStart, 10))
// 添加当前请求
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: userID})
// 设置过期时间
pipe.Expire(ctx, key, window)
// 统计窗口内请求数
pipe.ZCard(ctx, key)
results, err := pipe.Exec(ctx)
if err != nil {
return false
}
count := results[3].(*redis.IntCmd).Val()
return count <= int64(maxRequests)
}
```
#### 4.2 验证码
```go
func (s *StockService) VerifyCaptcha(ctx context.Context, userID, ticket, captcha string) bool {
key := fmt.Sprintf("captcha:%s:%s", userID, ticket)
stored, err := s.redis.Get(ctx, key).Result()
if err != nil {
return false
}
// 验证码一次性使用
s.redis.Del(ctx, key)
return stored == captcha
}
```
---
### 方案五:防刷策略
**核心思路**:多维度识别和防止机器刷单
#### 5.1 用户行为分析
```go
type AntiSpamService struct {
redis *redis.Client
}
// 检查用户行为风险
func (a *AntiSpamService) CheckUserRisk(ctx context.Context, userID string) (risk float64, reason string) {
risk := 0.0
// 1. 检查购买频率
purchaseCount, _ := a.redis.Get(ctx, fmt.Sprintf("user:%s:purchase_count:1h", userID)).Int()
if purchaseCount > 100 {
risk += 0.5
reason = "高频购买"
}
// 2. 检查注册时间
registerTime, _ := a.redis.Get(ctx, fmt.Sprintf("user:%s:register_time", userID)).Int64()
if time.Now().Unix()-registerTime < 86400 { // 注册不到1天
risk += 0.3
if reason == "" {
reason = "新用户"
}
}
// 3. 检查设备指纹
deviceCount, _ := a.redis.Get(ctx, fmt.Sprintf("user:%s:device_count", userID)).Int()
if deviceCount > 5 {
risk += 0.4
if reason == "" {
reason = "多设备"
}
}
// 4. 检查IP关联账号数
ipKey := fmt.Sprintf("ip:%s:user_count", getUserIP(ctx))
ipUserCount, _ := a.redis.Get(ctx, ipKey).Int()
if ipUserCount > 10 {
risk += 0.3
if reason == "" {
reason = "同IP多账号"
}
}
return risk, reason
}
// 是否允许购买
func (a *AntiSpamService) AllowPurchase(ctx context.Context, userID string) (bool, string) {
risk, reason := a.CheckUserRisk(ctx, userID)
if risk > 0.7 {
// 高风险:直接拒绝
return false, "高风险用户:" + reason
}
if risk > 0.4 {
// 中风险:要求验证码
return false, "需要验证码"
}
return true, ""
}
```
#### 5.2 黑名单机制
```go
type BlacklistService struct {
redis *redis.Client
}
// 检查是否在黑名单
func (b *BlacklistService) IsBlacklisted(ctx context.Context, userID string) (bool, string) {
// 1. 用户黑名单
if exists, _ := b.redis.SIsMember(ctx, "blacklist:user", userID).Result(); exists {
return true, "用户黑名单"
}
// 2. IP黑名单
ip := getUserIP(ctx)
if exists, _ := b.redis.SIsMember(ctx, "blacklist:ip", ip).Result(); exists {
return true, "IP黑名单"
}
// 3. 设备黑名单
deviceID := getDeviceID(ctx)
if exists, _ := b.redis.SIsMember(ctx, "blacklist:device", deviceID).Result(); exists {
return true, "设备黑名单"
}
return false, ""
}
```
---
### 方案六:库存预加载与预热
**核心思路**:提前将库存加载到 Redis减轻数据库压力
#### 6.1 预热库存
```go
func (s *StockService) PreloadStock(ctx context.Context, productID string) error {
// 1. 从数据库加载库存
var product Product
err := s.db.Where("product_id = ?", productID).First(&product).Error
if err != nil {
return err
}
// 2. 加载到 Redis
stockKey := fmt.Sprintf("product:%s:stock", productID)
limitKey := fmt.Sprintf("product:%s:limit", productID)
pipe := s.redis.Pipeline()
pipe.Set(ctx, stockKey, product.AvailableStock, 24*time.Hour)
pipe.Set(ctx, limitKey, product.LimitPerUser, 24*time.Hour)
// 3. 加载用户购买记录
var purchases []UserPurchase
s.db.Where("product_id = ? AND created_at > ?", productID, time.Now().Add(-24*time.Hour)).
Find(&purchases)
for _, purchase := range purchases {
userQtyKey := fmt.Sprintf("user:%d:product:%s:qty", purchase.UserID, productID)
pipe.Set(ctx, userQtyKey, purchase.Quantity, time.Hour)
}
_, err = pipe.Exec(ctx)
return err
}
// 批量预热多个商品
func (s *StockService) PreloadStocks(ctx context.Context, productIDs []string) error {
for _, productID := range productIDs {
if err := s.PreloadStock(ctx, productID); err != nil {
log.Errorf("Failed to preload stock for product %s: %v", productID, err)
}
}
return nil
}
```
#### 6.2 定时同步
```go
// 定时将 Redis 中的数据同步回数据库
func (s *StockService) SyncToDatabase(ctx context.Context) error {
// 1. 获取所有商品库存
keys, _, err := s.redis.Scan(ctx, "product:*:stock").Result()
if err != nil {
return err
}
for _, key := range keys {
// 2. 获取 Redis 中的库存
stock, _ := s.redis.Get(ctx, key).Int()
// 3. 提取 productID
productID := strings.TrimPrefix(key, "product:")
productID = strings.TrimSuffix(productID, ":stock")
// 4. 更新数据库
s.db.Model(&Product{}).
Where("product_id = ?", productID).
Update("available_stock", stock)
}
return nil
}
```
---
### 方案七:订单异步处理
**核心思路**:库存扣减后,异步创建订单
#### 7.1 消息队列
```go
type OrderMessage struct {
OrderID string `json:"order_id"`
UserID string `json:"user_id"`
ProductID string `json:"product_id"`
Quantity int `json:"quantity"`
Timestamp time.Time `json:"timestamp"`
}
func (s *StockService) DeductStockAsync(ctx context.Context, productID, userID string, quantity int) (*OrderResult, error) {
// 1. 扣减库存(同步)
result, err := s.DeductStock(ctx, productID, userID, quantity)
if err != nil {
return nil, err
}
// 2. 发送订单创建消息(异步)
orderID := generateOrderID()
orderMsg := &OrderMessage{
OrderID: orderID,
UserID: userID,
ProductID: productID,
Quantity: quantity,
Timestamp: time.Now(),
}
msgBytes, _ := json.Marshal(orderMsg)
if err := s.kafka.SendMessage("orders", msgBytes); err != nil {
// 消息发送失败,回滚库存
s.rollbackStock(ctx, productID, userID, quantity)
return nil, err
}
return &OrderResult{
OrderID: orderID,
Success: true,
}, nil
}
func (s *StockService) rollbackStock(ctx context.Context, productID, userID string, quantity int) {
stockKey := fmt.Sprintf("product:%s:stock", productID)
userQtyKey := fmt.Sprintf("user:%s:product:%s:qty", userID, productID)
pipe := s.redis.Pipeline()
pipe.IncrBy(ctx, stockKey, quantity)
pipe.DecrBy(ctx, userQtyKey, quantity)
pipe.Exec(ctx)
}
```
---
## 实战案例
### 案例1秒杀活动
**场景**
- 商品iPhone 15 Pro库存1000台
- 限购每人1台
- 预估QPS10万
**处理流程**
```
1. 活动前预热T-10分钟
├─ 预热库存到 Redis
├─ 预热用户购买记录
└─ 启动监控告警
2. 活动开始T=0
├─ 10万QPS 请求涌入
├─ 第一层用户限流每秒1次
│ └─ 拦截 90% 请求 → 剩余 1万QPS
├─ 第二层:验证码验证
│ └─ 拦截 50% 请求 → 剩余 5千QPS
├─ 第三层Redis Lua 扣减库存
│ └─ 原子操作,保证准确
└─ 第四层:异步创建订单
└─ Kafka 消息队列
3. 活动进行中T+1分钟
├─ 库存1000 → 0
├─ Redis 压力:正常
├─ 数据库压力:低(异步写入)
└─ 订单队列5000 个待处理
4. 活动结束后
├─ 停止接收新订单
├─ 处理剩余订单
├─ 同步数据到数据库
└─ 生成销售报表
```
### 案例2黄牛刷单检测
**场景**
- 某用户使用脚本在1秒内发起100次请求
- IP地址相同
- 设备指纹相同
**处理流程**
```
1. 监控检测异常
├─ 用户购买频率100次/秒
├─ IP地址单一来源
└─ 触发告警
2. 风控规则匹配
├─ 规则11分钟内购买>10次 → 高风险
├─ 规则2同IP多账号>5个 → 高风险
└─ 规则3新用户大额购买 → 中风险
3. 自动处理
├─ 加入黑名单24小时
├─ 冻结订单
├─ 释放库存
└─ 通知风控团队
4. 人工审核
├─ 检查用户历史行为
├─ 确认是否为恶意刷单
└─ 决定是否永久封禁
```
---
## P7 加分项
### 深度理解
1. **为什么选择 Redis 而不是数据库?**
- 性能Redis QPS 可达 10万+MySQL 只有几千
- 原子性Redis Lua 脚本保证原子操作
- 轻量级内存操作比磁盘IO快得多
2. **如何保证 Redis 和数据库一致性?**
- 最终一致性:允许短暂的不一致
- 定时同步:定时将 Redis 数据同步到数据库
- 对账系统:定时比对 Redis 和数据库数据
- 补偿机制:发现不一致时自动修复
3. **如何处理网络分区?**
- CAP权衡选择 AP可用性 + 分区容错)
- Redis Cluster多主复制保证高可用
- 降级策略Redis 不可用时降级为数据库
### 实战扩展
**相关技术**
- Redis缓存、分布式锁
- Kafka消息队列、异步处理
- Hystrix熔断器
- Sentinel限流熔断
- Prometheus监控告警
**最佳实践**
1. 预热:提前加载库存到 Redis
2. 限流:多层限流保护
3. 异步:订单异步处理
4. 监控:实时监控告警
5. 降级:异常情况降级处理
### 变体问题
1. **如何支持多种商品同时秒杀?**
- 为每个商品单独设置库存 key
- 使用 Redis Cluster 分片
- 不同商品使用不同的 key 前缀
2. **如何处理库存回滚?**
- 订单超时未支付自动回滚
- 使用延迟消息队列
- 设置订单 TTL
3. **如何支持预约抢购?**
- 预售阶段:只允许预约,不扣减库存
- 抢购阶段:按预约顺序扣减库存
- 使用 Sorted Set 实现排队
4. **如何防止黄牛倒卖?**
- 实名制购买
- 限制转卖时间如7天内不可转卖
- 价格波动策略(价格逐步上涨)
---
## 总结
**核心要点**
1. **Redis + Lua**:原子操作,保证库存准确性
2. **多层限流**:防止系统过载
3. **异步处理**:订单异步创建,提高性能
4. **防刷策略**:多维度识别刷单行为
5. **预热机制**:提前加载库存,减轻压力
**技术栈**
- 缓存Redis库存、用户记录
- 消息队列Kafka异步订单
- 限流Redis + 滑动窗口
- 分布式锁Redis SETNX
- 监控Prometheus + Grafana
**关键指标**
- QPS10万+
- 库存准确性100%
- 响应时间P99 < 100ms
- 系统可用性99.99%
**易错点**
- 忘记使用 Lua 脚本导致并发问题
- 过度依赖数据库导致性能瓶颈
- 忽视防刷策略导致黄牛刷单
- 没有降级预案导致系统雪崩
- 忘记异步处理导致响应慢