前言
为什么需要写个消重系统?遇到了哪些问题?要如何实现?
1.请求消重:
在一些场景中,我们需要对用户的请求进行过滤。举个例子,在秒杀活动中,我们就需要对用户的请求数量进行限制,防止一些刷单的脚本请求。
这种场景的特点是请求数量多,并发性高,处理请求的服务往往是无状态分布式的,
2.特定值的消重:
典型的场景是推荐系统,在给用户推荐物品后,需要在下次推荐时,将之前推荐过的内容过滤掉。
这种场景的特点是,消重机制复杂,用户差异度大,资源消耗多。
请求消重的方法
- 首先想到redis。
每个用户请求到来的时候,把用户的id设置到redis中,类似如下的命令。返回成功则执行后续操作,反之则说明这个用户已经在30秒内请求过一次了,需要进行消重处理。
redis
set user_id_001 1 ex 30 nx
思考:假设系统DAU在500百万左右,一个接口的消重可能不算什么,但整个系统有若干个需要消重的接口,算下来是非常恐怖的资源消耗。当然你的用户量不高,完全可以这么干。
- bitmap
bitmap是一种节约空间的好方法,将N个用户 通过取余或者hash 映射到一个bit空间,当请求过来的时候,我们只需要判断对应位置是否为1,来判断是否请求过。
举例,有1k大小的空间,假设用户id为x,则对应这个空间的 x%1028*8 的位置,通过设置这个二进制位是 0还是1来判断是否请求过。
思考1:这种方式并不严格过滤,存在误差,可能将没请求过的用户,当做已请求过的处理。 对于绝对严格的场景是不适用。且我们常用的redis的bitmap最大512m,也就是说精度存在上限。但一般场景下是够用的。
思考2:reids的集群方式,决定了一个bitmap分布在一个机器上,如果并发过高,会存在性能瓶颈。实现上可以通过分段来避免这个问题,同时分段还能避免大Key问题,扩容问题,但这会提升系统复杂性。
思考3:bitmap无法单独设置某个用户过期,一个bitmap过期,意味着一批过期。
布隆过滤器
上面的映射方法过于粗糙,实际上我们更多的是用布隆过滤器? 啥是布隆过滤器?
通过若干种hash算法,将一个key映射到一个bit空间中。如下图,如果每个位置的值都为1,则认为值已存在,反之则不存在。

思考:生产中我应该选择多少函数,多大空间哪?
根据上面的原理,我们知道空间大小 和hash函数数量 决定了随着key的增多,准确率的变化。那反过来,我们假设key的数量,和准确率已知,则能求出样本空间和函数数量。
具体的求解公式我就不贴了,这里有个在线的计算方法可以使用
推荐系统中的消重
推荐系统中消重则是按照多个维度来消重:
-
内容相似度消重,推荐系统在召回的时候,会将和用户相关度最高的item召回,往往存在大量相似的内容,将多个过于相似的item推给用户明显是不合理的,通过相似度消重来解决这个问题。实现上是通过各种混排算法和运营策略决定,不是本文的重点。
-
浏览消重,不能重复给用户推视频这很好理解。但也要分情况处理:
- 服务端消重,也叫已推未阅,推荐系统将一批内容推给用户,用户并没有点进去查看详情。此时对这些推送过的内容,应该有一个短期的消重。大概一到七天左右。
- 客户端消重,用户浏览过某个内容后,在很长的一段时间里,都不应该给他推送这个内容。七天到三个月不等。
- 不喜欢列表。如果用户明确将某个内容标记为不喜欢,那么直到天荒地老,海枯石烂都不应该给他推这个内容。
我们来看一下这种消重和特点:
- 消重的数量非常庞大,即便你只有一百万的用户,消重数量都可能按亿算。
- 过期策略不同,需要短中长三种跨度过期数据。
- 需要按照用户维度消重,对单个用户的消重是串行的,批量的。
消重系统设计
我们这里主要实现一个短期消重的方案,长期的消重主要是通过数据库来实现的,这里就不写了。
下面我们简单画一下结构设计:
- 在用户力度上,每个用户都有自己的一个bitmap块串,和元数据(块标识+块容量)。并且抽象成一个过滤器。也就是一个块一个过滤器。一个用户对应一个过滤器组。
- 分块的目的是方便扩容,和过期数据。但过滤数据的时候,需要将一个组里面的chunk都比照一遍,才确定值是否已经存在。
- FilterExpandStrategy:过滤器的扩展策略,是核心功能,主要用来决定过滤器的参数和扩展策略。
- Pool : 用做缓存

