我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。

设计基于 Redis 的定时任务系统(ZSET + Scripting)

这篇文章会带领大家实现一个基于 Redis 的分布式高可用定时任务系统,其中的 worker 以 Node.js 为例,但其实可以使用任何语言来实现。这篇文章不会给出完整的代码,更侧重于探索的过程。

高可用设计

首先我们希望让 worker 是无状态的,这样会大幅减少对于 worker 的高可用需求,也不涉及到 worker 之间的数据同步或者选举。我们将所有的状态集中到 Redis 上,Redis 可以用 Master-Slave + Sentinel 的方式达到一个相对较高的可用性。

为什么不使用选举 Master 的方式?

有一种很常见的做法是在单个实例上完成所有的工作,这样甚至连 Redis 都不需要了。但为了保证高可用,往往会同时启动多个实例,然后引入一个「选举」的过程来决定谁是那个完成所有工作的人(称为 master),在 master 失效后,则需要重新进行选举,选出另外一个 master。

简单来说我觉得这种做法不够「分布式」,只有一个 master 在工作,其他实例只是热备而已,同时选举和对 master 失效的监测也是一个非常麻烦的事情。而在我们的方案中所有的 worker 都是平等的,都在执行任务,可以在任意时候创建或移除 worker。

核心循环

对于一个定时任务系统来说,核心的工作是给定时任务们按时间排序,然后找到并等待下一个需要触发的任务。Redis 刚好为我们提供了 ZSET 这种支持排序的集合类型,ZSET 中存储着若干个互不相同的 member(字符串或二进制数据),每个 member 有一个相关联的 score(数字),整个 ZSET 会按照 score 排序,基于这样的数据结构提供了若干操作。

我们将定时任务的 ID 作为 member 放在一个叫 cronjobs 的 ZSET 里,使用下次触发时间作为 score 来排序,这样便得到了一个以下次触发时间排序的列表。

我们可以这样向系统中添加任务:

redisClient.zadd('cronjobs', nextTriggerAt(cronjobId), cronjobId)

ZRANGE cronjobs 0 -1 WITHSCORES 可以看到已添加的任务:

127.0.0.1:6379> ZRANGE cronjobs 0 -1 WITHSCORES
1) "2"
2) "1565453967832"
3) "1"
4) "1565453998742"

然后我们可以在 worker 中写一个无限循环,不断地检查这个 ZSET 中 score 最小(触发时间最早)的任务是否已经超过了当前时间,如果是的话就执行这个任务,并修改 ZSET 中的 score 为下次触发时间,Node.js 的代码如下:

while (true) {
  const [cronjobId, triggerAt] = await redisClient.zrange('cronjobs', 0, 0, 'WITHSCORES')

  if (parseInt(triggerAt) < Date.now()) {
    // ZADD CH 会返回被修改 member 数量,只有当成功修改了 score 我们才会继续执行,否则说明这个任务已经被其他的 worker 执行了
    if (await redisClient.zadd('cronjobs', 'CH', nextTriggerAt(cronjobId), cronjobId)) {
      // 异步地运行任务,避免「阻塞」核心循环
      runJob(cronjobId)
    }
  } else {
    // 等待下一个任务触发,如果距离下一个任务的触发少于 10 秒,则等待下一个任务执行,否则等待 10 秒后重试。
    await bluebird.delay(triggerAt ? Math.min(parseInt(triggerAt) - Date.now(), 10000) : 10000)
  }
}

上面的循环构成了这个定时任务系统最核心的部分,后面我们会逐渐地完善他。

为什么不用 Keyspace Notifications?

在社区中有很多文章推荐简单地使用 Keyspace Notifications 来实现「定时」,即为一个 key 设置一个过期时间,然后订阅这个 key 过期的事件。但这种方式主要的问题是 Redis 的 Pub/Sub 并不保证送达,如果刚好在这个 key 过期时 worker 不在线,那么这一次触发就不会生效;如果刚好有多个 worker 在线,那么这一次触发的任务也可能被执行多次。

而我们选择的基于 ZSET 的方式,需要 worker 主动修改 ZSET 中的下次触发时间,即使 worker 暂时不可用,在恢复时也会继续执行之前剩余的任务。

这样 Redis 就变成了系统的单点?

是这样的,这个系统中几乎全部的状态都存储于 Redis 上,可以说是系统中的单点。但相比于 worker,Redis(或其他的数据库)是一个更稳定、更标准化的组件。你可以用官方的 Master-Slave + Sentinel 方案来达到一个相对较高的可用性,你也可以使用由云服务厂商提供的托管 Redis 产品,避免自己来维护它。

继续完善

CRON 表达式

前面的代码中我们并没有实现 nextTriggerAt,你可以用 cron-parser 这样的库去解析 CRON 表达式,计算下次触发时间:

