基于Rust ,从零实现一个 极致,优雅,实用 的消重系统。

前言

为什么需要写个消重系统?遇到了哪些问题?要如何实现?

1.请求消重:

在一些场景中,我们需要对用户的请求进行过滤。举个例子,在秒杀活动中,我们就需要对用户的请求数量进行限制,防止一些刷单的脚本请求。

这种场景的特点是请求数量多,并发性高,处理请求的服务往往是无状态分布式的,

2.特定值的消重:

典型的场景是推荐系统,在给用户推荐物品后,需要在下次推荐时,将之前推荐过的内容过滤掉。

这种场景的特点是,消重机制复杂,用户差异度大,资源消耗多。

请求消重的方法

  1. 首先想到redis。

每个用户请求到来的时候,把用户的id设置到redis中,类似如下的命令。返回成功则执行后续操作,反之则说明这个用户已经在30秒内请求过一次了,需要进行消重处理。

redis 复制代码
set user_id_001 1 ex 30 nx

思考:假设系统DAU在500百万左右,一个接口的消重可能不算什么,但整个系统有若干个需要消重的接口,算下来是非常恐怖的资源消耗。当然你的用户量不高,完全可以这么干。

  1. 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推给用户明显是不合理的,通过相似度消重来解决这个问题。实现上是通过各种混排算法和运营策略决定,不是本文的重点。

  • 浏览消重,不能重复给用户推视频这很好理解。但也要分情况处理:

    • 服务端消重,也叫已推未阅,推荐系统将一批内容推给用户,用户并没有点进去查看详情。此时对这些推送过的内容,应该有一个短期的消重。大概一到七天左右。
    • 客户端消重,用户浏览过某个内容后,在很长的一段时间里,都不应该给他推送这个内容。七天到三个月不等。
    • 不喜欢列表。如果用户明确将某个内容标记为不喜欢,那么直到天荒地老,海枯石烂都不应该给他推这个内容。

我们来看一下这种消重和特点:

  1. 消重的数量非常庞大,即便你只有一百万的用户,消重数量都可能按亿算。
  2. 过期策略不同,需要短中长三种跨度过期数据。
  3. 需要按照用户维度消重,对单个用户的消重是串行的,批量的。

消重系统设计

我们这里主要实现一个短期消重的方案,长期的消重主要是通过数据库来实现的,这里就不写了。

下面我们简单画一下结构设计:

  • 在用户力度上,每个用户都有自己的一个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实现待定了。

相关推荐
金銀銅鐵9 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路10 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x10 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn12 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈13 小时前
AI 读你的提示词,其实很没耐心
后端
用户83562907805113 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我1234513 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒13 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记14 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk5987814 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端