分布式延时任务方案:Redis ZSet + 时间轮 (Time Wheel)
1. 方案背景在电商等高并发场景下订单超时取消、预定提醒等延时任务需要满足以下核心需求高可靠性任务不能丢失即使服务器宕机也需能恢复。高精度触发时间偏差应在毫秒级或秒级。高性能避免大批量任务直接轮询数据库导致的 I/O 瓶颈。分布式扩展支持多实例部署任务不重复执行。2. 核心架构设计本方案采用“二级调度”思想将 Redis 作为全局持久化任务池将本地时间轮作为高精度触发器。2.1 数据结构设计总任务池 (order:timeout:all)类型ZSetScore任务执行的绝对时间戳如15:30:0015:30:0015:30:00。Value任务唯一标识如orderId。实例私有桶 (order:timeout:processing:{instanceId})类型ZSet作用记录该实例已取走但尚未完成的任务用于宕机恢复。心跳键 (instance:heartbeat:{instanceId})类型String(带过期时间)作用标识实例存活状态。3. 详细工作流程第一阶段任务入库 (Producer)业务系统创建订单计算过期时间。执行ZADD order:timeout:all timestamp orderId。第二阶段预取并加载 (Loader)每个服务实例启动一个定时线程如每 1 分钟执行一次。通过Lua 脚本原子性地从“总池”中捞取未来 60s 内到期的任务转移至“私有桶”。将任务加载至本地内存的HashedWheelTimer时间轮。第三阶段精准触发 (Execution)本地时间轮转动到预定刻度触发回调函数。执行业务逻辑调用支付网关查询状态、修改订单状态、回滚库存。确认完成业务成功后从 Redis “私有桶”中删除该任务。4. 关键 Lua 脚本4.1 预取脚本 (fetch_to_private.lua)保证多个实例竞争时任务只会被一个实例取走并转移。Lua-- KEYS[1]: 总池 (all), KEYS[2]: 私有桶 (processing) -- ARGV[1]: 当前时间, ARGV[2]: 预取截止时间 (now 60s) local orders redis.call(ZRANGEBYSCORE, KEYS[1], ARGV[1], ARGV[2]) if #orders 0 then for i, order_id in ipairs(orders) do redis.call(ZREM, KEYS[1], order_id) redis.call(ZADD, KEYS[2], ARGV[2], order_id) end end return orders4.2 确认删除脚本 (complete_task.lua)Lua-- KEYS[1]: 私有桶 -- ARGV[1]: 订单 ID return redis.call(ZREM, KEYS[1], ARGV[1])5. 容错与恢复机制 (Fault Tolerance)5.1 解决“易失性”由于任务在执行前已进入 Redis “私有桶”即使服务器内存的时间轮因宕机丢失任务数据依然在 Redis 中。5.2 故障清理 (Watchdog)启动一个独立的监控任务或利用现有实例的定时任务遍历所有私有桶order:timeout:processing:*。检查对应的实例心跳instance:heartbeat:{instanceId}。恢复逻辑如果发现心跳消失且私有桶中有残留任务则说明该实例已宕机。将私有桶中的任务重新ZADD回总池。6. 方案对比总结维度纯 DB 轮询纯 Redis ZSet纯时间轮本方案 (ZSet时间轮)精度低 (分钟级)中 (秒级)极高 (毫秒级)极高 (毫秒级)DB/Redis 压力极高中无低 (按需预取)可靠性高高低 (重启即丢)高 (双重备份)实现难度简单中中较高7. 最佳实践建议实例 ID 选择在 K8s 环境下建议使用HOSTNAME(Pod Name)在虚拟机环境下使用IP:Port。任务幂等取消订单逻辑必须实现幂等防止在极端恢复场景下重复触发。时钟同步分布式环境下请确保所有实例节点的 NTP 时间同步以防预取区间出现偏差。希望这份文档能帮助你清晰地整理方案。如果你需要针对特定的开发框架如 Spring Boot 或 Quarkus提供更详细的代码脚手架我可以继续为你提供支持。