Skip to content

如何设计抢红包系统

一、问题描述

1.1 业务背景

抢红包是高并发场景的经典案例,广泛应用于:

  • 微信红包:春节期间每秒抢红包请求达到百万级
  • 支付宝红包:集五福活动
  • 电商红包雨:双十一抢红包

核心特点

  • 瞬间高并发(百万QPS)
  • 金额随机分配
  • 防止超抢、重复抢
  • 数据强一致性(钱不能多发也不能少发)

1.2 核心功能

基础功能

  1. 发红包:指定总金额和红包个数
  2. 抢红包:用户抢到随机金额
  3. 拆红包:查看抢到的金额
  4. 红包详情:查看红包领取记录

进阶功能

  1. 红包过期:24小时未抢完退回
  2. 防重复抢:同一用户只能抢一次
  3. 防刷:IP限流、设备指纹
  4. 红包雨:批量发红包

1.3 技术挑战

高并发抢

微信红包峰值数据:
- 2014年春晚:1600万次/分
- 2015年春晚:10.1亿次红包
- 2016年春晚:420万次/秒(峰值)

防超抢

红包总数:100个
并发请求:10000个
如何保证只有100人抢到?

金额分配公平性

100元分10个红包
如何保证每人机会均等?
不能前面的人抢光,后面的人没得抢

数据一致性

Redis记录抢到的金额
MySQL记录交易流水
如何保证一致性?

1.4 面试考察点

  • 红包算法:如何分配金额才公平
  • 高并发处理:Redis + Lua原子操作
  • 防超抢:库存控制
  • 数据一致性:Redis和MySQL一致性
  • 防作弊:防重复、防刷

二、红包算法

2.1 二倍均值法(微信红包算法)

原理: 每次抢到的金额在 [0.01, 剩余平均值 × 2] 之间随机。

数学推导

剩余金额:M
剩余个数:N
剩余平均值:M / N

当前红包金额:random(0.01, M / N × 2)

示例:100元分10个红包
第1个:random(0.01, 100/10×2) = random(0.01, 20) = 15.5元
第2个:random(0.01, 84.5/9×2) = random(0.01, 18.78) = 8.2元
...

公平性证明

每人期望:E = M / N

第1个人期望:
E1 = (0.01 + 2M/N) / 2 ≈ M/N ✅

第2个人期望:
E2 = E[(M - E1) / (N - 1)] = E[M/N] = M/N ✅

数学归纳法证明:每个人期望相同

优点

  • ✅ 公平:每人期望相同
  • ✅ 随机:金额有大有小,有趣
  • ✅ 简单:实现容易

实现

go
package redenvelope

import (
    "fmt"
    "math/rand"
    "time"
)

// DoubleAverageAlgorithm 二倍均值法
func DoubleAverageAlgorithm(totalAmount int64, totalCount int) []int64 {
    rand.Seed(time.Now().UnixNano())
    
    var amounts []int64
    remaining := totalAmount * 100 // 转为分
    
    for i := 0; i < totalCount; i++ {
        if i == totalCount-1 {
            // 最后一个红包:剩余全部金额
            amounts = append(amounts, remaining)
        } else {
            // 剩余平均值
            avg := remaining / int64(totalCount-i)
            
            // 随机范围:[1分, 平均值×2]
            max := avg * 2
            if max > remaining {
                max = remaining
            }
            
            // 保证至少1分
            if max < 1 {
                max = 1
            }
            
            // 随机金额
            amount := rand.Int63n(max-1) + 1
            
            amounts = append(amounts, amount)
            remaining -= amount
        }
    }
    
    return amounts
}

// 示例
func main() {
    amounts := DoubleAverageAlgorithm(100, 10) // 100元分10个
    
    fmt.Println("红包金额分配:")
    sum := int64(0)
    for i, amount := range amounts {
        fmt.Printf("第%d个:%.2f\n", i+1, float64(amount)/100.0)
        sum += amount
    }
    fmt.Printf("总计:%.2f\n", float64(sum)/100.0)
}

