以微信红包为例,分为如下流程:
发红包;
抢红包;
红包退回。
设置金额和份数,完成支付,红包详情入库,为红包生成一个唯一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还可以部署集群。
对于超过一定时间未抢的红包要退回到发红包者的账户中。
发红包时添加延时队列,红包到期后去检查红包是否已抢完,没抢完的根据红包纪录中的已抢数量、金额返回未抢金额到发红包用户余额中。
未防止延时队列不可靠,可同步增加定时轮训脚本去监控超时未抢完的红包。