做直播App,红包是最容易出问题的功能。几百人同时抢,一旦超发,平台直接亏钱。
这篇文章记录红包系统的设计思路。
需求
直播间红包场景:
- 主播发红包,几百上千人同时抢
- 抢到获得平台货币
- 红包总金额固定,不能多发
三种红包类型:
- 普通红包 --- 均分
- 拼手气红包 --- 随机金额
- 抽奖红包 --- 随机抽人
约束:
- 不能超发 --- 发100份就是100份
- 高并发 --- 抢红包瞬间QPS上千
- 实时性 --- 用户立即知道结果
核心设计:预生成 + 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年)
小结
- 预生成 --- 提前算好金额,存Redis List
- 原子取 --- LPOP防超发,不需要分布式锁
- 双重校验 --- 数据库WHERE条件二次保险
- 二倍均值 --- 拼手气红包随机算法
- 过期退款 --- 红包超时返还剩余金额
核心原则:不要在抢红包时做计算。能提前算的,都预生成。