const cronParser = require('cron-parser')

async function nextTriggerAt(cronjobId) {
  // 从 Redis 或其他数据库中根据 cronjobId 拉取定时任务的详情
  const cronjobInfo = await getCronjobInfo(cronjobId)
  return cronParser.parseExpression(cronjobInfo.cron).next().getTime()
}

中断任务处理

如果一个 Worker 意外退出,那么当时正在被它处理的所有任务都会永久性地丢失。为了避免这种情况,我们将正在执行的任务也存储到 Redis 中(一个叫 running 的 ZSET):

if (await redisClient.zadd('cronjobs', 'CH', nextTriggerAt(cronjobId), cronjobId)) {
    // 为每个任务生成一个随机的 uuid 以便能单独地追踪每个任务,例如打印到日志中
  await redisClient.zadd('running', Date.now() + 60000, `${cronjobId}:${uuid.v4()}`)
  runJob(cronjobId).finally( () => {
    // 在一个任务被完成时,我们还需要将它从 running 集合中取出
    redisClient.zrem('running', uniqueId)
  })
 }

如果你有一些为多实例应用编写代码的经验,那么可能会注意到这里存在一个竞态条件:对 cronjobs 和 running 的操作并不是原子的,可能会出现对 cronjobs 的操作成功了,随即 worker 意外退出,没有来得及写入 running 的情况。

因为这里我们需要对 ZADD 的返回值做判断,所以不能简单地使用 Redis 的 Pipeline 功能,而是要用到 Lua Script:

redisClient.defineCommand('startJob', {
  lua: `
    local cronjobId = ARGV[1]
    local jobName = ARGV[2]
    local nextTriggerAt = tonumber(ARGV[3])
    local timeoutAt = tonumber(ARGV[4])

    local changed = redis.call('ZADD', 'cronjobs', 'CH', nextTriggerAt, cronjobId)

    if changed ~= 0 then
      redis.call('ZADD', 'running', timeoutAt, jobName)
    end

    return changed
  `
})

经过修改后的核心循环:

const jobName = `${cronjobId}:${uuid.v4()}`

if (await redisClient.startJob(cronjobId, jobName, nextTriggerAt(cronjobId), Date.now() + 60000)) {
  runJob(cronjobId).finally( () => {
    redisClient.zrem('running', uniqueId)
  })
}

然后我们便可以添加另外一个循环,从 running 中拉取已经超时的任务进行重试或其他处理,这里不再给出具体的代码。

Lua Script

Lua Script 是 Redis 提供的一种类似事务能力,Redis 保证每个 Lua Script 都是串行执行的,中途不会有其他指令被执行,这提供了一种非常强的一致性保证。在实际的开发中,我们可以将需要一致性保证的逻辑写成 Lua Script。

平滑关闭

我们不可避免地会对 worker 进程进行新版本的部署或其他维护,因此我们需要一种平滑的方式来关闭 worker 进程,让它继续执行已经收到的任务,但不去接受新的任务,在执行完当前的任务之后,主动退出。

在 Unix 中最正统的方式是实现自定义的 SIGINT 处理器来实现这个功能,即由终端模拟器、进程管理器或容器平台向程序发送 SIGINT 信号,程序即开始进行退出前的清理工作,然后待清理工作结束后,程序主动退出。当然进程管理器也有可能等不及,再发送一个强制结束的 SIGKILL。

所以我们需要将所有正在执行的任务注册到一个全局的 Promise 数组中,然后在受到 SIGINT 时停止接受新任务,并等待所有正在执行的任务完成后主动退出:

let runningJobs = []
let shuttingDown = false

process.on('SIGTERM', () => {
  shuttingDown = true

  // 等待 runningJobs 中所有的任务完成,无论成功还是失败
  Promise.all(runningJobs.map( p => p.catch(() => {}) )).then( () => {
    process.exit(0)
  })
})

修改核心循环,在开始任务时将 runJob 返回的 Promise 存入 runningJobs,然后在任务执行完时取出:

while (true) {
  if (shuttingDown) {
    break
  }

  // ...

  if (await redisClient.zadd('cronjobs', 'CH', nextTriggerAt(cronjobId), cronjobId)) {
    // 异步地运行任务,避免「阻塞」核心循环
    const jobPromise = runJob(cronjobId)

    jobPromise.finally( () => {
      _.pull(runningJobs, jobPromise)
    ))

    runningJobs.push(jobPromise)
  }

  // ...
}

容量的横向拓展

目前这个定时任务系统中的 worker 是可以无限拓展的,但 Redis 却是整个系统中的瓶颈,每个 worker 都需要从 Redis 获取任务来执行。按照我们对于 Redis 通常 70k QPS 的估计,按每个任务需要执行 5 个命令计算,整个系统可以支持每秒 14k 次任务触发,对于绝大部分的场景其实完全够用了。