trait设计,这里看几个核心抽象
首先抽象过滤器为一个trait,如下:
这里将单值和批量接口分开,对应上面两个场景,所以他们的实现应该是不同的
rust
#[async_trait::async_trait]
pub trait SingleKeyFilter: Send + Sync {
//单值判断
async fn insert(&self, item: &str) -> anyhow::Result<()>;
async fn contain(&self, item: &str) -> anyhow::Result<bool>;
//批量操作接口
async fn pre_insert(...) -> anyhow::Result<Vec<usize>>;
async fn commit_insert(...) -> anyhow::Result<()>;
async fn pre_contain(...) -> anyhow::Result<bool>;
}
关于扩展策略的定义如下。
rust
#[async_trait::async_trait]
pub trait FilterExpandStrategy: Send + Sync {
//加载过滤器组
async fn load_filter_group(&self, group: &str)-> anyhow::Result<Vec<Arc<dyn SingleKeyFilter>>>;
//增加一个新chunk
async fn expand_chunk(...) -> anyhow::Result<Arc<dyn SingleKeyFilter>>;
}
功能实现
我主要是基于 布隆过滤器+redis实现。详细代码请见
单值操作,主要是通过getbit
和setbit
实现。
批量操作,则直接通过拉取和设置整个value实现。就是将整个bitmap拉下来,设置好后,全量推上去。
关于扩展策略则有三种:
rust
pub enum Strategy {
// 以一个固定的大小扩容
Fixed(usize),
// 固定梯度扩容
Ladder(Vec<usize>),
// 以某一种函数扩容,入参为chunk下标,从0开始
Function(Box<dyn Fn(usize) -> usize + Send + Sync + 'static>),
}
关于数据过期:给每个chunk的名字都增加时间戳,需要一个离线扫描的脚本,将过期的key删除。
测试验证
rust
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_pressure() {
//创建一个布隆过滤器的扩展策略,并以固定100扩容
let strategy =
BloomExpandStrategy::build_from_redis("biz02", "redis://:root@127.0.0.1/")
.unwrap()
.set_strategy_fixed(100);
let pool = FiltersPool::from(strategy);
//创建一批Key 200个
let key_count = 200;
let group = "user001";
let mut keys = Vec::with_capacity(key_count);
for i in 0..key_count {
keys.push(format!("key_{}", i));
}
// 第一次查看这些keys,应该都是不存在的 200key查询用时768ms
let _ = pool.batch_contain(group, keys.clone()).await.unwrap();
// 将这些key批量插入 200key查询用时3044ms,
// 这里用时高主要因为我设置块只有100容量。也就是扩容了两次,实际插入用时在1s以内。
let _ = pool.batch_insert(group, keys.clone()).await.unwrap();
// 再次查看,发现他们都是已经存在的 200key查询用时851ms
let result = pool.batch_contain(group, keys.clone()).await.unwrap();
}
尾语
在实际推荐中,扩容策略是十分关键的。因为大部分的用户都是不活跃的,而头部用户的浏览量又是非常大的,所以扩容应该是一个指数函数。
我接手推荐后,首先做的就是调整系统的扩容参数,空间资源使用直接缩了三分之一,很是牛气了一波。
对于服务端消重,并不是必要的,比如youtube就不用,但大部分还是用的。
本来想着把rpc服务一起实现了,但最近事情有点多,先把上面实现的部分推到crate上,rpc实现待定了。