预算、疲劳度通用设计

业务场景分析

通常业务都有控制预算或疲劳度的需求:

  • 预算:每天最多发放N张优惠券、每月最多发放价值1000元的金币,……
  • 疲劳度:每小时最多曝光N次,每个用户每天最多展示N次,……
    本质抽象出来,就是属于“限流”中的“计数器模型”,在指定时间段内,请求数量累加,有数量的最大限制。

整体思路分析

budget_fatigue_model.jpg

只会做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
2
jedis.zadd(key, System.currentTimeMillis(), 业务数据);
jedis.pexpire(key, EXPIRE_TIME);

3、查询任意周期

1
2
3
long now = System.currentTimeMillis();
long startTime = now - Duration,ofHours(hours).toMillis();
return redisClient.useJedis(jedis-> jedis.zcount(key, startTime, now).intValue());

4、清除多余数据
zset是没有每个item的过期时间配置的,因此需要手动清除过期数据。有2种方案:

  1. 如果key有规律,定时扫描。如果key不是用户维度,也是固定维度,那么可以用zscan定时扫描所有key,清除过期数据。
  2. 如果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,有大量用户每时每刻都在领取的记录,无法放在集合里,此时需要做数据压缩。

  1. key空间分段
    key增加时间后缀,比如1小时,当查询某段时间时,构造多个key进行查询:

    1
    2
    3
    jedis.zcount("00:00", start, now);
    jedis.zcount("01:00", start, now);
    jedis.zcount("02;00", start, now);

    这样,每个分段的数据可以足够少

  2. score压缩
    如果不压缩key,可以压缩score。我们之前记录的score是毫秒。如果数据精度要求不高(比如只按照小时调整),那么完全可以记录比精度低一级的数据(比如10分钟)

    1
    2
    long now = System.currentTimeMillis();
    long timestamp = now - now % Duration.ofMinutes(10).toMillis();

    这样,集合内的数据会足够少。甚至,我们可以用其他的数据结构来实现,比如hash:

    1
    jedis.hincrBy(key, String.value0f(timestamp), value);

    在读取的时候,同样需要获取多个数据进行求和:获取zset中符合条件的全部数据,或者hash表中符合条件的全部field

预算、疲劳度通用设计

https://www.bananaoven.com/posts/500/

作者

香蕉微波炉

发布于

2023-04-07

更新于

2023-04-07

许可协议