10万级用户数据日更与定向推送系统的可靠性设计

我们有一张用户表 user_profile

字段名 数据类型 (建议) 描述 备注
user_id BIGINT 用户唯一标识 主键,关联 RPC 标签服务
register_time DATETIME 注册时间 用户初始进入系统的时间
tag VARCHAR / TEXT 用户最新标签 存储由 RPC 服务拉取的回写结果
create_time DATETIME 记录创建时间 数据库条目首次插入的时间
updated_at DATETIME 记录更新时间 关键字段:用于标记今天是否处理过

大约 10w 用户。每天会跑一个定时任务做两件事:

  1. 通过一个 RPC 标签服务 拉取用户最新标签,把结果回写到用户表的 tag 字段(可以认为每个用户每天都可能变化)。
  2. 对"满足条件"的用户发一条 app 通知:命中 xx 条件,通知用户参与 xx 活动

业务侧的期望是:这个任务每天固定时间启动后,30 分钟左右能跑完,并且线上偶尔会遇到一些现实问题:

  • RPC 会有超时、限流、偶发失败;
  • 数据库写入可能抖动;
  • 推送也可能失败或重复;
  • 任务可能中途挂掉,需要能恢复继续跑。

首先考虑一下整个推送系统的流程:

  1. 读取数据库,请求RPC服务获取tag
  2. 更新数据库
  3. 对满足条件的用户发送通知

实际上在执行的过程当中可以划分为4个阶段,对于用户的通知模块可以通过消息中间件解耦,异步通知,这样实际上就变成了一下四个阶段

  1. 读取数据库,请求RPC服务获取tag
  2. 更新数据库
  3. 记录待通知的用户写入Kafka
  4. 异步消费,通知用户
数据批量读取与 RPC 高可用调用策略

而题干当中所描述的四个问题都分别对应在这四个阶段当中,在第一阶段我们需要请求RPC服务,而我们用户表的量级是10w,如果每一个用户单独发送一次RPC请求会对RPC服务造成很大的压力,因此可以批量请求。

在读取数据库的过程当中我们也可以采用批量读取的方式,借助主键索引一次性读取500条数据,之后开启线程池将这500个用户批量发送给RPC服务返回其tag,在这里通过批量聚合策略减少了RPC发送频率,大大减少RPC服务压力。

在这个基础上我们的RPC服务任然可能会遇到超时、限流、偶发失败等问题。

  • 超时:超时比发送失败更为严重,超时会导致占用线程资源,因此一定要设置合理的超时等待时间,当请求/链接超过时间之后直接中断,之后通过失败重试策略对其重新请求。
  • 偶发失败:对于请求失败问题,必须设置重试策略,可以通过指数退避重试策略,也就是说第一次间隔1s重试,第二次间隔2s重试,第三次间隔4s指数增长。同时还需要设置合适的重试次数,在多次重试都失败之后,可以定义一张消息失败表/Kafka 的死信队列,将失败的请求信息写入该表,设置定时任务扫描改表重试。除此之外,如果失败请求的数量过多还需要采取熔断降级策略,比如当1min内失败的请求达到50%,此时可以开启熔断降级/限流策略。
  • 基于当前的场景限流策略应该是先于熔断之前,对于RPC服务来说本身就必须设置一个限流策略以避免突发流量,关于限流算法可以参考下述。在基于限流策略的基础上发生了熔断,这个时候就不得不采取降级策略了。

同时在这里我们还需要考虑到服务的容灾性,在拉去数据库的过程当中还可以使用Redis/DB记录当前读取到的最大用户ID,这样在服务宕机后,下次重启可以基于这个ID继续读取。

数据库批量回写与并发控制

在基于上述的RPC服务请求拿到tag后,我们需要写回数据库更新状态,在这一步操作当中仍然可以使用批量处理一次性更新多条记录,同时需要合理设置线程池的数量避免耗尽数据库连接池,在更新操作当中添加事务保证一致性,同时记录失败的sql,设置重试机制。

这里考虑到表字段当中包含updated_at的一个字段,该字段可以表示tag是否更新过。在上述的逻辑当中幂等性不必通过改字段来保证,我们基于批量读取ID,只要保证这个读取到的IDlist不会有重叠,在之后的更新操作也通过事务保证原子性,从而保证了幂等性。

异步通知投递与防重过滤

在对于数据写会之后,我们还需要判断当前用户是否需要发送通知,基于通知类型以及标签的不同要发送的通知可能有多种,可以定义多个topic,在该过程当中筛选符合的用户,将其信息写入到对应topic当中。

这里引入消息中间件kafka的好处在于可以实现消息发送的解耦,我们可以在后台异步发送通知给用户,同时可以通过Kafka的消息一致性策略保证发送通知的一致性。

如果极度担心 Kafka 宕机导致消息丢失,可以在"批量写库"的同一个事务中,将要推送的消息写到一张 local_message 表里。然后用一个非常轻量的后台线程把这张表的数据搬运到 Kafka。这就实现了 100% 的事务最终一致性(Outbox Pattern)。

同时为了保证通知的不重复(同一条消息重复通知了某个用户),可以定义一个bitmap,将用户id作为下标,如果该用户已完成通知则将对应下表处改为1(0:未通知/1:已通知)

熔断器与降级策略

一、 熔断器(Circuit Breaker)的底层原理:状态机

