微信红包大家都很熟悉,算是秒杀系统的一种,实现的时候主要需要考虑两点:
金额的分配,可以采用在抢的时候实时计算的方式得出,这样会节省存储空间,特别是包的个数很大的时候。 微信红包的分配算法很简单,是在最小值(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;
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 的内存缓存服务,把抢这个步骤也放在内存执行。
把抢红包和给用户打钱分开执行。用单独的服务,扫描新增的红包领取记录,然后处理转账。