资讯动态

从单机到集群的异步调度内核设计:时间轮、优先级队列与租约锁实践

发布时间:2026/10/17 9:52:23 来源:尧图企业网站定制
周末晚上十一点半我本来已经洗漱完准备躺下手机突然连震五下监控群里炸了订单导出任务的数据库连接池被打满报错日志刷得比瀑布还快。刚开始我以为是 SQL 写烂了查完一圈才发现根子根本不在 SQL在于调度那一层。订单服务、支付服务、清结算服务各自为战有的用time.Sleep硬等有的开 goroutine 定时拉数据到点大家一起往库里写谁都不管谁还活着重试逻辑又写得过于激进失败任务排到下一个周期继续打一波就把连接池拖垮了。当时几个人一合计干脆做一个统一的东西别再让每个服务自己造定时器了。新项目得有个代号顺手就叫ax。取的是 async 的头尾字母本意是“异步调度”后来大家喊顺口了也都说“ax调度”。不少朋友问过我这到底是个什么东西说实话它不是什么大而全的分布式任务调度平台不搞控制台、不搞执行器全家桶它更像一个嵌入业务服务内部的调度内核专门解决“什么时间、用什么方式、去执行什么任务”这件事。这篇文章就是把ax从零开始设计、单机跑稳、再走向集群的完整过程涉及时间轮、优先级队列、租约锁、分布式分片和线上排障经验希望对正在自己写调度组件或者准备优化异步任务体系的人有点用。1. 为什么先从语义下手立即执行、延迟执行、周期执行必须分开动手写代码之前我先把所有用到定时和异步的场景拉了一遍发现绕来绕去都能归到三种语义立即执行ASAP、延迟执行Delayed、周期执行Cron。听起来简单但很多调度组件挂就挂在把这三件事混在一起处理。任务类型触发方式典型场景容易踩的坑立即任务提交后尽快执行异步通知、缓存刷新、补偿重发并发一高就疯狂开线程/goroutine资源失控延迟任务到指定时间点执行订单超时关单、验证码过期、延迟队列时间精度和内存开销难以兼得周期任务按固定间隔或 cron 表达式报表生成、数据对账、定时拉取多副本部署时重复执行不加锁就出事这三个语义混在一起处理最典型的问题就是拿time.Sleep当延迟任务。看着很直接实现也简单但一上量就完蛋。假设有 50 万个订单超时关单任务你按“一个任务睡一个 goroutine”的方式实现光 goroutine 的栈空间可能就占几百 MB而且这些 goroutine 纯粹在睡眠等待什么活也不干。操作系统和运行时管理这些协程都有成本任务量再上去光调度开销就能把你拖垮。真正的调度器不是这种写法。它应该有一个统一的任务存储有一个负责推进时间指针的调度循环有一组数量可控的执行器。任务本身只是数据只有到点被唤醒才变成执行动作。ax最开始的架构就是按这三个角色拆的任务仓库存任务、调度循环推时间、Worker 池执行任务。这个模型后面一直没变过变的只是各个角色的实现细节。1.1 任务数据结构的设计任务对象定义得越精简后面序列化和内存开销越小。我第一版就尽量把字段控制住后面线上运行发现这个决策非常正确。type Task struct { ID string // 全局唯一 ID分布式场景下必须带上实例标识 Type string // 任务类型用于路由到对应的 handler Priority int // 优先级0 最低数值越大越优先 RunAt time.Time // 期望执行时间点 Timeout time.Duration // 单次执行超时时间 MaxRetry int // 最大重试次数 RetryCount int // 当前已经重试的次数 Payload []byte // 业务数据尽量小 IdempotentKey string // 幂等键防止重复执行造成脏数据 }这里的IdempotentKey看着不起眼真正到了集群阶段它就是保命符。调度系统哪怕设计得再完善也逃不过网络分区、锁过期、重复提交这些极端情况。幂等键就是在业务侧兜底的。比如关单任务IdempotentKey 直接用订单号执行 SQL 里带一个where status 待支付就算同一个任务被触发了两次第二次执行最多打一条无用日志不会产生重复扣减或者错误关单。1.2 调度循环的基本骨架调度循环是整个内核的心脏说白了就是两个动作反复做把到期的延迟任务挪到就绪队列把就绪队列里的任务交给 Worker 执行。func (s *Scheduler) loop(ctx context.Context) { ticker : time.NewTicker(s.tickInterval) // 100ms 或 1s按业务精度要求调 defer ticker.Stop() for { select { case -ctx.Done(): return case now : -ticker.C: s.moveReadyTasks(now) // 到期任务进入就绪队列 s.dispatch(now) // 从就绪队列取出任务交给 Worker } } }这个模型是典型的“时间驱动 事件驱动”混合体。调度循环本身不执行业务逻辑它只负责搬任务和发任务。业务逻辑全部在 handler 里。好处是任务量再大调度循环的复杂度也跟任务总数脱钩只看时间轮和队列的维护成本。进程重启也不用怕任务元数据持久化在存储层启动时扫一遍未完成任务就能恢复。2. 时间轮是延迟任务的核心和 time.AfterFunc 的对比与实测延迟任务是ax里最核心的场景。订单超时关单、优惠券过期、延迟通知全都要靠它。我最早一版偷了个懒直接用time.AfterFunc给每个任务挂一个系统定时器。任务量几百个的时候毫无感觉上到几万就开始难受定时器数量一多Go 运行时内部的 timer 堆管理开销成倍增长GC 还要频繁扫描这些对象。更麻烦的是任务执行完如果还想调整时间重新排操作成本高代码也容易绕晕。后来我换成时间轮。这个思想的本质是把时间切成固定大小的槽位用指针循环移动每转到一个槽位就触发这个槽位里挂着的任务。但单层时间轮有一个绕不开的矛盾槽位数量固定时精度越高能表达的时间范围就越小。100 个槽位、每个槽位 10 毫秒最多只能表达 1 秒内的延迟。想要表达 1 小时槽位数量就得做到 36 万个内存和指针扫描都受不了。工程上的解法是分层时间轮一层轮子走满一圈就进位到下一层类似水表的计数逻辑。ax用的是两层加一个溢出堆第一层 1000 个槽位每个槽位 10 毫秒覆盖 10 秒超过 10 秒的任务落到第二层第二层 3600 个槽位每个槽位 1 秒覆盖 1 小时超过 1 小时的丢到最小堆兜底。实际运行下来绝大多数延迟任务的延迟时间都在 1 小时以内前两层就足够消化溢出堆很少被用到。核心结构的伪代码大概是这样type timeWheel struct { tick time.Duration // 每个槽位的时间跨度 size int64 // 槽位数量 current int64 // 当前指针位置 slots []*list.List // 槽位链表 overflow *minHeap // 溢出任务按到期时间排序 }插入任务的逻辑是先算目标槽位相对当前指针的偏移量把任务挂到对应槽位的链表末尾。指针每次走一格取出当前槽位所有任务逐个判断是否真正到期到期就移交就绪队列没到期说明跨了层重新插入上层轮子。整个操作平均下来是 O(1) 的。我拿 50 万个延迟任务做了个对比测试结果非常直观方案50 万任务内存占用插入耗时趋势调度精度goroutine time.Sleep600MB 以上高并发创建协程高但不可控time.AfterFunc260MB 左右O(log n) 堆操作高双层时间轮 溢出堆约 45MB平均 O(1)10ms 级别你可能会问10ms 精度够用吗对绝大多数业务完全够。需要毫秒级精确的基本是高频交易、实时风控那类场景和普通业务服务的关单、重试、异步通知完全是两个世界。ax定位就是业务异步调度10ms 甚至 100ms 的 tick 都够换来的是极低的内存占用和可控开销。3. 优先级队列和公平性设计高优任务插队不能把低优任务饿死时间轮解决了“什么时候执行”但任务到点进入就绪队列之后怎么被取走也是个技术活。如果老老实实 FIFO一个要跑 5 分钟的重型报表任务会把一批本该优先执行的超时关单任务全堵在后面。线上业务就是会同时存在“必须秒级响应”和“可以慢慢跑”两类任务调度器必须分清轻重缓急。ax的就绪队列实现成了一个带优先级的二叉堆。入队排序规则是“优先级高的先出优先级相同的按入队先后出”。Go 的container/heap提供了现成接口重写Len、Less、Swap、Push、Pop五个方法就行。type readyQueueItem struct { task *Task enqueued time.Time } type priorityQueue []*readyQueueItem func (pq priorityQueue) Less(i, j int) bool { if pq[i].task.Priority ! pq[j].task.Priority { return pq[i].task.Priority pq[j].task.Priority } return pq[i].enqueued.Before(pq[j].enqueued) }但纯优先级队列有个著名的问题高优先级任务源源不断进来时低优先级任务会被饿死。你一直有优先级为 10 的任务进来优先级为 1 的对账任务可能永远排不上最后业务方来投诉“我的任务怎么不见了”。解决思路我在ax里选了最有效的“老化”策略每个任务记录进入就绪队列的时间出队前计算等待时长超过一定阈值就在原优先级基础上加一档。低优先级任务等得越久实际出队的优先级就越高最终一定会被执行。这和操作系统里调度器处理公平性的思路是同一个道理系统不需要绝对公平但不能有任务被无限期饿死。3.1 Worker 池的并发控制Worker 端我用了固定大小的协程池默认大小是 CPU 核数的两倍。为什么不用“一个任务一个 goroutine”因为业务机器上的数据库连接池、下游接口的并发容忍度都有限。任务无脑并发第一个被打爆的就是你自己的依赖。固定 Worker 池把并发度控制住任务执行才是可控的。执行前取令牌执行完归还标准的信号量模型朴素但有效。type Executor struct { sem chan struct{} // 令牌桶大小即并发上限 handler func(ctx context.Context, task *Task) error } func (e *Executor) submit(ctx context.Context, task *Task) { select { case e.sem - struct{}{}: go e.run(ctx, task) case -ctx.Done(): // 队列满了且调用方已取消任务进入持久化缓存等待重载 e.backlog.save(task) } }任务执行必须有超时控制。没有超时的调度器等于允许死任务无限占坑一个 handler 卡住了整个 Worker 跟着阻塞。ax给每个任务都包一层context.WithTimeout同时用带缓冲的 channel 接收执行结果避免 handler 在超时之后返回导致 goroutine 泄漏。4. 单机版本必须先把自己扛干净三条不能省的基本功很多团队一上来就奔着分布式调度去其实单机都没站稳。我自己的想法是单机版本先把自己的责任扛干净再谈扩展。ax单机版主要做了三件“不让自己成为事故源头”的事。第一任务写入必须极快。调用方一次性提交十万个任务也不能让它阻塞。入队本质是内存操作加异步持久化我用的是批量写 WAL攒一批再刷盘刷盘失败先记内存缓存并告警不阻塞主流程。任务提交接口响应时间常年保持在 1ms 以内这一点很关键因为调度器自己慢了会把上层业务全部拖慢。第二任务执行慢路径必须可控。Worker 占满之后新任务不能直接丢而是留在就绪队列排队。队列长度加一个上限超过上限就把任务先溢写持久化队列空出来再重新载入。宁可任务晚跑也不能把内存撑爆。第三必须有 panic 兜底。业务 handler 很多写得不严谨panic 直接崩掉整个线程调度器跟着停摆。每个 Worker 执行任务前强制recover记录任务 ID 和执行堆栈然后按失败流程处理。线上那么多事故相当一部分是 handler 抛异常没兜住导致整个调度框架挂掉。这些点看起来都是“调度器最基本的东西”但真去翻自研调度组件的代码缺的恰恰就是这些。基本功到位了后面走到集群阶段才不会四处漏水。5. 分布式扩展分片、租约锁与双主防御单机稳定之后自然要支持多副本部署。这一步的难点不是“把任务分发到多台机器”而是“同一个任务不能被两台机器同时执行”。我最早用的是 RedisSETNX加 30 秒过期时间想着任务执行完就主动释放。结果有一次业务 handler 在加锁后、执行完成前发生了 FullGC锁超过 30 秒自动过期另一台机器拿锁进来把同一批数据又处理了一遍。重复消息、重复扣减全来了。后来改成租约式锁。租约锁和一次性锁的核心区别是锁不会因为一次过期就失效持有方在执行期间持续续租。func renewLease(ctx context.Context, key, owner string, ttl time.Duration) { ticker : time.NewTicker(ttl / 3) defer ticker.Stop() for { select { case -ctx.Done(): return case -ticker.C: // 使用 Lua 脚本保证原子性 // if redis.call(get, key) owner // then redis.call(pexpire, key, ttl_ms) // end } } }租约式锁的整条链路是拿锁 → 续租 → 执行 → 释放。只要续租心跳没断其他副本无法抢到同一任务。就算持有方真的宕机了锁也会在 TTL 之后自然过期由另一个副本补位。双主风险依然存在但窗口已经被压缩到“网络分区 TTL 同时出错”这种极小概率配合幂等键基本能防住。再往前走一步就是分片。ax支持按任务 ID 哈希分到固定分片比如 4 个副本分成 0/1/2/3 四个分片每个副本只处理自己分片内的任务。这比全局抢锁高效得多因为平时各副本之间不需要竞争。分片和租约可以叠加使用正常时各跑各的分片副本挂了之后剩余副本通过租约接管死亡节点的分片。5.1 持久化与恢复重启不能丢任务进程必然重启调度器必须能恢复。ax的做法是把任务状态落到数据库核心字段就是前面那个 Task struct 加上状态字段pending、running、success、failed、dead。每次状态变更都更新数据库调度循环每 5 秒扫一次“应该运行但还处于 pending”的任务做补偿式唤醒。补偿式唤醒的好处是能抵抗各种时间不确定性但代价是重复执行的概率上升。所以幂等键在这个阶段从“建议”变成了“必须”。接入规范里明确写了凡是ax触发执行的任务handler 必须幂等做不到幂等业务表必须建唯一索引兜底。没有这条纪律任何调度组件都救不了你。6. 线上排障我抓到的四类脏数据调度器正常跑起来之后真正消耗精力的反而是排障。本地环境和压测环境看不到的问题线上全冒出来了。我把最常见的四类列一下每条都是用事故换来的经验。6.1 时钟漂移是排障的第一大误导源有一阵子任务总是“提前几分钟执行”调度逻辑排查了无数遍都没问题最后发现是容器宿主机的时间不同步。调度器判断是否到点用的是time.Now()几台机器的系统时间差了 3 分钟时间快的节点提前跑时间慢的节点延后跑同一类任务在不同节点上执行时间的差异看着就像故障。解法很土但有效统一用 NTP 同步后的时间程序启动时校验本机时间和数据库服务器时间差超过阈值直接拒绝启动调度器。宁可集群少一个节点也不能让两个节点的时间语义不一致。6.2 重复调度不一定来自锁失效有一次线上出现重复关单排查租约、续租、幂等键全都没问题。后来发现是任务提交方自己炸了业务方创建订单时把同一笔订单的关单任务提交了两次两次任务 ID 不同但业务幂等键相同。调度器层面不是重复业务层却看到了两条执行记录。解法是在调度器入口直接对IdempotentKey建唯一索引重复提交直接返回“已存在”。但更深一层的经验是调度器永远不要假设上游只提交一次任务要在自己这一层就把“同幂等键的任务只允许一份生效”落到持久化约束里。6.3 超时误判催生出一堆重试任务的 Timeout 设置太紧或者业务执行时间刚好卡在超时线上会出现一种很迷惑的现象任务 B 的执行逻辑已经完全跑完但响应因为 GC 慢了一点调度器判定超时又触发一次重试。第二次执行时逻辑从头再跑一遍没做幂等的业务就彻底乱了。我的建议是Timeout 不能拍脑袋定要按 P99 执行耗时再放 1.5 到 2 倍余量。同时把“超时”和“失败”在告警里分开超时不立即重试先标记为可疑等业务确认后再手动补偿避免自动重试放大错误。6.4 日志必须能回答“任务为什么 3 秒后才开始执行”上线第三周收到一个反馈某个任务在数据库里显示创建时间是 14:00:00实际执行时间却是 14:00:03业务方怀疑漏任务。排障时才发现 3 秒完全是合理范围因为当时正好赶上那个分片的 Worker 队列排满。但问题在于没有数据能证明这一点只能靠猜。从那以后ax每执行一个任务就打印三条结构化日志提交日志含 RunAt、调度日志含实际出队时间、完成日志含耗时和结果。三个时间点一出来调度延迟RunAt 到实际出队时间和执行耗时立即分开。再遇到“为什么这么慢”的质疑打开日志对照一下就清楚慢在哪一段不用再扯皮。7. 几个抄走就能用的工程细节最后分享几个不一定写进设计文档、但真能少加班的细节。第一个是任务 ID 的生成。别用纯自增 ID多副本部署后你会后悔。我是用“实例前缀 时间戳 随机数”拼的比如ax-node3-20250317140000-abc123天然携带来源信息和时间信息。排障时看一眼 ID 就知道任务来自哪台机器。第二个是调度器启动前必须慢启动。进程启动后如果有大量到期任务需要补偿执行全部同时涌入 Worker 可能直接把下游打挂。正确顺序是先启动 Worker 池但限制并发量为平时的 1/2等补偿任务消化得差不多再逐步放开到正常并发。这个策略在大促场景救了我两次。第三个是任务 payload 大小必须设上限。我有一次见到有人往任务里塞了 1MB 的字符串序列化时直接把存储层打爆。ax默认限制每个任务 payload 不超过 32KB超了直接报参数错误。任务数据要小要能被序列化业务需要大对象时应该存引用而不是存实体。第四个是调度 tick 不能太小。有人想把 tick 设成 1ms 追求极致精确结果指针转得飞快CPU 空转严重。10ms 已经是性价比极限100ms 对绝大多数业务也完全够。调度精度从来不是越高越好而是够用就好。如果你正打算自研一个小调度组件我的建议是把单机核心做透再想着集群把时间轮和优先级队列这两个数据结构吃透再开始写代码把超时、重试、幂等这三件兜底事定死再上流量。“ax调度”听起来玄乎剥开看就是一层时间驱动的内核、一组有纪律的执行器、一堆可靠的持久化记录。把这三样理顺了调度系统想崩都难。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