yiplee's Blog

开发笔记

微信抢红包简易实现 Go + Gorm

Posted at — Aug 2, 2020

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

金额的分配,可以采用在抢的时候实时计算的方式得出,这样会节省存储空间,特别是包的个数很大的时候。 微信红包的分配算法很简单,是在最小值(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;

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 的内存缓存服务,把抢这个步骤也放在内存执行。

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