场景题: 使用 redis 实现某抽奖活动人维度抽样逻辑

要求如下:

  • 假设中奖率为 0.15% 需要尽可能实现 用户抽一万次 中奖15次
  • 概率需要实现人维度公平
  • 假设人维度 请求QPS不会超过 1w 不希望使用很重的锁逻辑
  • 当中奖比率发生修改的时候 能够尽可能地进行调整
  • 使用redis 实现

核心实现思路 使用redis Set、Incr 命令,key为用户id, value为int64值

  • origin_pool_cnt 指的是奖次大小 比如 奖池是 10000
  • origin_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
}
  1. 每次用户请求则window + 1
go 复制代码
	cmd := redisCli.Incr(key)
	num, err := cmd.Result()

这时候会返回 num,其中包含了 抽奖需要的所有信息

  • redis中记录的奖池大小
  • redis中记录的 origin_pool_cnt(第一次请求可能未设置)
  • 当前用户中奖次数 sample_flag(第一次请求可能未设置)
  • 当前用户抽奖次数 window
  1. 根据 最新的奖池大小curPoolCntcurLuckyNum 以及 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 &quotaManager{
		usedCount: lowBit / sampledFlag,
		window:    lowBit % sampledFlag,
		luckyNum:  curLuckyNum,
		poolCnt:   curPoolCnt,

		originLuckyNum: originLuckyNum,
		originPoolCnt:  originPoolCnt,
		outdated:       outdated,
	}
}
  1. 判断出此次请求是否命中
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
}
  1. 若此次请求中奖,则记录对应的中奖额度
go 复制代码
        // sampledFlag = 100000
	cmd := redisCli.IncrBy(key, sampledFlag)

由于 上述自增window值 和 记录中奖额度是分两步完成的 可能出现超卖的情况 但是只是透支了未来的额度 当QPS < 1w 的前提下 是可以保证 整体的中奖率是符合设定的

  1. 判断当前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 &quotaChangeInfo{
			changeType: changeTypeReset,
			resetVal:   buildInitialRedisNum(q.poolCnt, q.luckyNum, 0),
		}, true
	}

	// 需要补上 origin_pool_cnt等信息 第一次请求尚未初始化对应的 origin_pool_cnt 等字段
	if q.originPoolCnt == 0 {
		return &quotaChangeInfo{
			changeType: changeTypeModify,
			delta:      buildInitialRedisNum(q.poolCnt, q.luckyNum, 0),
		}, true
	}

	// 窗口内的计数已经超过了奖池的大小*2 需要定时收缩 避免"溢出" 因为设计里 window的值需要小于 10w
	if q.window >= q.poolCnt*2 {
		return &quotaChangeInfo{
			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)
}
  1. 如果发现需要调整(低频操作) 则加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)
	}
}
相关推荐
我是大咖1 小时前
c语言笔记 一维数组与二维数组
c语言·笔记·算法
誓约酱1 小时前
(每日一题) 力扣 283 移动零
linux·c语言·数据结构·c++·算法·leetcode
浪遏1 小时前
面试官:字符串反转有多少种实现方式 ?| 一道题目检测你的基础
前端·面试
码农CV1 小时前
Java基础面试题全集
java·面试
IT、木易1 小时前
大白话CSS 优先级计算规则的详细推导与示例
前端·css·面试
Luis Li 的猫猫1 小时前
基于Matlab的人脸识别的二维PCA
开发语言·人工智能·算法·matlab
技术蔡蔡2 小时前
Android多线程开发之线程安全
android·面试
福鸦2 小时前
C++ STL深度解析:现代编程的瑞士军刀
开发语言·c++·算法·安全·架构
仟濹2 小时前
【算法 C/C++】一维前缀和
数据结构·c++·算法