// 输出示例:
// 第1个:15.50元
// 第2个:8.21元
// 第3个:12.33元
// ...
// 总计:100.00元
java
public class RedEnvelopeAlgorithm {
    
    /**
     * 二倍均值法
     */
    public static List<Long> doubleAverage(long totalAmount, int totalCount) {
        List<Long> amounts = new ArrayList<>();
        long remaining = totalAmount * 100; // 转为分
        
        Random random = new Random();
        
        for (int i = 0; i < totalCount; i++) {
            if (i == totalCount - 1) {
                // 最后一个:剩余全部
                amounts.add(remaining);
            } else {
                // 剩余平均值
                long avg = remaining / (totalCount - i);
                
                // 随机范围:[1分, 平均值×2]
                long max = avg * 2;
                if (max > remaining) {
                    max = remaining;
                }
                if (max < 1) {
                    max = 1;
                }
                
                // 随机金额
                long amount = random.nextLong(max - 1) + 1;
                
                amounts.add(amount);
                remaining -= amount;
            }
        }
        
        return amounts;
    }
}

2.2 其他算法

线段切割法

0 ------- 100元 -------

在线段上随机N-1个点,切割成N段
每段长度即为红包金额

缺点:可能出现0元红包

固定金额法(支付宝):

固定金额:0.01, 0.5, 1, 2, 5, 10元
随机选择

优点:简单、高效 缺点:不够随机

三、系统设计

3.1 架构图

mermaid
graph TB
    subgraph 客户端
        A[用户App]
    end
    
    subgraph 接入层
        B[Nginx + Lua限流]
    end
    
    subgraph 业务层
        C[发红包服务]
        D[抢红包服务]
    end
    
    subgraph 缓存层
        E[Redis]
        E --> E1[红包池<br/>Hash存储金额]
        E --> E2[抢红包记录<br/>Set防重复]
        E --> E3[库存计数<br/>String原子递减]
    end
    
    subgraph 存储层
        F[MySQL]
        F --> F1[红包表]
        F --> F2[领取记录表]
        F --> F3[账户流水表]
    end
    
    subgraph 消息队列
        G[Kafka]
        H[异步写MySQL]
    end
    
    A --> B
    B --> C
    B --> D
    
    C --> E
    D --> E
    D --> G
    
    G --> H
    H --> F

3.2 数据库设计

红包表