如果要继续拓展的话,我的建议是根据业务上的一些区分(例如用户、任务类型)将队列分散到不同的 Key 上面(例如 userA:cronjobsuserB:cornjobs),这样便可以利用 Redis Cluster 的分片功能来进行扩展了。

小结

我们用 ZSET 将定时任务按照触发时间排序,然后使用一个无限循环来拉取需要触发的任务,实现了一个分布式定时任务系统的核心部分,读者可以在此基础上根据自己的需要做进一步扩展。

本文甚至没有给出完整的代码,因此并不能直接地复制到你的项目中使用,更多地在于提出和讨论一种解决方案。社区中也有一些类似的开源组件可供选用,例如 Bull 是一个功能完整的任务队列,其中包括了定时任务功能,Bull 使用了和本文类似的 ZSET + Scripting 技术,使用 Redis 作为后端。

我和太空探索的故事

从小我就对太空以及人类对太空的探索特别感兴趣,最近一段时间因猎鹰重型火箭首次商业发射和回收成功,又激起了我对于太空探索的兴趣,今天我想讲一讲我和太空探索的故事以及这件事为何如此让我着迷。

在 2002 年前后(7 岁),我爸送了我两本书 最新21世纪少年儿童百科 和「十万个为什么」(这个版本全是文字、每本几百页左右、共 12 册,我并没有在网络上找到,现在想起来这本书的目标用户也许是家长才对)。我对太空探索以及其他科学知识的启蒙都来自于这两本书,小时候可以阅读的内容并不多,在之后的近十年中,这两本我起码看了十几遍以上,不过现在其实并不能想起来着两本书上具体讲了什么,只有一些模糊的片段。

后来 2005 年(10 岁)的暑假,发现号航天飞机在哥伦比亚号的事故后首次 执行任务,我记得那半个月我每天都从新闻和报纸关注事件的动态,还用积木(其实是麻将牌)去模拟我所理解的航天飞机发射过程。那时候每天晚上会有一段固定的时间,爷爷会给我解答一些我感兴趣的问题,我当时并不理解太空是怎样的,也不了解飞船是如何工作的。我想搞清楚这些,但受限于当时的知识,我只能以我所理解的方式去提问,所以很多时候并没有得到想要的答案。

值得一提的时候我高中有那么一年多在玩 EVE 这个游戏,其实说起来 EVE 在飞船的操纵上已经十分简化了,游戏背景中的先进科技也绕开了现实中的燃料和光速等限制。但在 EVE 中驾驶飞船给了我对于宇宙的一种直观的认识 —— 星球是如此地大、星球之间的距离是如此地远,其余的空间都是一片虚无,如果没有一个坐标的话,你几乎不可能和别人相遇。

对我影响比较大的作品还有 三体星际穿越。三体是我在高中的时候看的,因为我小说其实看得并不多,所以这是第一本让我印象深刻的科幻小说;星际穿越则是我刚刚离开学校(18 岁)时看的,也是我非常喜欢的太空背景电影。这两部作品构建了我对于太空的形象化的认识,无论是三体中的黑暗森林设定还是星际穿越中黑洞的视觉呈现,都很好地把握了宇宙的尺度。当时看完星际穿越我发了一条 推文:「看了 Interstellar, 虽然不喜欢结局,但确实是一部非常之优秀的科幻作品。觉得在宇宙面前人类的力量实在太渺小,或许其他的技术难关都可以克服,但时间的限制是永远突破不了的。大概人类永远也无法离开地球,因为一旦到宇宙中去,对于人类,时间或是过于短暂,或是无比漫长。」

蛋黄同样对太空探索很感兴趣,近两年我们也看了不少相关的纪录片,我们经常互相拉着对方讲新了解到的关于太空探索有趣的事情:蛋黄讲得比较多的是历史,而我会更关注技术。我们关注最多的具体项目还是阿波罗计划了,毕竟是人类首次登上另外一颗星球,直到目前依然保持着人类所到达的最远的地方的记录。在做了更多了解之后,我发现其实有非常多的美国公司(尤其是飞机制造商)都参与了阿波罗计划,负责具体的火箭和飞船制造,而 NASA 主要负责的是设计和组织,整个项目也可以算是人类历史上最宏大的工程之一了。