熔断器的实现灵感来源于物理电路中的"保险丝"。在代码层面,它本质上是一个带有滑动窗口(Sliding Window)的状态机,通常包含三种状态:

  1. 闭合状态(Closed - 正常运行)
    • 行为:所有对 RPC 标签服务的请求都正常放行。
    • 后台动作:熔断器在内存中维护一个"滑动窗口"(按时间,例如最近 1 分钟;或按请求数,例如最近 100 次调用)。它会实时统计这个窗口内的请求总数、成功数、失败数、超时数。
  2. 断开状态(Open - 触发熔断)
    • 触发条件:当滑动窗口内的失败率或慢调用率达到设定的阈值(例如:最近 1 分钟内,请求总数大于 20 次,且失败率 >= 50%),熔断器"跳闸",状态变为 Open。
    • 行为(核心保护机制):处于 Open 状态时,所有尝试调用 RPC 的请求不再发起真实的网络通信,而是在本地直接抛出特定异常(如 CallNotPermittedException),实现快速失败(Fail-Fast)。这极大释放了客户端线程,也给服务端留出了喘息恢复的时间。
  3. 半开状态(Half-Open - 试探恢复)
    • 触发条件:熔断器不能一直断开。进入 Open 状态后,会启动一个冷却定时器(Wait Duration,例如 30 秒)。冷却时间结束后,状态自动切换为 Half-Open。
    • 行为:此时,熔断器会**"放行少量请求"(例如允许 10 个请求真实打到 RPC 标签服务上去),作为探路先锋。
    • 状态流转**:
      • 如果这 10 个请求的大部分都成功了(达到成功阈值),说明标签服务恢复了,状态切换回 Closed(闭合)。
      • 如果这 10 个请求依然有很高的失败率,说明服务端还没修好,状态退回 Open(断开),重新开始 30 秒的冷却倒计时。
二、 降级策略(Fallback)的设计与实现

降级是伴随熔断而生的"备胎计划"。当请求被熔断拦截,或者 RPC 调用超时、抛出异常时,业务流程不能就此中断,我们需要给出一个兜底的返回值或处理逻辑,这就是降级。

结合你"拉取 10w 用户标签并推送通知"的业务场景,降级策略通常有以下几种实现方式:

  1. 返回旧数据(容忍一定的不一致) ------ 最推荐
    • 逻辑:RPC 拉取最新标签失败了?没关系,在 Fallback 方法中,直接查询本地 user_profile 表中该用户昨天的老标签数据作为返回值。
    • 效果:业务流程不受影响,用户依然能根据昨天的标签被筛选出来发送通知(商业逻辑上,标签晚一天更新通常是可以接受的)。
  2. 返回默认值 / 空值
    • 逻辑 :如果不允许使用老标签,Fallback 可以直接返回一个空集合 [],或者一个特定的 DEFAULT_TAG
    • 效果:当前批次的用户不会触发任何"命中特定条件"的逻辑,静默跳过,保证定时任务顺利往下跑。
  3. 异步补偿(结合失败表)
    • 逻辑 :在 Fallback 逻辑中,将这批失败的用户 ID 封装成一条消息,写入数据库的 rpc_failed_record 表,或者丢进 Kafka 的死信队列。
    • 效果 :主任务继续跑,不阻塞。等 RPC 标签服务恢复正常后,由另一个低频的定时任务扫描 rpc_failed_record 表,重新拉取标签并回写数据库。

限流算法

1. 令牌桶算法 (Token Bucket) ------ 允许突发流量,最推荐用于客户端调用
  • 原理:系统以恒定的速率(例如 50个/秒)向桶里放入令牌。桶有最大容量。当一个请求到来时,必须先从桶里拿走一个令牌才能执行;如果桶空了,请求就被阻塞或拒绝。
  • 特点 :因为桶里可以预存令牌,所以它允许一定程度的突发流量。比如桶容量是 100,突然来 100 个并发请求可以瞬间处理完,但随后的请求就被限制在每秒 50 个的匀速了。
  • 适用场景 :作为调用方(客户端)主动平滑发流的绝佳选择。在你的任务中,调用标签 RPC 前使用令牌桶,能确保 QPS 严格控制在下游允许的 SLA 范围内。
2. 漏桶算法 (Leaky Bucket) ------ 绝对平滑,强制匀速
  • 原理 :请求就像水一样倒入漏桶中,不管水倒进来的速度有多快,漏桶总是以绝对恒定的速度(例如 10个/秒)往下漏水(处理请求)。如果桶满了,新进来的水(请求)就直接溢出(被丢弃或拒绝)。
  • 特点:强行将突发流量整形为绝对平滑的匀速流量。
  • 适用场景 :常用于服务端保护 或者消息队列削峰。比如第三方推送接口要求"绝对不能超过 500 次/秒,多一次都不行",此时服务端通常会用漏桶来保护自己。
相关推荐
弘毅 失败的 mian2 小时前
嵌入式系统观
数据库·经验分享·笔记·物联网·嵌入式
深蓝轨迹2 小时前
Redis+Lua实现秒杀优化
数据库·redis·lua
凸头2 小时前
从“搜了就答”到“智能决策”:拥抱 RAG 2.0 时代的架构演进 ——Java 后端工程师视角下的 AI 应用工程化落地
java·人工智能·架构·rag
nap-joker2 小时前
PIPE4:快速PPI预测器,用于综合的跨物种和跨物种相互作用组
算法·多模态生物医学数据分析·蛋白质互作网络
DJ斯特拉2 小时前
JUC基础
java·jvm·juc
小年糕是糕手2 小时前
【35天从0开始备战蓝桥杯 -- Day7】
开发语言·jvm·数据库·c++·蓝桥杯
小江的记录本2 小时前
【端口号】计算机领域常见端口号汇总(完整版)
java·前端·windows·spring boot·后端·sql·spring
色空大师2 小时前
网站搭建实操(二)后台管理(1)登录
java·linux·数据库·搭建网站·论坛
柒.梧.2 小时前
深入理解AQS:Java并发编程的核心基石
java·开发语言