基于Redis的频次控制能力实现

引言

公司业务上目前存在许多发送给用户的消息,例如PUSH,短信等,我们将其统称为触达行为。假如不对其加以约束,过多的触达消息不但不能带来流量和销售上的转化,反而会降低用户体验。如何管理各种服务对用户的触达,统一对触达的频次进行有效控制成为了亟待解决的一件事。本文将介绍频次控制实现方式。

背景

频次控制的场景,在我所接触到的类型中主要分为两类:

一类是速率限制(rate limiting),主要是对服务请求速率进行限制,可能是限定某个接口,限定某个应用,或者是限制对某个资源的访问,目的是防止由大量不合理请求产生的服务不可用。这种能力常见于各种限流中间件。

另一类是展示频率上限(frequency capping)控制,主要是对触达频次进行限制,包括向用户发送的营销信息,页面展示的广告等,目的是避免用户疲劳(banner fatigue)所带来的体验下降和用户流失。也是这里我们所需要的实现。

方案

区别于各种限流中间件的频次控制,限制频率上线的频次控制其实是更近似于广告学中的一个概念,指的是用户在某一段时间内,看到广告(或类广告行为)的频率。例如,同一个用户在一天内最多收到三次推送,就是一条限制频次的规则。

现状

如图是目前公司的触达体系:

触达类型

站外触达的频次一般会由广告商自行对曝光情况进行控制,但作为一个自营品牌,有很多站内发送触达消息的场景,同时又有站内的广告位,使得自己既是投放方也是服务提供方,需要具有广告商一样控制曝光行为的能力。

实现

那么如何实现这样一个系统?

首先是设计,频次控制需要的最基本的能力有:能够记录用户/触达类型/频率周期等多个维度组合的唯一标识出现次数,由于有toC的场景,需要做到能够支持高并发和低延迟。

设计

如果涉及到的场景比较确定,在较长时间内不会有大规模的扩展,同时需要快速迭代的话,可以考虑固定维度的设计,即对场景进行枚举和分类。

各种维度之间应该是正交的关系,通过各维度之间的组合来生成记录频次使用的唯一key,而通过限制生成key维度的数量来实现控制频次的粒度。

例如,假设有消息发送方,接收用户,发送内容这三个维度,生成的频次控制key含义是每个消息发送方对每个用户发送的相同内容不超过某个频次;如果去掉发送内容这个维度,生成的key含义就转变成了,消息发送方对每个用户发送的次数不超过某个频次。

架构图

维度架构

流程图

频次控制流程图如下:
频次控制流程图

如何满足高并发和低延迟?

高频的快速读写最常见的就是使用redis来记录数据,因为redis具有原子性和高性能。同时redis支持的数据结构丰富也是原因之一,这一点会在之后提到。

确定了技术选型,那么应该考虑的就是具体的实现了。整个服务实现的核心是频次控制算法,参考同类型的限流算法,提出了以下两种实现。

固定时间窗口算法

固定时间窗口算法又被叫做计数器法,计数器是最简单的一种限制频次的实现,记录某固定时间段内的总次数,例如限制每小时n次,那么就允许从每小时开始到结束的时间内,只最多允许n次触达,多余的请求会被告知“余额不足”。

实现

生成一个唯一key,redis记录这个key的次数,以周期结束时间作为过期时间。

由上面的流程图可以知道,主要分为两个阶段,一个是判断,一个是自增。
判断时只需要获取当前key记录的次数,与配置进行对比即可返回判断结果,自增时需要考虑的则更多一些,自增通过incrBy实现对记录次数的增加,对于首次自增的key,通过expire来实现设置超时时间。由于incrBy和expire两个操作并不是原子的,所以需要有手段来保证原子性。
最简单的做法就是对key进行加分布式锁,保证资源不会被同时使用。

伪代码:

redisClient.lock(lockKey);
try{
    int num = redisClient.incrBy(key,count);
    if(num == count){
        long expire = calculateExpire(config);
        redisClient.expire(key,expire);
    }
}catch(Exception){
    redisClient.del(key);
}finally{
    redisClient.unlock(lockKey);
}
优化

加锁操作太重了,每次自增操作需要额外进行一次setnxpx和del的操作,这将直接导致redis请求扩张几倍。而且只有需要expire的场景才需要加锁。所以想到用lua脚本的方式来保证这两个命令的原子性。

lua脚本

local times = redis.call('incrBy',KEYS[1],ARGV[1]);
if times == tonumber(ARGV[1]) then 
    redis.call('expire',KEYS[1],ARGV[2]);
end;
return times

