基于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实现。详细代码请见

单值操作,主要是通过getbitsetbit实现。

批量操作,则直接通过拉取和设置整个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实现待定了。

相关推荐
多敲代码防脱发5 小时前
Spring框架基本使用(Maven详解)
java·网络·后端·spring·maven
Asthenia04125 小时前
Mybatis实践——Wrapper&&三表联查&&BaseMapper和Service的功能分异
后端
B站计算机毕业设计超人5 小时前
计算机毕业设计SpringBoot+Vue.jst0甘肃非物质文化网站(源码+LW文档+PPT+讲解)
java·vue.js·spring boot·后端·spring·intellij-idea·课程设计
why技术6 小时前
可以说是一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
后端·面试
m0_748254666 小时前
定时任务特辑 Quartz、xxl-job、elastic-job、Cron四个定时任务框架对比,和Spring Boot集成实战
java·spring boot·后端
diemeng11196 小时前
2024系统编程语言风云变幻:Rust持续领跑,Zig与Ada异军突起
开发语言·前端·后端·rust
Warren986 小时前
Springboot中分析SQL性能的两种方式
java·spring boot·后端·sql·mysql·intellij-idea
canonical_entropy7 小时前
Nop平台与橙单OrangeForm集成
后端·低代码
计算机学姐7 小时前
基于SpringBoot的校园消费点评管理系统
java·vue.js·spring boot·后端·mysql·spring·java-ee
猎人everest7 小时前
Spring Boot数据访问(JDBC)全解析:从基础配置到高级调优
java·spring boot·后端