我们有一张用户表 user_profile:
| 字段名 | 数据类型 (建议) | 描述 | 备注 |
|---|---|---|---|
| user_id | BIGINT |
用户唯一标识 | 主键,关联 RPC 标签服务 |
| register_time | DATETIME |
注册时间 | 用户初始进入系统的时间 |
| tag | VARCHAR / TEXT |
用户最新标签 | 存储由 RPC 服务拉取的回写结果 |
| create_time | DATETIME |
记录创建时间 | 数据库条目首次插入的时间 |
| updated_at | DATETIME |
记录更新时间 | 关键字段:用于标记今天是否处理过 |
大约 10w 用户。每天会跑一个定时任务做两件事:
- 通过一个 RPC 标签服务 拉取用户最新标签,把结果回写到用户表的
tag字段(可以认为每个用户每天都可能变化)。 - 对"满足条件"的用户发一条 app 通知:命中 xx 条件,通知用户参与 xx 活动。
业务侧的期望是:这个任务每天固定时间启动后,30 分钟左右能跑完,并且线上偶尔会遇到一些现实问题:
- RPC 会有超时、限流、偶发失败;
- 数据库写入可能抖动;
- 推送也可能失败或重复;
- 任务可能中途挂掉,需要能恢复继续跑。
首先考虑一下整个推送系统的流程:
- 读取数据库,请求RPC服务获取tag
- 更新数据库
- 对满足条件的用户发送通知
实际上在执行的过程当中可以划分为4个阶段,对于用户的通知模块可以通过消息中间件解耦,异步通知,这样实际上就变成了一下四个阶段
- 读取数据库,请求RPC服务获取tag
- 更新数据库
- 记录待通知的用户写入Kafka
- 异步消费,通知用户
数据批量读取与 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)的状态机,通常包含三种状态:
- 闭合状态(Closed - 正常运行)
- 行为:所有对 RPC 标签服务的请求都正常放行。
- 后台动作:熔断器在内存中维护一个"滑动窗口"(按时间,例如最近 1 分钟;或按请求数,例如最近 100 次调用)。它会实时统计这个窗口内的请求总数、成功数、失败数、超时数。
- 断开状态(Open - 触发熔断)
- 触发条件:当滑动窗口内的失败率或慢调用率达到设定的阈值(例如:最近 1 分钟内,请求总数大于 20 次,且失败率 >= 50%),熔断器"跳闸",状态变为 Open。
- 行为(核心保护机制):处于 Open 状态时,所有尝试调用 RPC 的请求不再发起真实的网络通信,而是在本地直接抛出特定异常(如 CallNotPermittedException),实现快速失败(Fail-Fast)。这极大释放了客户端线程,也给服务端留出了喘息恢复的时间。
- 半开状态(Half-Open - 试探恢复)
- 触发条件:熔断器不能一直断开。进入 Open 状态后,会启动一个冷却定时器(Wait Duration,例如 30 秒)。冷却时间结束后,状态自动切换为 Half-Open。
- 行为:此时,熔断器会**"放行少量请求"(例如允许 10 个请求真实打到 RPC 标签服务上去),作为探路先锋。
- 状态流转**:
- 如果这 10 个请求的大部分都成功了(达到成功阈值),说明标签服务恢复了,状态切换回 Closed(闭合)。
- 如果这 10 个请求依然有很高的失败率,说明服务端还没修好,状态退回 Open(断开),重新开始 30 秒的冷却倒计时。
二、 降级策略(Fallback)的设计与实现
降级是伴随熔断而生的"备胎计划"。当请求被熔断拦截,或者 RPC 调用超时、抛出异常时,业务流程不能就此中断,我们需要给出一个兜底的返回值或处理逻辑,这就是降级。
结合你"拉取 10w 用户标签并推送通知"的业务场景,降级策略通常有以下几种实现方式:
- 返回旧数据(容忍一定的不一致) ------ 最推荐
- 逻辑:RPC 拉取最新标签失败了?没关系,在 Fallback 方法中,直接查询本地
user_profile表中该用户昨天的老标签数据作为返回值。 - 效果:业务流程不受影响,用户依然能根据昨天的标签被筛选出来发送通知(商业逻辑上,标签晚一天更新通常是可以接受的)。
- 逻辑:RPC 拉取最新标签失败了?没关系,在 Fallback 方法中,直接查询本地
- 返回默认值 / 空值
- 逻辑 :如果不允许使用老标签,Fallback 可以直接返回一个空集合
[],或者一个特定的DEFAULT_TAG。 - 效果:当前批次的用户不会触发任何"命中特定条件"的逻辑,静默跳过,保证定时任务顺利往下跑。
- 逻辑 :如果不允许使用老标签,Fallback 可以直接返回一个空集合
- 异步补偿(结合失败表)
- 逻辑 :在 Fallback 逻辑中,将这批失败的用户 ID 封装成一条消息,写入数据库的
rpc_failed_record表,或者丢进 Kafka 的死信队列。 - 效果 :主任务继续跑,不阻塞。等 RPC 标签服务恢复正常后,由另一个低频的定时任务扫描
rpc_failed_record表,重新拉取标签并回写数据库。
- 逻辑 :在 Fallback 逻辑中,将这批失败的用户 ID 封装成一条消息,写入数据库的
限流算法
1. 令牌桶算法 (Token Bucket) ------ 允许突发流量,最推荐用于客户端调用
- 原理:系统以恒定的速率(例如 50个/秒)向桶里放入令牌。桶有最大容量。当一个请求到来时,必须先从桶里拿走一个令牌才能执行;如果桶空了,请求就被阻塞或拒绝。
- 特点 :因为桶里可以预存令牌,所以它允许一定程度的突发流量。比如桶容量是 100,突然来 100 个并发请求可以瞬间处理完,但随后的请求就被限制在每秒 50 个的匀速了。
- 适用场景 :作为调用方(客户端)主动平滑发流的绝佳选择。在你的任务中,调用标签 RPC 前使用令牌桶,能确保 QPS 严格控制在下游允许的 SLA 范围内。
2. 漏桶算法 (Leaky Bucket) ------ 绝对平滑,强制匀速
- 原理 :请求就像水一样倒入漏桶中,不管水倒进来的速度有多快,漏桶总是以绝对恒定的速度(例如 10个/秒)往下漏水(处理请求)。如果桶满了,新进来的水(请求)就直接溢出(被丢弃或拒绝)。
- 特点:强行将突发流量整形为绝对平滑的匀速流量。
- 适用场景 :常用于服务端保护 或者消息队列削峰。比如第三方推送接口要求"绝对不能超过 500 次/秒,多一次都不行",此时服务端通常会用漏桶来保护自己。