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

做直播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. 过期退款 --- 红包超时返还剩余金额

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

复制代码
相关推荐
llz_11241 分钟前
web-第二次课后作业
前端·后端·web
红尘散仙7 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记8 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
会编程的土豆8 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
喵个咪9 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball6169 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang
qq_2518364579 小时前
SpringBoot+Vue 共享电池柜管理系统 完整实现 前后端分离项目实战 完整代码
vue.js·spring boot·后端
zhangxingchao9 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
IT_陈寒11 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端
ayqy贾杰12 小时前
基层管理的三板斧,在AI时代行不通了
前端·后端·团队管理