Redis系列八 抢红包

形而上者谓之道;形而下者谓之器。(《周易·系辞上》)

本文概述

  1. 掌握红包的两种常见生成算法
  2. 掌握lua+redis 实现原子性抢红包
  3. 项目中还有mysql相关内容
  4. 了解jmeter的基本用法
  5. 遗留问题
    1. redis同步DB时机问题

红包生成算法

普通随机方法

该方法的原理是:每次都以 [最小值,剩余金额值] 之间进行随机取值。
假设红包金额为 88.88,红包数量为 8 个

  1. 第一个人领取金额将从 [0.01, 88.88] 之间进行取值,假设取值为 20.20,那么剩余的金额为 68.68。
  2. 第二个领取金额将从 [0,01, 68.68] 之间进行取值,
  3. 以此类推…

这里可以明显看出此方法的弊端,前面领取红包的金额区间更大,也就更容易获取更大的红包金额。下面看二倍均值法的原理。

二倍均值法 — 公平版

原理:每次以 [最小值,红包剩余金额 / 人数 * 2] 的区间进行取值。

假设100元红包发10个人,那么合理的做法应该是每个人领到10元的概率相同。
第一个人随机金额的范围为[0,100/10×2] ,也就是[0,20],这样平均可以领到10元,此时剩余金额为100-10=90。
第二个人随机金额的范围为[0,90/9×2] ,也就是[0,20],这样平均也可以领到10元,此时剩余金额为90-10=80。
第三个人随机金额的范围为[0,80/8×2] ,也就是[0,20],这样平均也可以领到10元。

该方法也不是完美的,上述是非常理想情况下红包的领取金额,同时每个人获取金额区间相对公平。但是当其中一个人在区间取值接近最小值或者最大值都会对后面的区间造成影响。当取到接近最小值时,后面领取红包金额区间将会变大;反之,则变小。这也是该方法的弊端。

截线段法 — 拼手速版

假设

  1. 问题:十人分一段十米长绳子,先到先截取。
  2. 前提:人人理性、利己
  3. 分析:
    1. 第一个人可以直接拿走,问题结束。这样太没意思了,就算拿5m;
    2. 第二个人最多可以拿5m;
    3. 一次类推,越往后,可选择越短,直到没了绳子。

代码部分

随机整数 【含最大值,含最小值】

function randomInt(min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}

二倍均值法

/**
* 1. 二倍均值法
*
* !核心:红包 = 随机([最小值,(红包剩余金额 / 人数 * 2)])
*
*
* @param totalAmount 总金额 单位:分
* @param redPacketNum 总人数
* @return 小红包值【单位:分】
*
*
*/
export function fairMethod(totalAmount: number, redPacketNum: number) {
// 无效数据
if (totalAmount < 0 && redPacketNum < 0) throw '金额错误';
let curTotalAmount = totalAmount;
if (curTotalAmount / redPacketNum < 1) throw '最低每人0.1元';
// 最小值 单位:分
const min = 1;
// 剩余人数
let _redPacketNum = redPacketNum;
// 结果单位
const reslut: number[] = [];
while (_redPacketNum > 1) {
const max = (curTotalAmount / _redPacketNum) * 2;
// 红包金额
const amount = randomInt(min, max);
// 金额-、人数-
curTotalAmount -= amount;
_redPacketNum--;
reslut.push(amount);
}
// 最后金额为最后一个红包
reslut.push(curTotalAmount);
return reslut;
}

拼手速版本

/**
* 拼手速版本
* @param totalAmount 总金额; 单位分
* @param redPacketNum 红包个数
*/
export function speedMethod(totalAmount: number, redPacketNum: number) {
// 无效数据
if (totalAmount < 0 && redPacketNum < 0) throw '金额错误';
const curTotalAmount = totalAmount;
if (curTotalAmount / redPacketNum < 1) throw '最低每人0.1元';
let [ begin, end ] = [ 0, curTotalAmount ];
// 剩余
let _redPacketNum = redPacketNum;
// 结果
const result: number[] = [];
while (_redPacketNum > 1) {
// 如果红包发完了,就直接返回0
if (begin === end) {
result.push(0);
} else {
// 如果 起止间隔1,得特殊补充一下,否则会少1分,《原因在于math.randomInt(99, 100),总是返回99》
if (end === begin + 1) {
begin++;
result.push(1);
} else {
// 起止位置中 挑个点
const randomPoint = randomInt(begin, end);
// console.log("begin", begin);
// console.log("end", end);
// console.log("randomPoint", randomPoint);
// 亮点之间的距离作为 红包金额
const amount = randomPoint - begin;
// 更改起点
begin += amount;
result.push(amount);
}
}
_redPacketNum--;
}
// 最后一个兜底
result.push(end - begin);
return result;
}

测试结果