伪代码

long expire = calculateExpire(config);
List<String> args = Lists.newArrayList(String.valueOf(count),String.valueOf(expire));
int num = redisClient.eval(INCRBY_AND_EXPIRE_LUA_SCRIPT,keys,args)
优点

实现起来比较简单

缺点
  1. 更改配置周期导致一定时间内不可靠问题。

为了满足多个周期频次判断同时共存的场景,例如,同时存在一天三次和一小时一次两种频次配置,周期也会作为生成key的一个维度,当修改周期时,并不会对已生成的key进行同步,导致部分实际频次大于限制频次。

  1. 临界问题

固定时间窗口算法的劣势就在于其只关心时间段内的总访问次数,而忽略了瞬间集中请求的问题,如下图所示,有可能在统计范围的临界点上出现极端的流量。

在这里插入图片描述

根本原因在于这种统计方法的粒度太粗了,对于请求时间没有详细的记录,然而我们无法保证请求在时间段内的分布是平均的

滑动窗口

基于固定窗口的问题点,提出了滑动窗口算法,滑动窗口其实本质上只是粒度更细的固定窗口,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,频次的统计就会越精确,当格子足够多时,就能准确的判断出周期内的频次。

实现

回收之前提到的redis数据结构丰富的伏笔,在这里可以考虑使用redis的sorted set结构来实现。这是一种有序的存储结构,同一个key下可以保存多个带score的member,如果使用score来记录请求的时间戳,那么在判断时只需要根据时间戳范围取值就能得到以时间戳为粒度的滑动窗口。

在这里插入图片描述

判断时:

long nowTs = System.currentTimeMillis();
//移除时间窗口之前的记录,剩下的都是时间窗口内的
redisClient.zremrangeByScore(key,0,nowTs-period*1000);
int num = redisClient.zcard(key);
return num < configNum;

这里的判断逻辑其实是存在优化空间的,可以参考限流算法,先进行zcard的判断,如果没有超出直接返回,超出之后再进行多余数据的清理和获取。

int num = redisClient.zcard(key);
if(num < configNum){
    return true;
}else {
    long nowTs = System.currentTimeMillis();
    redisClient.zremrangeByScore(key,0,nowTs-period*1000);
    int num = redisClient.zcard(key);
    return num < configNum;
}

自增时:

long nowTs = System.currentTimeMillis();
redisClient.zadd(key,nowTs,nowTs+random);
redisClient.expire(key,period);
优点

频次控制比较平滑,不会出现毛刺的情况

缺点
  1. 空间和性能问题

相比固定窗口算法,在请求量大并且持续周期长时,每个key下需要保留的数据量较多,消耗存储空间,而且zremrangeByScore方法的时间复杂度为logN,数据量过大时对性能也有影响。

  1. 精确度问题

时钟漂移,currentTimeMillis可能不准确,导致精度不够,这是redis本身的问题,并不是限流算法的问题。

不过抛开业务场景谈问题都是耍流氓,在记录触达频次的时候,不会有针对一个key大量写的场景出现,所以从应用的角度来说,这些缺点的影响几乎可以忽略,不必担心。

总结

频次控制的用处

实现了基础的频次控制,可以在上层服务对业务需求进行封装,将来自不同业务的需求,不同的场景进行抽象,提供统一的对外接口暴露能力,进而实现对用户疲劳的统一管控。而这些实现都需要依赖频次控制来完成。

服务的关注点应该聚焦,业务系统之间的边界也应该进一步明确,与具体业务强耦合的部分应当拆分出来。

层级结构应该是:接入方 -> 疲劳度服务 -> 频次控制 -> redis

由疲劳度服务作为中台服务对外提供各种形式疲劳度的控制能力,而频次控制服务提供与具体业务无关的各维度自由组合的控制能力

疲劳度服务的能力应该包括:
业务上:对业务需求的封装,理解并处理业务规则,返回业务容易理解的结果
功能上:规则配置,合规合法性(权限)校验,可视化,系统行为管理(限流),以及疲劳度使用的可记录和可追溯能力

疲劳度处理业务,频次服务只关注频次控制判断,对传入的不同维度参数和条件进行组合,进行一系列的判断然后返回判断的结果,而这些结果会在疲劳度服务中进行组装,进一步转化为业务方能够直接使用的数据结构。

当然从另一个角度来看,也可以疲劳度和频次控制可以看成是一个服务,一个是业务层,一个是基础能力层。服务拆分这个话题只能说是见仁见智吧,不仅和服务本身有关,还和其他很多因素相关,具体做法需要结合实际情况。

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值