sql
CREATE TABLE red_envelope (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    envelope_no VARCHAR(32) UNIQUE NOT NULL COMMENT '红包编号',
    user_id BIGINT NOT NULL COMMENT '发红包用户ID',
    total_amount BIGINT NOT NULL COMMENT '总金额(分)',
    total_count INT NOT NULL COMMENT '红包总数',
    remaining_amount BIGINT NOT NULL COMMENT '剩余金额(分)',
    remaining_count INT NOT NULL COMMENT '剩余个数',
    expire_time DATETIME NOT NULL COMMENT '过期时间',
    status TINYINT DEFAULT 1 COMMENT '状态 1进行中 2已抢完 3已过期',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    
    KEY idx_user (user_id),
    KEY idx_status_expire (status, expire_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='红包表';

领取记录表

sql
CREATE TABLE envelope_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    envelope_no VARCHAR(32) NOT NULL COMMENT '红包编号',
    user_id BIGINT NOT NULL COMMENT '抢红包用户ID',
    amount BIGINT NOT NULL COMMENT '抢到金额(分)',
    grab_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '抢红包时间',
    
    UNIQUE KEY uk_envelope_user (envelope_no, user_id),
    KEY idx_user (user_id, grab_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='红包领取记录表';

3.3 Redis数据结构

redis
# 1. 红包池(List,存储预生成的金额)
Key: envelope:pool:{envelope_no}
Value: [1550, 821, 1233, ...] (单位:分)
TTL: 24小时

# 示例
RPUSH envelope:pool:ENV001 1550 821 1233 ...
LPOP envelope:pool:ENV001  # 抢红包时弹出

# 2. 红包库存(String,原子递减)
Key: envelope:stock:{envelope_no}
Value: {remaining_count}
TTL: 24小时

# 示例
SET envelope:stock:ENV001 100
DECR envelope:stock:ENV001  # 抢红包时递减

# 3. 抢红包记录(Set,防重复抢)
Key: envelope:grabbed:{envelope_no}
Value: {user_id1, user_id2, ...}
TTL: 24小时

# 示例
SADD envelope:grabbed:ENV001 10001
SISMEMBER envelope:grabbed:ENV001 10001  # 检查是否已抢

# 4. 红包详情(Hash)
Key: envelope:info:{envelope_no}
Fields: total_amount, total_count, expire_time
TTL: 24小时

# 5. 用户今日抢红包次数(String,防刷)
Key: user:grab:count:{user_id}:{date}
Value: {count}
TTL: 24小时

3.4 核心流程

发红包流程

mermaid
sequenceDiagram
    participant U as 用户
    participant S as 发红包服务
    participant R as Redis
    participant M as MySQL
    
    U->>S: 发红包(100元, 10个)
    S->>S: 二倍均值法生成10个金额
    S->>M: 创建红包记录
    S->>R: 存储红包池(List)
    S->>R: 设置库存(String)
    S->>R: 设置红包详情(Hash)
    S-->>U: 返回红包编号

抢红包流程(关键)

mermaid
sequenceDiagram
    participant U as 用户
    participant S as 抢红包服务
    participant R as Redis
    participant K as Kafka
    participant M as MySQL
    
    U->>S: 抢红包(envelope_no, user_id)
    
    S->>R: Lua脚本原子操作
    Note over R: 1. 检查是否已抢<br/>2. 检查库存<br/>3. 弹出金额<br/>4. 记录用户已抢
    
    R-->>S: 返回金额
    
    alt 抢到红包
        S->>K: 异步写MySQL
        K->>M: 写入领取记录
        S-->>U: 返回金额
    else 未抢到
        S-->>U: 红包已抢完/已抢过
    end

四、核心实现

4.1 Lua脚本(原子操作)

抢红包Lua脚本

lua
-- KEYS[1]: envelope:pool:{envelope_no}
-- KEYS[2]: envelope:stock:{envelope_no}
-- KEYS[3]: envelope:grabbed:{envelope_no}
-- ARGV[1]: user_id

local function grab_envelope()
    -- 1. 检查是否已抢过
    local grabbed = redis.call('SISMEMBER', KEYS[3], ARGV[1])
    if grabbed == 1 then
        return {-1, 0}  -- 已抢过
    end
    
    -- 2. 检查库存
    local stock = redis.call('GET', KEYS[2])
    if not stock or tonumber(stock) <= 0 then
        return {-2, 0}  -- 已抢完
    end
    
    -- 3. 弹出金额
    local amount = redis.call('LPOP', KEYS[1])
    if not amount then
        return {-2, 0}  -- 红包池为空
    end
    
    -- 4. 库存-1
    redis.call('DECR', KEYS[2])
    
    -- 5. 记录已抢
    redis.call('SADD', KEYS[3], ARGV[1])
    
    return {0, tonumber(amount)}  -- 成功
end

return grab_envelope()

4.2 Go实现

点击查看完整实现
go
package redenvelope

import (
    "context"
    "fmt"
    "time"
    
    "github.com/go-redis/redis/v8"
    "gorm.io/gorm"
)

type RedEnvelopeService struct {
    db          *gorm.DB
    redis       *redis.Client
    kafkaClient *KafkaClient
}

type RedEnvelope struct {
    ID              int64     `gorm:"primary_key"`
    EnvelopeNo      string    `gorm:"column:envelope_no"`
    UserID          int64     `gorm:"column:user_id"`
    TotalAmount     int64     `gorm:"column:total_amount"`
    TotalCount      int       `gorm:"column:total_count"`
    RemainingAmount int64     `gorm:"column:remaining_amount"`
    RemainingCount  int       `gorm:"column:remaining_count"`
    ExpireTime      time.Time `gorm:"column:expire_time"`
    Status          int8      `gorm:"column:status"`
    CreateTime      time.Time `gorm:"column:create_time"`
}

type EnvelopeRecord struct {
    ID         int64     `gorm:"primary_key"`
    EnvelopeNo string    `gorm:"column:envelope_no"`
    UserID     int64     `gorm:"column:user_id"`
    Amount     int64     `gorm:"column:amount"`
    GrabTime   time.Time `gorm:"column:grab_time"`
}

// Send 发红包
func (s *RedEnvelopeService) Send(ctx context.Context, userID int64, totalAmount int64, totalCount int) (string, error) {
    // 1. 生成红包编号
    envelopeNo := generateEnvelopeNo()
    
    // 2. 二倍均值法生成金额
    amounts := DoubleAverageAlgorithm(totalAmount, totalCount)
    
    // 3. 创建红包记录
    envelope := &RedEnvelope{
        EnvelopeNo:      envelopeNo,
        UserID:          userID,
        TotalAmount:     totalAmount * 100,
        TotalCount:      totalCount,
        RemainingAmount: totalAmount * 100,
        RemainingCount:  totalCount,
        ExpireTime:      time.Now().Add(24 * time.Hour),
        Status:          1,
        CreateTime:      time.Now(),
    }
    
    err := s.db.Create(envelope).Error
    if err != nil {
        return "", err
    }
    
    // 4. 写入Redis
    err = s.initRedisData(ctx, envelopeNo, amounts, totalCount)
    if err != nil {
        return "", err
    }
    
    return envelopeNo, nil
}

// initRedisData 初始化Redis数据
func (s *RedEnvelopeService) initRedisData(ctx context.Context, envelopeNo string, amounts []int64, totalCount int) error {
    pipeline := s.redis.Pipeline()
    
    // 1. 红包池(List)
    poolKey := fmt.Sprintf("envelope:pool:%s", envelopeNo)
    for _, amount := range amounts {
        pipeline.RPush(ctx, poolKey, amount)
    }
    pipeline.Expire(ctx, poolKey, 24*time.Hour)
    
    // 2. 库存(String)
    stockKey := fmt.Sprintf("envelope:stock:%s", envelopeNo)
    pipeline.Set(ctx, stockKey, totalCount, 24*time.Hour)
    
    // 3. 已抢记录(Set)
    grabbedKey := fmt.Sprintf("envelope:grabbed:%s", envelopeNo)
    pipeline.Expire(ctx, grabbedKey, 24*time.Hour)
    
    _, err := pipeline.Exec(ctx)
    return err
}

// Grab 抢红包
func (s *RedEnvelopeService) Grab(ctx context.Context, envelopeNo string, userID int64) (int64, error) {
    // 1. 防刷检查
    if !s.checkRateLimit(ctx, userID) {
        return 0, fmt.Errorf("抢红包过于频繁")
    }
    
    // 2. 执行Lua脚本(原子操作)
    amount, err := s.grabByLua(ctx, envelopeNo, userID)
    if err != nil {
        return 0, err
    }
    
    if amount <= 0 {
        if amount == -1 {
            return 0, fmt.Errorf("已抢过该红包")
        }
        return 0, fmt.Errorf("红包已抢完")
    }
    
    // 3. 异步写MySQL
    record := &EnvelopeRecord{
        EnvelopeNo: envelopeNo,
        UserID:     userID,
        Amount:     amount,
        GrabTime:   time.Now(),
    }
    s.kafkaClient.SendEnvelopeRecord(record)
    
    return amount, nil
}

// grabByLua 通过Lua脚本抢红包
func (s *RedEnvelopeService) grabByLua(ctx context.Context, envelopeNo string, userID int64) (int64, error) {
    script := `
        local grabbed = redis.call('SISMEMBER', KEYS[3], ARGV[1])
        if grabbed == 1 then
            return {-1, 0}
        end
        
        local stock = redis.call('GET', KEYS[2])
        if not stock or tonumber(stock) <= 0 then
            return {-2, 0}
        end
        
        local amount = redis.call('LPOP', KEYS[1])
        if not amount then
            return {-2, 0}
        end
        
        redis.call('DECR', KEYS[2])
        redis.call('SADD', KEYS[3], ARGV[1])
        
        return {0, tonumber(amount)}
    `
    
    poolKey := fmt.Sprintf("envelope:pool:%s", envelopeNo)
    stockKey := fmt.Sprintf("envelope:stock:%s", envelopeNo)
    grabbedKey := fmt.Sprintf("envelope:grabbed:%s", envelopeNo)
    
    result, err := s.redis.Eval(ctx, script, []string{poolKey, stockKey, grabbedKey}, userID).Result()
    if err != nil {
        return 0, err
    }
    
    arr := result.([]interface{})
    code := arr[0].(int64)
    amount := arr[1].(int64)
    
    if code == -1 {
        return -1, nil // 已抢过
    }
    if code == -2 {
        return -2, nil // 已抢完
    }
    
    return amount, nil
}

// checkRateLimit 防刷检查
func (s *RedEnvelopeService) checkRateLimit(ctx context.Context, userID int64) bool {
    date := time.Now().Format("20060102")
    key := fmt.Sprintf("user:grab:count:%d:%s", userID, date)
    
    count, _ := s.redis.Incr(ctx, key).Result()
    s.redis.Expire(ctx, key, 24*time.Hour)
    
    // 每天最多抢100个红包
    return count <= 100
}

// ConsumeEnvelopeRecord 消费红包记录(Kafka Consumer)
func (s *RedEnvelopeService) ConsumeEnvelopeRecord(record *EnvelopeRecord) error {
    // 幂等性检查
    var exist EnvelopeRecord
    err := s.db.Where("envelope_no = ? AND user_id = ?", record.EnvelopeNo, record.UserID).First(&exist).Error
    if err == nil {
        return nil // 已存在,幂等返回
    }
    
    // 插入记录
    return s.db.Create(record).Error
}

func generateEnvelopeNo() string {
    return fmt.Sprintf("ENV%d", time.Now().UnixNano())
}

4.3 Java实现

java
@Service
public class RedEnvelopeService {
    
    @Autowired
    private RedEnvelopeMapper envelopeMapper;
    
    @Autowired
    private EnvelopeRecordMapper recordMapper;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private KafkaTemplate<String, EnvelopeRecord> kafkaTemplate;
    
    /**
     * 发红包
     */
    @Transactional
    public String send(Long userId, Long totalAmount, Integer totalCount) {
        // 1. 生成红包编号
        String envelopeNo = "ENV" + System.currentTimeMillis();
        
        // 2. 生成金额
        List<Long> amounts = RedEnvelopeAlgorithm.doubleAverage(totalAmount, totalCount);
        
        // 3. 创建红包记录
        RedEnvelope envelope = new RedEnvelope();
        envelope.setEnvelopeNo(envelopeNo);
        envelope.setUserId(userId);
        envelope.setTotalAmount(totalAmount * 100);
        envelope.setTotalCount(totalCount);
        envelope.setRemainingAmount(totalAmount * 100);
        envelope.setRemainingCount(totalCount);
        envelope.setExpireTime(LocalDateTime.now().plusHours(24));
        envelope.setStatus((byte) 1);
        
        envelopeMapper.insert(envelope);
        
        // 4. 写入Redis
        initRedisData(envelopeNo, amounts, totalCount);
        
        return envelopeNo;
    }
    
    /**
     * 抢红包
     */
    public Long grab(String envelopeNo, Long userId) {
        // 1. 防刷检查
        if (!checkRateLimit(userId)) {
            throw new BusinessException("抢红包过于频繁");
        }
        
        // 2. 执行Lua脚本
        Long amount = grabByLua(envelopeNo, userId);
        
        if (amount == null || amount <= 0) {
            if (amount != null && amount == -1) {
                throw new BusinessException("已抢过该红包");
            }
            throw new BusinessException("红包已抢完");
        }
        
        // 3. 异步写MySQL
        EnvelopeRecord record = new EnvelopeRecord();
        record.setEnvelopeNo(envelopeNo);
        record.setUserId(userId);
        record.setAmount(amount);
        record.setGrabTime(new Date());
        
        kafkaTemplate.send("envelope_topic", record);
        
        return amount;
    }
    
    /**
     * Lua脚本抢红包
     */
    private Long grabByLua(String envelopeNo, Long userId) {
        String script = 
            "local grabbed = redis.call('SISMEMBER', KEYS[3], ARGV[1]) " +
            "if grabbed == 1 then return {-1, 0} end " +
            "local stock = redis.call('GET', KEYS[2]) " +
            "if not stock or tonumber(stock) <= 0 then return {-2, 0} end " +
            "local amount = redis.call('LPOP', KEYS[1]) " +
            "if not amount then return {-2, 0} end " +
            "redis.call('DECR', KEYS[2]) " +
            "redis.call('SADD', KEYS[3], ARGV[1]) " +
            "return {0, tonumber(amount)}";
        
        String poolKey = "envelope:pool:" + envelopeNo;
        String stockKey = "envelope:stock:" + envelopeNo;
        String grabbedKey = "envelope:grabbed:" + envelopeNo;
        
        DefaultRedisScript<List> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(script);
        redisScript.setResultType(List.class);
        
        List<Object> result = redisTemplate.execute(
            redisScript,
            Arrays.asList(poolKey, stockKey, grabbedKey),
            userId
        );
        
        if (result == null || result.size() < 2) {
            return null;
        }
        
        Integer code = (Integer) result.get(0);
        Long amount = ((Number) result.get(1)).longValue();
        
        return code == 0 ? amount : Long.valueOf(code);
    }
    
    /**
     * 初始化Redis数据
     */
    private void initRedisData(String envelopeNo, List<Long> amounts, Integer totalCount) {
        // 红包池
        String poolKey = "envelope:pool:" + envelopeNo;
        redisTemplate.opsForList().rightPushAll(poolKey, amounts.toArray());
        redisTemplate.expire(poolKey, 24, TimeUnit.HOURS);
        
        // 库存
        String stockKey = "envelope:stock:" + envelopeNo;
        redisTemplate.opsForValue().set(stockKey, totalCount, 24, TimeUnit.HOURS);
    }
    
    /**
     * 防刷检查
     */
    private boolean checkRateLimit(Long userId) {
        String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        String key = "user:grab:count:" + userId + ":" + date;
        
        Long count = redisTemplate.opsForValue().increment(key, 1);
        redisTemplate.expire(key, 24, TimeUnit.HOURS);
        
        return count <= 100; // 每天最多100个
    }
}

五、性能优化

5.1 优化策略

1. Redis预生成金额

优点:抢时无需计算,直接弹出

go
// 发红包时预生成
amounts := DoubleAverageAlgorithm(100, 10)
redis.RPush("envelope:pool:ENV001", amounts...)

2. Lua脚本原子操作

保证:检查 + 扣减 + 记录 的原子性

lua
-- 一次性完成所有操作,避免并发问题
redis.call('LPOP', KEYS[1])
redis.call('DECR', KEYS[2])
redis.call('SADD', KEYS[3], ARGV[1])

3. 异步写MySQL

Kafka削峰,避免数据库压力

Redis(同步,ms级) → Kafka → MySQL(异步,秒级)

4. 限流

Nginx + Lua限流:

lua
-- 限制单用户QPS
local key = "rate:limit:" .. ngx.var.remote_addr
local count = redis.call('INCR', key)
if count == 1 then
    redis.call('EXPIRE', key, 1)
end
if count > 10 then
    ngx.exit(503)
end

5.2 性能数据

指标优化前优化后提升
抢红包响应时间200ms10ms20x
支持QPS500010万+20x
Redis内存1GB500MB50%
MySQL写入延迟同步异步3s-

六、数据一致性

6.1 Redis和MySQL一致性

问题

Redis抢到了,MySQL写入失败 → 钱多发了
MySQL写入了,Redis丢失 → 钱少发了

解决方案

1. 本地消息表(推荐)

go
// 抢红包成功后
// 1. Redis操作成功
amount := redis.LPOP(...)

// 2. 发送Kafka消息
kafka.Send(record)

// 3. Kafka Consumer消费
func ConsumeRecord(record) {
    // 幂等性插入MySQL
    db.Insert(record)
}

2. 定时对账

go
// 每天凌晨对账
func Reconcile() {
    // 1. 从Redis统计已抢金额
    redisTotal := countRedisGrabbed()
    
    // 2. 从MySQL统计已入账金额
    mysqlTotal := countMySQLRecords()
    
    // 3. 差异告警
    if redisTotal != mysqlTotal {
        alert("金额不一致", redisTotal, mysqlTotal)
    }
}

3. 退款兜底

go
// 24小时后,未入账的自动退款
func RefundExpired() {
    // 查询Redis已抢但MySQL没记录的
    // 执行退款
}

七、防作弊

7.1 防重复抢

Redis Set

redis
SISMEMBER envelope:grabbed:{envelope_no} {user_id}

数据库唯一键

sql
UNIQUE KEY uk_envelope_user (envelope_no, user_id)

7.2 防刷

IP限流

go
// 同一IP每秒最多10次
key := fmt.Sprintf("rate:limit:ip:%s", clientIP)
count := redis.Incr(key)
redis.Expire(key, 1)
if count > 10 {
    return error("请求过于频繁")
}

用户限流

go
// 同一用户每天最多抢100个红包
key := fmt.Sprintf("user:grab:count:%d:%s", userID, date)
count := redis.Incr(key)
redis.Expire(key, 24*time.Hour)
if count > 100 {
    return error("今日抢红包次数已达上限")
}

设备指纹

go
// 记录设备指纹,检测异常设备
deviceID := getDeviceFingerprint(request)
if isAbnormalDevice(deviceID) {
    return error("设备异常")
}

八、面试要点

8.1 常见追问

Q1: 微信红包和支付宝红包的算法有何不同?

A:

维度微信红包支付宝红包
算法二倍均值法固定金额法
随机性高(金额随机)低(固定几个档位)
公平性高(期望相同)
趣味性
性能中(需预生成)高(直接选择)

Q2: 如何保证不会超抢?

A: 三重保障:

  1. Redis库存:原子递减
  2. Lua脚本:原子性检查+扣减
  3. 数据库唯一键:最后防线

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

A:

  1. 本地消息表 + Kafka(推荐)
  2. 定时对账 + 告警
  3. 退款兜底

Q4: 如何防止高并发击穿Redis?

A:

  1. 前置限流:Nginx + Lua
  2. Redis集群:主从+哨兵
  3. 布隆过滤器:过滤不存在的红包
  4. 熔断降级:Redis故障时降级

8.2 扩展知识点

相关场景题

相关技术文档

九、总结

抢红包系统设计要点:

  1. 红包算法:二倍均值法(公平+随机)
  2. 高并发处理:Redis + Lua原子操作
  3. 防超抢:库存控制 + 唯一键
  4. 数据一致性:Kafka异步 + 定时对账
  5. 防作弊:防重复 + 限流 + 设备指纹

核心技术栈

  • Redis:存储红包池、库存、已抢记录
  • Lua:原子性抢红包
  • Kafka:异步写MySQL
  • MySQL:持久化存储

性能指标

  • 响应时间:<10ms
  • 支持QPS:10万+
  • 数据一致性:99.99%

面试重点

  • 能说清楚二倍均值法的原理和公平性证明
  • 能解释Lua脚本如何保证原子性
  • 能设计Redis和MySQL的一致性方案
  • 能说出防作弊的多重手段

参考资料

  • 微信红包技术分享
  • 支付宝红包架构
  • 《高并发系统设计40问》

正在精进