// !结果
/**
拼手速红包: [ 66, 8, 13, 0, 10, 0, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] -- 100 - 20
拼手速红包: [ 52, 2, 42, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] -- 100 - 20
拼手速红包: [ 50, 0, 45, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] -- 100 - 20
拼手速红包: [ 90, 2, 3, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] -- 100 - 20
拼手速红包: [ 61, 2, 25, 6, 0, 1, 1, 1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] -- 100 - 20

公平红包 : [ 6, 7, 2, 7, 4, 1, 7, 1, 6, 1, 9, 4, 10, 6, 8, 3, 8, 5, 1, 4 ] -- 100 - 20
公平红包 : [ 7, 1, 1, 1, 6, 6, 11, 8, 6, 4, 5, 5, 2, 9, 1, 1, 1, 9, 8, 8 ] -- 100 - 20
公平红包 : [ 6, 5, 8, 5, 6, 7, 7, 3, 7, 6, 4, 7, 1, 1, 1, 5, 1, 11, 4, 5 ] -- 100 - 20
公平红包 : [ 4, 7, 4, 8, 7, 5, 4, 8, 8, 1, 7, 2, 2, 6, 3, 4, 3, 3, 2, 12 ] -- 100 - 20
公平红包 : [ 9, 3, 2, 6, 3, 8, 6, 5, 2, 4, 10, 7, 5, 1, 6, 5, 8, 1, 7, 2 ] -- 100 - 20
*/

redis+lua 抢红包实现

有了就红包算法,结合 redis+lua, 就可以实现一个抢红包功能了

整体步骤

采用事先生成小红包的方式。

  1. 生成红包数据
    1. 插入红包表 DB
    2. 生成红包算法
    3. 生成红包&用户表 DB
    4. 插入待消费队列 redis
  2. redis相关
    1. 待消费队列: 生成红包时插入redis
    2. 集合 set: 存储抢红包的用户ID;
    3. 消费队列: 从待消费队列pop消费队列
  3. 最后同步到:红包&用户表

代码部分

支持三种类型的红包生成方式

  1. 公平版
  2. 手速版
  3. 固定版

lua

写代码离不开debug, lua也不例外,不会的左转上篇文章有调试的教程。

-- 函数:尝试获得红包,如果成功,则返回json字符串,如果不成功,则返回空
---
-- 参数:
--- KEYS[1-3] 未消费队列名、已消费的队列名、hset key <集合: 去重用户>
--- ARGV[1] 用户ID
-- 返回值:nil 或者 json字符串
---- {userId: xxx, packetId: xxx, amount: 11} : 用户ID:userId,红包ID:packetId,红包金额:money

-- 用户是否抢过
local judge = redis.call("SISMEMBER", KEYS[3], ARGV[1])
if judge ~= 0 then
return 100 -- 已经抢过了
else
local item = redis.call("RPOP", KEYS[1]) -- 先取出一个小红包
if item then
local _item = cjson.decode(item)
_item["userId"] = ARGV[1] -- 加入用户ID信息
local newItem = cjson.encode(_item)
redis.call("SADD", KEYS[3], ARGV[1]) -- 把用户ID放到去重的set里
redis.call("LPUSH", KEYS[2], newItem) -- 把红包放到已消费队列里
return newItem
else
return 200 -- 红包队列已经为空
end
end
return nil

抢红包

/**
* redis+lua抢红包
* @param packetId
* @param userId
*/
public async getRedPacket_redis_lua(packetId: number, userId: number) {
const redisDefalueKey = `${RP_DEFALUT_LIST}${packetId}`;
const redisConsumeKey = `${RP_CONSUME_LIST}${packetId}`;
const redisSetKey = `${RP_USER_SET}${packetId}`;
// lua
const filePath = path.join(__dirname, '../bin/redpacket.lua');
const luaScript = fs.readFileSync(filePath, "utf8");
const result = await this.app.redis.eval(luaScript, 3, redisDefalueKey, redisConsumeKey, redisSetKey, userId);
if(result === 100){
console.log('已经抢过了~', userId)
}
if(result === 200){
console.log('红包空了', userId)
}
if(typeof result === 'string') {
console.log('抢红包成功', result)
}
return result;
}

测试

redis 数据结构
jmeter 测试
# 结果
抢红包成功 {"id":71,"userId":"16","rpId":9,"amount":17}
抢红包成功 {"id":72,"userId":"5","rpId":9,"amount":14}
抢红包成功 {"id":73,"userId":"18","rpId":9,"amount":11}
已经抢过了~ 18
抢红包成功 {"id":74,"userId":"19","rpId":9,"amount":16}
抢红包成功 {"id":75,"userId":"13","rpId":9,"amount":10}
抢红包成功 {"id":76,"userId":"1","rpId":9,"amount":1}
抢红包成功 {"id":77,"userId":"4","rpId":9,"amount":15}
抢红包成功 {"id":78,"userId":"14","rpId":9,"amount":2}
抢红包成功 {"id":79,"userId":"3","rpId":9,"amount":6}
已经抢过了~ 13
已经抢过了~ 13
已经抢过了~ 19
抢红包成功 {"id":80,"userId":"8","rpId":9,"amount":8}
红包空了 15
已经抢过了~ 8
已经抢过了~ 1
已经抢过了~ 19
已经抢过了~ 4
红包空了 6
红包空了 7

jmeter的强大功能后续再摸索

源码

因为后续还会往这个项目中加其他redis业务,采用 egg+ts

https://github.com/simuty/Integration/blob/main/Redis/

参考

并发 - 利用redis + lua解决抢红包高并发的问题
[高并发]抢红包设计(使用redis)
抢红包算法–四种抢红包算法对比
抢红包的红包生成算法
微信红包生成算法
抢红包算法(公平版和手速版)
红包分配算法