要求如下:
- 假设中奖率为 0.15% 需要尽可能实现 用户抽一万次 中奖15次
- 概率需要实现人维度公平
- 假设人维度 请求QPS不会超过 1w 不希望使用
很重的
锁逻辑 - 当中奖比率发生修改的时候 能够尽可能地进行调整
- 使用redis 实现
核心实现思路 使用redis Set、Incr
命令,key为用户id, value为int64值
origin_pool_cnt
指的是奖次大小 比如 奖池是 10000origin_lucky_num
指的是 中奖阈值 比如 15 则认为 小于该值都算中奖sample_flag
用于记录 中奖次数 该值等于低32位 / 10000
window
用于记录 用户抽奖次数 该值等于低32位 % 10000
高两位 | 15位 | 15位 | 16位 | 16位 |
---|---|---|---|---|
保留 | origin_pool_cnt | origin_lucky_num | sample_flag | window(小于10w) |
go
const (
// 写入redis的数字 |origin_pool_cnt|origin_lucky_num|sampled_flag|window
// [最高位2位 保留] [15位存储pool_cnt] [15位存储lucky_num] (高32位)|(低32位) [存储 used_count] [存储 window < 10w]
sampledFlag = int64(100000)
windowSize = int64(10000)
low32BitShift = 0xffffffff
luckyCntShift = 32
poolCntShift = luckyCntShift + 15
metaMark = 0x7fff
poolCntMark = 0x7fff << poolCntShift
luckyNumMark = 0x7fff << luckyCntShift
)
// 获取redis中实际窗口计数 window
func getRedisWindow(redisNum int64) int64 {
return (redisNum & low32BitShift) % sampledFlag
}
// 获取redis中实际中奖次数 sample_flag
func getRedisPoolCnt(redisNum int64) int64 {
return (redisNum & poolCntMark) >> poolCntShift
}
func buildShrinkDelta(windowDelta int64, usedCountDelta int64) int64 {
return -(windowDelta + usedCountDelta*sampledFlag)
}
func buildInitialRedisNum(poolCnt, luckyNum int64, delta int64) int64 {
return (((poolCnt & metaMark) << poolCntShift) | ((luckyNum & metaMark) << luckyCntShift)) + delta
}
- 每次用户请求则
window + 1
go
cmd := redisCli.Incr(key)
num, err := cmd.Result()
这时候会返回 num
,其中包含了 抽奖需要的所有信息
- redis中记录的奖池大小
- redis中记录的 origin_pool_cnt(第一次请求可能未设置)
- 当前用户中奖次数 sample_flag(第一次请求可能未设置)
- 当前用户抽奖次数 window
- 根据 最新的奖池大小
curPoolCnt
、curLuckyNum
以及 redis中返回的num
值 转化成一个中间结构体quotaManager
,方便后续判断
go
type quotaManager struct {
usedCount int64 // 已使用的额度
window int64 // 当前窗口位置 为避免window过大 超过 100000 的限制 需要定期重置
luckyNum int64 // 小于该值则命中
poolCnt int64 // 代表奖池的大小
originLuckyNum int64
originPoolCnt int64
outdated bool
}
func newQuotaManager(curLuckyNum int64, curPoolCnt int64, redisNum int64) *quotaManager {
lowBit := redisNum & low32BitShift
//从 redis中解析出原来记录的 pool_cnt
originPoolCnt := getRedisPoolCnt(redisNum)
// redis中解析出原来记录的 pool_cnt
originLuckyNum := getRedisLuckyNum(redisNum)
// 与当前值进行比较, 看相关配置是否发生变化,变化则认为数据过期
outdated := originPoolCnt > 0 && (originPoolCnt != curPoolCnt || originLuckyNum != curLuckyNum)
return "aManager{
usedCount: lowBit / sampledFlag,
window: lowBit % sampledFlag,
luckyNum: curLuckyNum,
poolCnt: curPoolCnt,
originLuckyNum: originLuckyNum,
originPoolCnt: originPoolCnt,
outdated: outdated,
}
}
- 判断出此次请求是否命中
go
// Bingo 判断是否命中
func (q *quotaManager) Bingo() bool {
if q == nil {
return false
}
// redis数据过期 认为不命中
if q.outdated {
return false
}
realWindow := q.window
realUsedCount := q.usedCount
// window 窗口可能还没有收缩 需要计算出本轮次实际的 window 和 sample_flag
if realWindow > q.poolCnt {
turn := realWindow / q.poolCnt
realWindow = realWindow - (turn * q.poolCnt)
realUsedCount = realUsedCount - (turn * q.luckyNum)
}
// 额度用完了
if realUsedCount >= q.luckyNum {
return false
}
leftQuota := q.luckyNum - realUsedCount
// 接近窗口尾部 此时强制命中 尽量保证中奖率和设置的接近
if realWindow+leftQuota >= q.poolCnt {
return true
}
// 本地随机
return fastrand.Int63n(q.poolCnt) < q.luckyNum
}
- 若此次请求中奖,则记录对应的中奖额度
go
// sampledFlag = 100000
cmd := redisCli.IncrBy(key, sampledFlag)
由于 上述自增window值 和 记录中奖额度是分两步完成的 可能出现超卖的情况 但是只是透支了未来的额度 当QPS < 1w 的前提下 是可以保证 整体的中奖率是符合设定的
- 判断当前redis中的值是否需要调整
go
type quotaChangeInfo struct {
changeType changeType
delta int64
resetVal int64
}
type changeType string
const (
changeTypeModify changeType = "modify" // 修正
changeTypeReset changeType = "reset" // 重置
)
func (q *quotaManager) ShouldAdjust() (info *quotaChangeInfo, needAdjust bool) {
if q == nil {
return nil, false
}
// redis中记录的数据 和 用户配置的抽样比例 不一致 需要重置
if q.outdated {
return "aChangeInfo{
changeType: changeTypeReset,
resetVal: buildInitialRedisNum(q.poolCnt, q.luckyNum, 0),
}, true
}
// 需要补上 origin_pool_cnt等信息 第一次请求尚未初始化对应的 origin_pool_cnt 等字段
if q.originPoolCnt == 0 {
return "aChangeInfo{
changeType: changeTypeModify,
delta: buildInitialRedisNum(q.poolCnt, q.luckyNum, 0),
}, true
}
// 窗口内的计数已经超过了奖池的大小*2 需要定时收缩 避免"溢出" 因为设计里 window的值需要小于 10w
if q.window >= q.poolCnt*2 {
return "aChangeInfo{
changeType: changeTypeModify,
delta: buildShrinkDelta(),
}, true
}
return nil, false
}
// buildShrinkDelta window 和 usedCount需要 同比收缩
func (q *quotaManager) buildShrinkDelta() int64 {
if q == nil || q.window < q.poolCnt {
return 0
}
// 原始 poolCnt = 10 luckyNum = 4 window = 15 usedCount = 5
// 则 缩小后 window = 5 usedCount = 1
turn := q.window / q.poolCnt
deltaWindows := turn * q.poolCnt
deltaUsedCount := turn * q.luckyNum
return buildShrinkDelta(deltaWindows, deltaUsedCount)
}
- 如果发现需要调整(低频操作) 则
加redis 锁
调整
go
func doAdjustQuotaInfo(ctx context.Context, sampleProportionKey string, changeInfo *quotaChangeInfo) {
if changeInfo == nil {
return
}
getLock()
defer func(){
releaseLock()
}()
switch changeInfo.changeType {
case changeTypeModify:
// 调整
cmd := redisCli.IncrBy(sampleProportionKey, changeInfo.delta)
case changeTypeReset:
// 重置
cmd := redisCli.Set(sampleProportionKey, changeInfo.resetVal, oneMonthDuration)
}
}