1. 知识回顾
上节课我们主要学习了 OpenClaw 的 heartbeat 和 cron 两种异步任务机制,有如下特点:
- 异步任务在后台自动运行。
- 当有用户提问时,系统会优先响应用户 ,阻塞 heartbeat等主动任务。
- cron 任务在后台默默执行,不影响主流程。
本节课我们来解决一个OpenClaw的核心痛点,当发送消息时,可能会因为网络故障 、进程崩溃 等原因导致回复丢失 而影响体验,所以OpenClaw的做法就是引入可靠消息投递机制,确保消息不会丢失。
2. 总体设计思路
OpenClaw的做法就是消息必须先写到磁盘,然后再尝试发送。
- 消息必须先持久化到本地磁盘,确保掉电/崩溃后依然可恢复。
- 随后再通过后台线程异步尝试发送,失败则自动重试。
实现细节
3.1 消息分片
大模型生成的回复可能过长,需要根据不同平台的消息长度限制进行切分:
- 如果回复较短,则不分片。
- 如果回复过长,则按平台规则切割为多个片段,依次投递。
3.2. 生成任务对象并持久化入队 💾
具体做法是:
- 生成唯一的
delivery_id(UUID 截取前 12 位)。 - 构造
QueuedDelivery任务对象,包含通道、接收方、文本内容、入队时间、下次重试时间等。 - 调用
_write_entry()写入磁盘。
下面的代码演示了原子写入的过程,防止写入过程中断导致文件损坏。
python
def _write_entry(self, entry: QueuedDelivery) -> None:
"""通过 tmp + os.replace() 保证原子写入,避免数据损坏"""
final_path = self.queue_dir / f"{entry.id}.json"
tmp_path = self.queue_dir / f".tmp.{os.getpid()}.{entry.id}.json"
data = json.dumps(entry.to_dict(), indent=2, ensure_ascii=False)
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno()) # 强制落盘
os.replace(str(tmp_path), str(final_path)) # 原子替换
通过将消息落入磁盘进行持久化,防止消息丢失。
python
delivery_id = uuid.uuid4().hex[:12]
entry = QueuedDelivery(
id=delivery_id,
channel=channel,
to=to,
text=text,
enqueued_at=time.time(),
next_retry_at=0.0, # 立即重试
)
self._write_entry(entry) # 落盘后才返回
return delivery_id # 返回 ID 给上层
3.3 后台线程扫描
系统启动时,后台线程会扫描 pending(待发送)和 failed(失败)队列,确保没有遗留任务。
python
pending = self.queue.load_pending()
failed = self.queue.load_failed()
# 打印恢复状态
if pending:
print_delivery(f"Recovery: {len(pending)} pending")
if failed:
print_delivery(f"Recovery: {len(failed)} failed")
后台线程进行轮询,遍历pending队列。
python
for entry in pending:
# 1. 检查是否到达下一次重试时间
if entry.next_retry_at > now:
continue
# 2. 尝试投递
try:
self.deliver_fn(entry.channel, entry.to, entry.text)
self.queue.ack(entry.id) # 投递成功,确认删除
self.total_succeeded += 1
except Exception as exc:
# 3. 投递失败,记录失败并更新重试次数
self.queue.fail(entry.id, str(exc))
self.total_failed += 1
# 4. 判断是否超过最大重试次数(MAX_RETRIES)
if entry.retry_count + 1 >= MAX_RETRIES:
print_warn(f"Delivery {entry.id} -> failed (最终失败)")
else:
# 进入指数退避等待
backoff = compute_backoff_ms(entry.retry_count + 1)
print_warn(f"next retry in {backoff / 1000:.0f}s")
为了防止某个消息一直发送不成功而抢占其他消息的时间,OpenClaw使用了带随机抖动的指数退避算法,具体代码如下:
python
# 基础退避时间序列(毫秒)
BACKOFF_MS = [5_000, 25_000, 120_000, 600_000] # 5s, 25s, 2min, 10min
def compute_backoff_ms(retry_count: int) -> int:
if retry_count <= 0:
return 0
# 根据重试次数选择对应的基础退避档位(超出则取最大)
idx = min(retry_count - 1, len(BACKOFF_MS) - 1)
base = BACKOFF_MS[idx]
# 加入随机抖动(±20%),避免大量消息同时重试造成网络风暴
jitter = random.randint(-base // 5, base // 5)
return max(0, base + jitter)
4. 知识总结
| 模块 | 作用 |
|---|---|
| 消息分片 | 适配不同平台的长度限制,避免发送失败 |
| 原子写入 | 利用 tmp+ os.replace,防止崩溃导致文件损坏 |
| 持久化队列 | 内存队列 + 磁盘存储,确保重启后任务不丢失 |
| 指数退避重试 | 失败后等待时间递增(含随机抖动),避免服务端过载 |
| 后台线程扫描 | 服务启动时自动恢复未完成的任务,实现高可用 |