原创分享 微信抢红包简易实现 Go + Gorm

yiplee-github · 2020年08月03日 · 107 次阅读

微信红包大家都很熟悉,算是秒杀系统的一种,实现的时候主要需要考虑两点:

  • 手气红包金额的分配,做到尽可能的随机和公平。
  • 并发抢情况的处理,既要控制好开出的红包的个数和金额,也要保证抢的效率。

金额的分配,可以采用在抢的时候实时计算的方式得出,这样会节省存储空间,特别是包的个数很大的时候。 微信红包的分配算法很简单,是在最小值(0.01 元)和剩余的平均金额的两倍之间随机选取;这里要注意控制最大值, 要给剩下的包每个至少留 0.01 元。

红包发出来的时候,可能会有很多人同时点开抢,这里可以用 CAS 操作保证单个包只会被一个用户抢到。CAS 即比较版本然后修改, 红包的剩余个数这个值,就是一个完美的版本号,CAS 操作也可以基于数据库比如 Mysql 的行锁简单的实现。

UPDATE packets SET remain_count = 9 AND remain_amount = "100" where id = "id" AND remain_count = 10;
  • 收到抢红包请求,从 db 读出对应的红包信息。
  • 检查是否已经抢过了,如果已经抢过了,直接返回这条记录。
  • 检查红包剩余个数,如果已经抢光了,直接返回已领取完的状态。
  • 通过上述金额分配算法,算出开出的金额;如果是最后一个,直接全部领取。
  • 更新红包的剩余个数和金额,更新的时候,要把剩余个数作为版本加入判断更新条件。
  • 如果更新成功,将这条领取记录入库,领取结束。
  • 如果更新失败,比如 Mysql 返回 RowsAffected 是 0,就表示在数据库,这个红包已不是这个剩余个数了,简单说就是被别人抢了。
  • 等待几十毫秒,重新读取红包信息,重复最开始的步骤。

Demo

golang + gorm 的抢红包简单实现。详细版本见 yiplee/packet-demo

func Claim(ctx context.Context, db *gorm.DB, packet *Packet, userID int64) (*Record, error) {
    // 检查剩余个数
    if packet.RemainCount == 0 {
        return nil, ErrExhausted
    }

    // 检查是否已经抢过了
    if r, err := FindUserRecord(db, userID, packet.ID); err == nil {
        return r, nil
    }

    r := &Record{
        UserID:   userID,
        PacketID: packet.ID,
    }

    switch {
    case packet.RemainCount == 1: // 最后一个包
        r.Amount = packet.RemainAmount
    case packet.Mode == Normal: // 平均分配
        r.Amount = packet.RemainAmount.Div(decimal.NewFromInt(packet.RemainCount))
    case packet.Mode == Luck:
        // 手气红包,在最小值和剩余平均值 * 2 之间随机选取
        // 要注意最大值,需要至少给剩下的人留一个最小值
        avg := packet.RemainAmount.Div(decimal.NewFromInt(packet.RemainCount))
        min := minimumRecordAmount
        max := avg.Add(avg)
        if Max := packet.RemainAmount.Sub(decimal.NewFromInt(packet.RemainCount - 1).Mul(min)); max.GreaterThan(Max) {
            max = Max
        }

        random := decimal.NewFromFloat(rand.Float64())
        r.Amount = max.Sub(min).Mul(random).Add(min).Truncate(min.Exponent())
    }

    packet.RemainAmount = packet.RemainAmount.Sub(r.Amount)
    if err := transaction(db, func(tx *gorm.DB) error {
        updates := map[string]interface{}{
            "remain_count":  packet.RemainCount - 1,
            "remain_amount": packet.RemainAmount,
        }

        // 这里在更新 packet 的时候在 Where 加了剩余个数的判断
        // 如果这个个数的红包已经被别人抢了,这里会更新失败, RowsAffected 返回 0
        if tx := tx.Model(packet).Where("id = ? AND remain_count = ?", packet.ID, packet.RemainCount).Updates(updates); tx.Error != nil {
            return tx.Error
        } else if tx.RowsAffected == 0 {
            return ErrOptimisticLock
        }

        // packet 更新成功,将记录入库
        return tx.Create(r).Error
    }); err != nil {
        // 被别人抢了,等待 50ms 继续抢
        if err == ErrOptimisticLock {
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(50 * time.Millisecond):
                // 获取最新的 packet
                packet, err := FindPacket(db, packet.ID)
                if err != nil {
                    return nil, err
                }

                // 继续抢
                return Claim(ctx, db, packet, userID)
            }
        }

        return nil, err
    }

    return r, nil
}

优化建议

如果并发太高数据库压力大,可以将红包放入缓存,先从缓存取红包信息,如果已经领完了, 就不需要再把流量打到数据库了。

更进一步,可以采用独立的支持 CAS 的内存缓存服务,把抢这个步骤也放在内存执行。

把抢红包和给用户打钱分开执行。用单独的服务,扫描新增的红包领取记录,然后处理转账。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册