提到太空探索的爱好者,就必须要提 Kerbal Space Program(坎巴拉太空计划)这款游戏,基本上在知乎这样的社区只要涉及航天或火箭相关的话题,评论区就一定有人刷坎巴拉的梗。在我入手这个游戏后便很快沉迷无法自发,在游戏中我可以亲手实现之前的文字或视频中见到过无数次的技术:发射入轨、霍曼转移、交会对接、反推着陆、引力弹弓。也对比冲、Δv 之类的数值有了更深刻的认识,之后再看纪录片的时候就能脑补出其中提到的各种操作了。后面我应该会专门写一篇文章来介绍坎巴拉太空计划这个游戏。

那么为什么我对于太空探索如此着迷呢?首先我还是视它为一种兴趣,是因为去了解这些知识会让我感到高兴和满足,而不是我要用这些知识做一件什么事情。相比于其他的学科,例如数学、物理甚至是计算机,最新的研究成果几乎都是我无法理解的,并不适合作为消遣和兴趣;而美国人登陆了月球、火星发现了液态水、木卫四可能存在生命这些就要好理解得多。这大概是因为作为太空探索的主力 —— NASA 的大部分资金都来自于财政拨款,这要求它必须以一种普通人能够理解的方式,向美国的纳税人解释他们的工作,以得到更多人的支持、得到更多的拨款。

接下来的几年也非常值得期待,在太空竞赛、人类登月之后,经过几十年的修整,接下来十几年人类很有可能会在航天领域取得新突破。比如 SpaceX 的 BFR 和 Starlink,人类重返月球和登上火星以及更多的深空探测计划。

流浪地球和大刘的短篇小说

除了是哆啦 A 梦的铁杆粉丝之外,我也算是刘慈欣的粉丝 —— 三体自然不用说,其他所有的短篇小说我都看过两到三遍,这些短篇小说一点都不比三体逊色,流浪地球就是其中之一。

刘慈欣的小说向来重设定而轻情节,小说关注的是全人类的命运,主角只是叙述的视角而非故事的核心。在阅读小说的过程中,你体验到的不是沉浸感,而是一种站在上帝视角观察人类的新奇感,其中的人物只是当时社会中人类的典型代表。他会在小说中使用类似「刹车时代」、「威慑纪元」这样的标题来强化这种上帝视角,让书中短短几句的描述比电影的特效画面更加让人感到震撼。

最近我又重温了刘慈欣的几个短篇小说,包括:

  • 流浪地球:给地球装上发动机来驶离太阳系
  • 镜子:一台能够根据宇宙的初始状态模拟整个宇宙的计算机
  • 吞食者:外星人通过环状太空船套住地球并掠夺地球的资源
  • 山:一个诞生于行星地心的机械文明探索世界的故事
  • 地火:通过点燃地下的煤层的方式来开采水煤气
  • 地球大炮:挖一条贯通地球地心的隧道
  • 赡养人类:在贫富差距达到极致之后,99% 的财富都集中到了一个人

回到流浪地球这个电影,视觉效果我还是很满意的,也确实是头一次看到中国的地标能够出现在科幻片或者灾难片中。情节上前半部分对于世界观的介绍还可以,但后半部分就非常俗套了,就是一个很普通的、几个英雄拯救地球的故事。其实我更宁可电影能从中间结束 —— 让地球撞木星也不是不可以。

前面提到过,流浪地球的小说关注的是全人类的命运、全人类在面对灾难时的反应,给人一种宏大和震撼的感觉,而电影则完全没有继承小说的精神内核,显得格局太小,更偏向于主角在整个大的世界观中的个人体验,而不是整个流浪地球计划。

我认为小说中有两个关键的点电影没有表达出来,一是人们对于「太阳」的情感的变化,以前太阳象征着温暖、壮美,但当人们知道这个太阳随时可能会爆炸之后,太阳便变成了恐惧的象征。在变轨的过程中地球一次次地接近和远离太阳,每当地球到达近日点的时候,这种恐惧就会到达顶峰。这种恐惧在几代人的时间里渗透到了社会文化和每个人的心中。而当地球逃离了太阳系、从对太阳的恐惧中解脱之后,人们又开始怀念太阳的温暖,开始怀疑整个计划是否正确。

另外一点是人们面对灾难时的冷静和理性,小说中有一个情节是在地下城遇到岩浆涌入需要撤离时,自动按照年龄排成一队,因为通常来说越年轻的人对于社会的价值会越大。因为人类知道了自己的命运,但又不知道自己是否可以逃离,太阳又永远悬在天上,这种恐惧深刻地影响了人们对于生活的态度,这也可以解释为什么人类能够组成联合政府、能够在全世界范围内协调和完成如此大规模的地球发动机。

当然这些评价是我从原著读者的角度来说的,如果作为一个普通的的科幻电影,或普通的国产电影,它应该是合格的。结尾的地方有一个很短的镜头是北京的地下城里有一队人在游行,举着一个「我们要太阳」的牌子,这个才是小说本来的结尾,算一个彩蛋吧。

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2024-04-08.