Durable Delays:将时间作为分布式状态来管理

SchemaBridge Team · 2026-01-10 · Time, Scheduling, Orchestration

无内存泄漏地处理 30 天的等待期。为什么 `sleep()` 是分布式系统的反模式。

sleep() 的谎言:为什么时间是最难的分布式原语

每种编程语言都有一个等待命令。Python 里是 time.sleep(),Node.js 里是 setTimeout(),Java 里是 Thread.sleep()。这些命令简单直观,但对于任何超过几秒的场景,在生产级分布式系统中完全无用。

朴素的 sleep() 是个谎言,因为它假设环境是稳定的。它假设运行代码的机器会一直存活,进程不会被负载均衡器杀掉,内存也不会被操作系统回收。在现代云环境中,这些假设都不成立。如果你在一个标准的 Kubernetes Pod 中执行 sleep(24 60 60)(一天),这个 Pod 很可能在这一天结束前就被轮换、重新调度或重新部署。

进程一死,你的"sleep"也随之消亡。你的业务逻辑就此消失得无影无踪。"健忘的软件"就是这样诞生的——这些系统会丢失客户试用期的跟踪、错过订阅续费、漏发关键的跟进邮件。

时间的层级:从秒到月

要正确地管理时间,首先必须对它分类。并非所有延迟都一样:

1. 瞬时延迟(毫秒到秒):通常是网络退避,或等待一个很快释放的数据库锁。这里有时可以接受 sleep(),因为在 50 毫秒的窗口内崩溃的风险很低。

2. 短延迟(分钟):从这里开始 sleep() 就会失灵。你在什么都不做的情况下占用一个工作线程或容器资源长达数分钟。这既浪费钱,也给连接池带来风险。

3. 持久化延迟(小时到月):这是 业务生命周期 的领域。14 天免费试用、30 天付款期限、每 6 个月一次的维护计划。这些不能存在于代码中,必须存在于 基础设施 中。

休眠工作流的架构

在 SchemaBridge,我们把时间视为 持久化状态。当工作流到达一个 DELAY vertex 时,它不会阻塞任何线程。该步骤被挂起为 SUSPENDED,唤醒意图被写入数据库,worker 立即空出来去处理其他工作。

数据库轮询模型(低精度,高持久性)

经典的自研方案把"唤醒时间"存到数据库表里,由一个后台 worker("轮询器")每隔几秒查询一次:SELECT * FROM timers WHERE wake_up < NOW()。由于数据库记录能在重启后保留,这种方式是持久的——但代价是源源不断的空查询,以及精度下限等于你的轮询间隔。

SchemaBridge 的做法

SchemaBridge 保留了数据库记录的持久性,同时去掉了固定周期的轮询。每个唤醒点都以一行带有绝对时间 execute_at 的记录持久化到 PostgreSQL 的 scheduled_tasks 表中(位于我们的 ScheduledTaskRepository 之后)。时钟 worker 会休眠到最早一个待处理的 execute_at,而每当有任务变为可执行时,PostgreSQL 的 LISTEN/NOTIFY 推送会提前唤醒它。当有任务到期时,它用一条带 FOR UPDATE SKIP LOCKED 的语句认领这些任务,因此多个时钟 worker 副本可以分摊积压任务而不会互相冲突。一个宽松的空闲上限仅作为兜底,以防漏掉某条通知。

-- 认领的简化形态:到期的行,最早的优先,在副本间分区
UPDATE scheduled_tasks SET status = 'PROCESSING'
WHERE task_id IN (
  SELECT task_id FROM scheduled_tasks
  WHERE status = 'PENDING' AND execute_at <= :now
  ORDER BY execute_at
  FOR UPDATE SKIP LOCKED
  LIMIT 100
)
RETURNING task_id;

取消与提前退出:改变未来

持久化延迟只是故事的一半。如果用户处于"等待 3 天付款"的步骤中,却在 2 小时后就付了款,应该发生什么?

首先是取消。在 SchemaBridge 中停止一次运行,会先取消该执行,然后解除这次运行中待处理的延迟定时器和审批定时器,因此已停止的流程不会在几天后醒来继续执行。这个顺序是刻意设计的——先取消,再解除——所以最坏的情况是某个定时器在一次已被取消的运行上触发,而终态守卫会让它成为无害的 no-op。

其次是提前唤醒。SchemaBridge 没有 内置"外部事件到达时唤醒此延迟"的原语。DELAY vertex 会等待其配置的时长。对提前退出进行建模,有两种诚实的方式:

