抢红包系统设计

业务流程

以微信红包为例,分为如下流程:

  1. 发红包;

  2. 抢红包;

  3. 红包退回。

发红包

设置金额和份数,完成支付,红包详情入库,为红包生成一个唯一ID。

红包拆分时机

红包金额拆分分为实时拆分提前拆分,实时拆分对系统性能和拆分算法要求较高,在拆分时既要保证线程安全,又要保证并发效率,开发难度较大。而且拆分过程中要一直保证后续待拆分红包的金额不能为0,不容易做到拆分的红包金额服从正态分布规律。

大型红包系统一般选择提前拆分,从而降低系统复杂度。

红包拆分算法

使用二倍均值算法能够在不论谁先抢的情况下, 都能公平保证每个人抢到平均金额的概率是相等的。假设红包总金额是M,红包个数为N,每个红包的最低金额是0.01元。 那么每次抢到的红包金额的范围在 [0.01, (M / N) * 2] 之间。

算法代码如下:

/**
 * 红包拆分二倍均值算法
 * @param $coin int 总金额
 * @param $num int 红包数量
 * @return array
 * @throws Throwable
 */
public function splitRedEnvelop(int $coin, int $num): array
{
    $results = [];
    // 检查总金额和红包数量的有效性
    if ($coin <= 0 || $num <= 0) {
        return $results;
    }
    // 将总金额转换为“分”以避免浮点数计算问题,并使用BCMath进行精确计算
    $coin = bcmul($coin, '100');
    $remainCoin = $coin;
    $remainNum = $num;
    for ($i = 0; $i < $num - 1; $i++) {
        // 计算平均值的两倍
        $avg = bcmul(bcdiv($remainCoin, $remainNum, 2), 2);
        // 最小值设为1分,最大值设为剩余平均值的两倍减去最后一个包至少能分配到的1分钱
        $amount = mt_rand(1, (int)($avg - 1));
        // 更新剩余金额和红包数量
        $remainCoin = bcsub($remainCoin, $amount);
        $remainNum--;
        // 将金额添加到结果集中,并转换回元
        $results[] = bcdiv($amount, '100', 2); // 设置小数点后保留两位
    }
    // 最后一个红包直接将剩余金额全部分配出去
    $results[] = bcdiv($remainCoin, '100', 2);
    return $results;
}

红包存储

为了应对用户高并发的请求, 也就是需要频繁读取红包金额和数量, 所以将红包金额和数量存储在Mysql中是不行的, 所以只能借助基于内存的Redis数据库来支持高并发的读取操作. 红包金额使用list队列存储, 红包数量用String键值对存储,在发红包时, 我们先用二倍均值算法随机生成一定数量的红包金额, 然后将红包金额和红包数量存入Redis缓存中,等待用户抢红包

1、发红包纪录入库获得自增ID

2、使用二倍均值算法拆分红包,生成红包金额

$splitCoin = $this->splitRedEnvelop($coin, $number);

3、将红包金额放入redis队列

foreach ($splitCoin as $val) {
    RedisHelper::lpush(CacheKey::RED_ENVELOP_LIST . $model->id, $val);
}

# 有效期设为1天
RedisHelper::expire(CacheKey::RED_ENVELOP_LIST . $model->id, 86400);

4、将红包个数放入redis string键值对

RedisHelper::set(CacheKey::RED_ENVELOP_REMAIN_NUMBER . $model->id, $number);
RedisHelper::expire(CacheKey::RED_ENVELOP_REMAIN_NUMBER . $model->id, 86400);

5、将红包id返回

抢红包

抢红包模块流程图

抢红包是整个流程中并发最高的阶段,对系统的性能要求最高。在此阶段,至少要做如下业务校验:

  • 红包还有没有剩余,有剩余才能抢;

  • 已经抢过的人不能重复抢。

不能重复抢可以通过客户端交互界面限制,但是碰到直接调接口的“高端玩家”就没办法了,所以还是需要后端校验。

整体的伪代码逻辑:

try {
    # 1、加锁
    if (!RedisHelper::setnx(CacheKey::RED_ENVELOP_LOCK . $uid, 1, 10)) {
        throw new SelfException('请勿重复领取');
    }

    # 2、判断用户是否已经抢过该红包
    if (RedisHelper::exists(CacheKey::RED_ENVELOP_USER_IS_RECEIVE . $uid . ':' . $id)) {
        throw new SelfException('您已经领过该红包');
    }

    # 3、判断是否还有红包
    $remainNumber = RedisHelper::get(CacheKey::RED_ENVELOP_REMAIN_NUMBER . $id);
    if (empty($remainNumber) || $remainNumber <= 0) {
        throw new SelfException('该红包已被抢完');
    }

    # 处于安全考虑可以获取红包信息从数据库层面二次验证

    # 4、从队列中出一个红包金额, 如果不为空,则说明抢到了
    $coin = RedisHelper::rpop(CacheKey::RED_ENVELOP_LIST . $id);
    if (empty($coin) || !is_numeric($coin)) {
        throw new SelfException('红包已被抢完');
    }

    # 5、红包个数减1
    RedisHelper::decrby(CacheKey::RED_ENVELOP_REMAIN_NUMBER . $id, 1);

    # 6、设置该用户已经抢过红包
    RedisHelper::set(CacheKey::RED_ENVELOP_USER_IS_RECEIVE . $uid . ':' . $id, $coin, 86400);

    # 存储抢红包记录,更新红包纪录信息(更新已抢数量、金额等)| 这个地方可以考虑异步实现

    # 7、返回抢到的金额
    return $coin;

} finally {
    # 8、删除锁
    RedisHelper::del(CacheKey::RED_ENVELOP_LOCK . $uid);
}

redis的数据全部保存在内存中,查询效率非常高,所以能支撑大量的并发,为了应对高并发,redis还可以部署集群。

超时红包退回

对于超过一定时间未抢的红包要退回到发红包者的账户中。

发红包时添加延时队列,红包到期后去检查红包是否已抢完,没抢完的根据红包纪录中的已抢数量、金额返回未抢金额到发红包用户余额中。

未防止延时队列不可靠,可同步增加定时轮训脚本去监控超时未抢完的红包。