红包系统:高并发下如何保证不超发

做直播App,红包是最容易出问题的功能。几百人同时抢,一旦超发,平台直接亏钱。

这篇文章记录红包系统的设计思路。


需求

直播间红包场景:

  • 主播发红包,几百上千人同时抢
  • 抢到获得平台货币
  • 红包总金额固定,不能多发

三种红包类型:

  • 普通红包 --- 均分
  • 拼手气红包 --- 随机金额
  • 抽奖红包 --- 随机抽人

约束:

  1. 不能超发 --- 发100份就是100份
  2. 高并发 --- 抢红包瞬间QPS上千
  3. 实时性 --- 用户立即知道结果

核心设计:预生成 + Redis List

红包金额提前算好,存入Redis List。

发红包时预生成

go 复制代码
const (
    minAmount = 1  // 单份最小金额
)

// 普通红包:均分
func pregenNormal(total, count int64) ([]int64, error) {
    if count <= 0 {
        return nil, errors.New("count must be positive")
    }
    if total < count*minAmount {
        return nil, errors.New("total amount too small")
    }
    
    list := make([]int64, count)
    each := total / count
    for i := int64(0); i < count; i++ {
        list[i] = each
    }
    return list, nil
}

// 拼手气红包:二倍均值
func pregenLucky(total, count int64) ([]int64, error) {
    if count <= 0 {
        return nil, errors.New("count must be positive")
    }
    if total < count*minAmount {
        return nil, errors.New("total amount too small")
    }
    
    list := make([]int64, count)
    for i := int64(0); i < count; i++ {
        remainCount := count - i
        amount := doubleAvg(remainCount, total)
        total -= amount
        list[i] = amount
    }
    return list, nil
}

// 二倍均值算法
func doubleAvg(count, amount int64) int64 {
    if count == 1 {
        return amount
    }
    max := amount - minAmount*count
    avg := max / count
    avg2 := 2*avg + minAmount
    return rand.Int63n(avg2) + minAmount
}

存入Redis:

go 复制代码
func saveToRedis(id uint, amounts []int64) error {
    key := "rp:amounts:" + strconv.Itoa(int(id))
    vals := make([]interface{}, len(amounts))
    for i, v := range amounts {
        vals[i] = v
    }
    
    if err := rdb.RPush(ctx, key, vals...).Err(); err != nil {
        return fmt.Errorf("rpush amounts: %w", err)
    }
    if err := rdb.Expire(ctx, key, 24*time.Hour).Err(); err != nil {
        return fmt.Errorf("set expire: %w", err)
    }
    return nil
}

抢红包时原子取

go 复制代码
func popAmount(id uint) (int64, bool) {
    val, err := rdb.LPop(ctx, "rp:amounts:" + strconv.Itoa(int(id))).Result()
    if err != nil {
        if err == redis.Nil {
            // 红包抢完了
            return 0, false
        }
        log.Printf("LPOP error: %v, id=%d", err, id)
        return 0, false
    }
    amount, err := strconv.ParseInt(val, 10, 64)
    if err != nil {
        log.Printf("parse amount error: %v, val=%s", err, val)
        return 0, false
    }
    if amount <= 0 {
        return 0, false
    }
    return amount, true
}

LPOP是原子操作。不需要加锁,天然不会超发。


数据库二次校验

Redis是第一道防线,数据库再加一道:

go 复制代码
func updateBalance(id uint, amount int64) (int64, error) {
    rs := db.Model(&rp).Where(
        "remain_amount >= ? AND remain_quantity > 0 AND status = ?",
        amount, StatusSending,
    ).Updates(map[string]interface{}{
        "remain_amount":   gorm.Expr("remain_amount - ?", amount),
        "remain_quantity": gorm.Expr("remain_quantity - ?", 1),
    })
    return rs.RowsAffected, nil
}

WHERE条件:

  • remain_amount >= amount --- 剩余金额够扣
  • remain_quantity > 0 --- 还有剩余份数
  • status = sending --- 红包状态正常

条件不满足时 RowsAffected = 0,抢红包失败。


二倍均值算法

拼手气红包要随机金额,但不能出现0,也不能有人拿太多。

每次抢红包,随机范围是 [1, 2*剩余均值]

举例:100元分10人

  • 第1人:剩余均值=100/10=10,随机范围[1, 21],抽到15
  • 第2人:剩余均值=85/9=9.4,随机范围[1, 20],抽到8
  • ...
  • 最后一人:拿剩余全部

特点:

  • 每人至少拿1
  • 最多拿2倍均值
  • 越早抢方差越大

过期退款

红包有超时机制:

go 复制代码
func expire(detail *RedPackage) error {
    if err := updateStatus(detail.ID, StatusExpired); err != nil {
        return fmt.Errorf("update status: %w", err)
    }
    if _, err := refundRemain(detail.ID); err != nil {
        log.Printf("refund remain failed: %v, id=%d", err, detail.ID)
    }
    return nil
}

func refundRemain(id uint) (bool, error) {
    dbInfo, err := getDetail(id)
    if err != nil {
        return false, fmt.Errorf("get red package detail: %w", err)
    }
    if dbInfo.RemainAmount <= 0 {
        return false, nil
    }
    _, err = walletClient.Refund(ctx, &WalletReq{
        UserId:   dbInfo.UserId,
        Amount:   dbInfo.RemainAmount,
    })
    if err != nil {
        return false, fmt.Errorf("refund to wallet: %w", err)
    }
    return true, nil
}

数据

  • 单红包峰值QPS:2000+
  • 平均响应时间:<50ms
  • 超发次数:0(运行2年)

小结

  1. 预生成 --- 提前算好金额,存Redis List
  2. 原子取 --- LPOP防超发,不需要分布式锁
  3. 双重校验 --- 数据库WHERE条件二次保险
  4. 二倍均值 --- 拼手气红包随机算法
  5. 过期退款 --- 红包超时返还剩余金额

核心原则:不要在抢红包时做计算。能提前算的,都预生成。

复制代码
相关推荐
码事漫谈1 小时前
Graphify 简明指南
后端
代码羊羊1 小时前
rust-字符串(切片)、元组、结构体、枚举、数组
开发语言·后端·rust
JavaGuide2 小时前
万字详解 RAG 向量索引算法和向量数据库
后端·面试
舒一笑2 小时前
Docker 离线镜像导入后变成 <none>:<none>?一文讲透原因、排查与正确打包姿势
后端·docker·容器
Nyarlathotep01132 小时前
并发集合类(1):CopyOnWriteArrayList
java·后端
霸道流氓气质2 小时前
SpringBoot中调用mybatis方法提示映射文件未找到Invalid bound statement(not found)的奇葩解决
spring boot·后端·mybatis
Lucifer三思而后行2 小时前
Navicat Premium× 金仓数据库体验 - 数据生成深度体验
后端
Lucifer三思而后行2 小时前
MySQL 8 新特性:全局参数持久化!
后端