这两种模式都不需要引擎让定时器与信号赛跑——这正是那种你应该通过设计来消除、而不是寄希望于它不发生的竞态。

时钟漂移与精度

在任何分布式系统中,时钟都不可能完全同步。这就是 时钟漂移 现象。如果一台机器的时钟比另一台快 50 毫秒,那么"等待 1 秒"的行为可能因任务在哪里被拾取而不同。

SchemaBridge 通过把唤醒点设为 存放在唯一位置的绝对时间戳——PostgreSQL 中的 execute_at 列——来把这个问题控制在很小的范围内。设置延迟时只计算一次 now + delaySeconds 并存储下来;此后没有任何 worker 在内存中维护倒计时,因此不存在会漂移或在重启时丢失的单 worker 定时器。机器之间普通的时钟偏差依然存在,但对于以小时或天计的业务级延迟而言,它对结果无关紧要。

示例场景:自动化一个 30 天的滴灌式营销活动

设想一个营销自动化平台正为其"欢迎旅程"逻辑头疼。

挑战

一个旅程包括:

如果构建在由 Cron 任务和一张 Postgres 表组成的自研方案上,这样的旅程很脆弱:在数据库维护窗口或部署期间,一部分用户可能会"卡"在等待状态,永远收不到下一封邮件。

SchemaBridge 的方式

设想把同样的旅程重建为 SchemaBridge 工作流。

1. 可视化延迟:把一个 DELAY vertex 拖进图中,并将 delaySeconds 设置为 259200(3 天)和 604800(7 天)。构建器会把它们显示为"3d"这样的紧凑时长。

2. 持久化恢复:在部署期间,等待中的运行只是继续停放在数据库中。时钟 worker 恢复后,会按从旧到新的顺序认领停机期间到期的定时器并恢复它们。

3. 唤醒时重新检查:每次延迟之后,由一个 Gateway 步骤询问邮件或计费服务商用户是否点击或升级,再由一条条件边选择下一步。

预期收益

对比:现有的调度策略

| 特性 | setTimeout() | Cron 任务 / Quartz | SchemaBridge 持久化延迟 |

| :--- | :--- | :--- | :--- |

| 持久性 | 无(易失) | 手动(基于数据库) | 原生(PostgreSQL scheduled_tasks) |

| 可扩展性 | 低(受限于内存) | 中(数据库瓶颈) | 高(跨副本 SKIP LOCKED) |

| 取消 | 复杂(手动句柄) | 手动清理数据库 | 停止运行即解除其定时器 |

| 可见性 | 无 | SQL 查询 | 挂起(暂停等待)的步骤可在运行视图中查看 |

| 分辨率 | 毫秒 | 秒/分钟 | 推送唤醒(LISTEN/NOTIFY),无固定轮询 |

长时间运行业务逻辑的专家清单

如果你在设计一个需要等待超过 5 分钟的系统,请遵循以下经验法则:

1. 停掉线程:等待时绝不要阻塞 worker。你的 worker 应该是无状态的,随时准备被杀掉。

2. 把时钟外部化:用一个集中、持久的存储来保存唤醒时间,而不是本地机器的内存定时器。

3. 为提前出现的结果而设计:始终假设用户可能在延迟到期 之前 就完成了你在等待的操作。等待结束时重新检查状态,而不是假设一切未变。

4. 审计等候室:关注有多少运行存在停靠在计时器上的步骤。这是衡量业务健康状况的重要指标。

5. 优先使用相对延迟:把等待表达为距上一步的时长(delaySeconds),而不是固定的日历日期,这样即使前面的步骤延迟运行,步骤之间的相对间隔也保持一致。

结论:时间是一个基础设施问题

会遗忘的软件就是会失败的软件。通过把时间视为一等的持久化状态,我们在实时事件与高延迟的人类行为之间架起了桥梁。借助 SchemaBridge,一个跨越数天或数周的旅程能够挺过那些足以杀死休眠线程的重启和部署——前提是你设计好提前退出,并把后续步骤需要的数据向后传递。

可靠地掌控交互时机的能力是一项真正的竞争优势。我们提供持久的时钟;你来提供旅程。

本文属于我们的 Building the Bridge 系列。另请参阅:可观测性鸿沟,了解如何调试长时间运行的多步骤执行,而不至于在日志中迷失方向。

深入了解