预算、疲劳度通用设计
业务场景分析
通常业务都有控制预算或疲劳度的需求:
- 预算:每天最多发放N张优惠券、每月最多发放价值1000元的金币,……
- 疲劳度:每小时最多曝光N次,每个用户每天最多展示N次,……
本质抽象出来,就是属于“限流”中的“计数器模型”,在指定时间段内,请求数量累加,有数量的最大限制。
整体思路分析
只会做2件事情:
- 计数:每次曝光后、每次发放优惠券后,都会按照不同维度进行计数
- 判断:每次曝光前、每次发放优惠券前,按不同维度,对比实时值和配置的最大值,如果超过则拒绝请求
挑战
1、数据太多,如何在“快速地进行累加和判断”和“海量存储”之间取舍?
2、如果配置的最大值可以随时调整,什么数据结构能够适应这种调整?
3、维度太多,如何巧妙设计存储的请求数据,来适应不同维度上的限制?
解决方案
普通自然周期
普通自然周期,指的是要求的计数周期是“秒”、“整分钟”、“整小时”、“自然天”、“自然周”、“自然月”、“自然
年”这种完美的周期。
- 优点:每个用户看到的周期都是相同的,设计上只需要根据自然周期来操作。比如自然天,设计上就可以以天作为id或者key。
- 缺点:每个周期切换的时候,会有瞬间的落差。比如“小红点每个月展示1次”,我在3.31 23:59:59看到了小红点,清除掉了,然而在4.100:00:00又再次看到了小红点,体验很不好。所以一般情况下,普通自然周期非常适合“预算”这样需要按自然周期计数的场景,而不适合“疲劳度”这样对附近时间段有感知的场景。
方案1 数据库 完整存储
数据库设计略过,很容易处理。注意选择高性能的数据库,以及考虑分库分表。
可以记录完整的每个请求,也可以记录自然周期的统计值。
方案2 Redis 自然周期key
按自然周期,只要把自然周期放在key里面就相当于自动轮转,所以很容易设计:
- key:自然周期_业务ID_其他维度
- value:Long,从0开始计数,汇总值
- 过期时间:自然周期过期。比如自然周期是1天,则1天后过期。
示例:
- key:2022-03-21_优惠券123456
- value:发放了233300张
- 过期时间:2022-03-22 00:00:00过期
需要注意:
- 由于redis并不是存储的每一条记录,而是存储的汇总值,因此需要提前对所有可能的周期进行累加。比如最开始可能只有天周期控制,后面如果切换为了月周期控制,月周期计数不能从0开始。所以一开始就要对所有可能的周期进行累加,方便切换。
- 维度过多时,redis的key可能会很多。比如维度可能有:活动ID、场次ID、用户ID等,可能会产生大量的key。如果接受不了维度太多,可以考虑采用后面Redis的“平滑窗口”方案
最近x周期
疲劳度场景,通常都会有“最近1小时最多曝光1次”这样的需求。或者是对于预算场景,我们希望能够控制“0点-6点最多发放10000张;6点-12点最多发放20000张,12点-24点最多发放30000张”这样为了平滑预算消耗分布、防止瞬间刷完一天的预算产生的需求,而且这些时间点和预算最大值可以随时调整。
方案1 数据库 完整存储
如果是“最近30天”这种太长时间,用Redis的话存储费用会很高,只能选择数据库。保证使用高性能数据库即可。
方案2 Redis zset 滑动窗口
如果是“最近2天”这种时间较短的,可以使用redis.用zset构建滑动窗口,以时间为score,存储用户这段时间内的操作,就能求得任意周期的累计值。
1、key设计
如果是用户,就是userld;如果是优惠券,就是voucherld。
然后可以加上其他维度,比如如果以次数统计,就加后缀_times
,如果以金额统计,就加后缀_amount
。
2、插入数据
1 | jedis.zadd(key, System.currentTimeMillis(), 业务数据); |
3、查询任意周期
1 | long now = System.currentTimeMillis(); |
4、清除多余数据
zset是没有每个item的过期时间配置的,因此需要手动清除过期数据。有2种方案:
- 如果key有规律,定时扫描。如果key不是用户维度,也是固定维度,那么可以用zscan定时扫描所有key,清除过期数据。
- 如果key没有规律,插入的时候执行清除。在插入数据的时候检查:
1
2
3
4
5
6
7// 清除N天前的数据
jedis.zremrangeByScore(key, 0, System.currentTimeMillis() - N天);
// 虽然都是同一天的,但是集合中保存了太多数据,清除最早的数据
long size = jedis.zcard(key);
if (size > 500) {
jedis.zremrangeByRank(key, 0, size - 500 -1);
}
5、数据压缩
当以userld为key时,每个key内集合的数据量较小,还可以接受。但如果是以voucherld为key,有大量用户每时每刻都在领取的记录,无法放在集合里,此时需要做数据压缩。
key空间分段
key增加时间后缀,比如1小时,当查询某段时间时,构造多个key进行查询:1
2
3jedis.zcount("00:00", start, now);
jedis.zcount("01:00", start, now);
jedis.zcount("02;00", start, now);这样,每个分段的数据可以足够少
score压缩
如果不压缩key,可以压缩score。我们之前记录的score是毫秒。如果数据精度要求不高(比如只按照小时调整),那么完全可以记录比精度低一级的数据(比如10分钟)1
2long now = System.currentTimeMillis();
long timestamp = now - now % Duration.ofMinutes(10).toMillis();这样,集合内的数据会足够少。甚至,我们可以用其他的数据结构来实现,比如hash:
1
jedis.hincrBy(key, String.value0f(timestamp), value);
在读取的时候,同样需要获取多个数据进行求和:获取zset中符合条件的全部数据,或者hash表中符合条件的全部field
预算、疲劳度通用设计