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

2019 年度小结(技术方面)

Redis 的工程价值

今年一开始先是完善了去年的 任务队列,为了让它真正地被用起来,我将之前由另外一个服务实现的定时任务系统也合并进了这个任务队列,让它有了一些固定的流量,以便我来发现性能上的问题,进一步完善它。

这项工作最后取得了还不错的效果,开始有了用户使用任务队列的功能。我也写了一篇文章来介绍 用 Redis 来实现定时任务,当然这篇文章中介绍的是一种经过极度简化的范式,实际上实际的代码要复杂的多,例如序列化、错误处理、结果查询、限流、统计等额外的特性。

这个项目让我再度增加了对 Redis 的好感,这次我用到了比较大量的 Lua Script 来保证分布式架构下的一致性(Redis 的Lua Script 会被独占地执行)。在将一致性需求限制到单个 Redis 实例可以容纳的范围(Redis 只使用一个线程)并且 Redis 运行相对稳定的情况(故障切换会丢失数据),Redis 为业务层提供了一个非常「够用」的高性能的、有一致性保证的数据同步方案。这并不是说 Redis 提供的方案有多么完美,而是在性能、功能、一致性、可靠性上提供了一个非常好的这种,更具有工程价值。

Golang 的表达能力

我还尝试了为 LeanCloud 写一个 Golang 的 SDK(后来因时间安排的关系暂停掉了,目前还未发布),就像之前我为 DeployBeta 写 ORM 一样,同样遇到了 Golang表达能力不足的问题。问题主要在于 Golang 中并没有能让 Struct 继承方法的机制(数据字段则可以通过内嵌匿名 Struct 的方式来继承)。

所以当用户定义一个「继承」自 ORM 基类的 Struct 时,我们无法向用户定义的 Struct 上添加例如 Save、Set 之类的方法,无法有效地追踪用户对于数据对象的改动。

经过几个版本的改动,我最后选择了一种将所有基本类型(string、int)包装为 Struct 的方案:

type Todo struct {
  orm.ObjectMeta

  Name     orm.String `orm:"name"`
  Priority orm.Number `orm:"priority"`
}

todo := Todo{
  Name:     orm.NewString("test"),
  Priority: orm.NewNumber(1),
}

err = orm.Save(&todo)

todo.Name.Set("test")
todo.Priority.Incr(1)

err = orm.Save(&todo)

fmt.Println(todo.Name.Get(), todo.Priority.Get())

这个方案可以做到不以字符串的形式传递字段名(可以得到编译期的类型检查),可以追踪对每个字段进行的修改(包括 Incr 等运算)。我将 Set 添加到了基本类型的封装类型上,将 Save 作为了一个全局方法,避开了 Golang 对于继承的限制。带来的问题则是用户需要通过我们的封装方法(Get)来访问字段的值;同时今后设计嵌套对象时也需要更大的工作量。

所以并不是如 Golang 的支持者说的那样,更少的特性意味着更简单的设计。当业务逻辑确实复杂,语言表达能力又非常匮乏的情况下,会逼着开发者做出一些不优雅的、不易理解的、反常规的设计,这些代码往往非常容易出错(例如反射、代码生成、强制类型转换等),而本来这些需求(如继承)在其他语言里是可以非常轻易地解决的。

TypeScript 的胜利

之前因为对 CoffeeScript 的喜爱,我的 TypeScript 使用经验非常少,终于今年我也不得不去接受 TypeScript 了。今年我用 TypeScript 开发了两个新的后端项目,也更深入地学习了 TypeScript,经过进一步的了解,我逐渐地发现了 TypeScript 的闪光点,之后我会单独写一篇文章来介绍 TypeScript。

TypeScript 有着一个先进的类型系统,这种先进并非是学术意义上的先进,而是工程意义上的先进。它几乎可以为所有 JavaScript 中常见的范式添加静态约束,得益于强大的类型推导,在大部分情况下并不需要自己添加类型标注,但却能在编译期提前发现错误、配合 Language Server 得到准确的代码补全和类型提示信息,完全没有前面提到的 Golang 中的那种束缚感。

因为 TypeScript 并不打算创造新的范式,而是尽可能将 JavaScript 社区中已有的范式用静态类型的语义描述起来。这样最大程度上地降低了 JavaScript 开发者学习的成本,提高了与标准 JavaScript 代码的互操作性,我认为这也是 TypeScript 能够取得成功的关键。

同时我也不得不接受 Atom 的市场已经几乎完全被 VS Code 取代的现实,切换到了对 TypeScript 支持更好的 VS Code。现在想想 Atom 失败的原因一方面是在 CoffeeScript 已经表现出没落的时候选择了 CoffeeScript;另一方面是希望依靠社区的力量,但又缺乏对社区的引导。例如对于插件的 GUI 改动引导不够导致界面卡顿,对于代码补全、调试等常见需求没能建立统一的标准等等。

Kubernetes 的阴谋

今年其中一个新项目是开发一个数据库调度平台,在 Kubernetes 上运行数据库容器,这和我之前在 DeployBeta 实现的原型非常相似,只不过这次是真的要上线的项目。

在去年和今年对 Kubernetes 的了解过程中我逐渐对 Kubernetes 由粉转黑。我现在认为 Kubernetes 是以 Google 为首的三大云计算巨头的垄断工具,他们开发出了一个如此复杂的系统,并引导其作为行业标准。虽然 Kubernetes 是开源并由社区维护的,但真正能够独立搭建好 Kubernetes 及其插件的公司是极少数,甚至可以说除了三大巨头之外,其他的云计算公司都不能提供稳定可靠的 Kubernetes 集群。最后大家在尝试过自己搭建之后,还是不得不购买三大巨头的 Kubernetes 云,毕竟这是行业标准嘛。

今年看过觉得最好的书是「数据密集型应用系统设计(DDIA)」,它给我的数据库调度平台带来了很多启发。书中介绍了分布式架构对于数据库的挑战,包括数据模型、复制、分区、事务、分布式共识等等,以及各个数据库在面对这些挑战时采取的解决方案,只有理清这些思路,才能在面对复杂的业务的时候采用一种或几种合适的数据库。

我的理解是当数据存在于两台或更多的计算机之上时(原因可能是容量或可用性要求),就可以称作「大数据」了。因为从一台到两台是一个质的变化,而从两台到更多只不过是量的变化。就如书中所说,在单机条件下,所有的称作都是确定的,一个操作要么成功要么失败(可能伴随着程序或系统的崩溃);但在分布式条件下,对于经过网络的操作会引入成功和失败之外的第三种情况 —— 网络延迟,你无法预测一个操作会在下一秒完成还是永远都不会完成。所以分布式系统需要被设计成可以在容忍一定的错误(部分失效)的情况下继续运行。无论是一个分布式数据库还是一个分布式的容器平台,其实都在与这种不确定性的超时进行对抗。

写不下去的业余项目

现在我愈发认识到软件开发不是一个人的单打独斗,之前在做一些业余项目的时候还会有一些幻想,幻想自己能长期维护下去、能吸引到其他的贡献者、能建立起一个社区。但现在想想还是以内容作为主要的输出更有可行性。同时因为我对现在的工作非常满意,在工作中基本完全满足了我对于写代码和团队协作的欲望,所以我可以将业余时间放在其他的输出形式上,在接下来一年中输出更多的文章或视频,这样我的经验和知识会给读者带来更大的价值。

设计基于 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 作为后端。

2018 年度小结(技术方面)

今年我完成的业余项目确实非常少,勉强算下来也就只有 DeployBeta 的 0.4 版本和 Elecpass 的 v3 版本。

所以我今年得到的一个重要的教训就是 一次要专注于一个项目,同时尽快完成一个阶段性的可用版本,尽快发布。尤其是对于我来说,还有全职工作,业余时间并不多,如果不能尽快发布,获得一些反馈,就失去了继续完善的动力,而且会摊子越铺越大,一直无法发布。

在这一点上,DeployBeta 是一个反面的例子,持续了两年的时间,但也没到可以对外发布的标准。而 Elecpass 是一个正面的例子,在 2017 年 10 月,我花了半个月的时间就发布了两个版本,之后一年多的时间我自己一直在使用,然后在今年十一月集中花了一周的时间发布了 v3 版本(筛选框、布局优化、强化编辑功能、编译 Windows 版),这个项目大部分的时候都是「已发布」状态,而不是还有功能做到一半。


在 DeployBeta v4 版本的前夕我将 DeployBeta 开源了,在 v4 和后续的未发布版本中,我实现了 MySQL、Redis、MongoDB 三种数据库的支持、重写了基于 Etcd 的 ORM。所以今年我也写了一些 Golang,今年我主要的怨念在于 Golang 中缺乏对于接口数组(或者说泛型数组)的支持,只能使用 interface{} 和反射来实现 ORM 中「获取结果数组」的功能。

这个 ORM 其实就是将 JSON 数据存储在 Etcd 中,同时提供关系和事务的简单封装。这其实和 Kubernetes 中的 api-server 做的事情差不多,但因为没有找到比较好的关于 Kubernetes 使用 Etcd 的文档,所以我没有太多地参考它。


为什么容器平台能够简化容器的管理工作呢?我认为一个重要的原因是容器平台提供了一种纯描述式的定义文件,让开发者去描述所期望的最终状态。这一点在 Kubernetes 中实现得最为彻底,我相信这也是 Kubernetes 成功的原因之一。

今年因为工作的原因,我非常深度地接触了 Kubernetes,在其基础上进行封装,来提供容器服务。Kubernetes 不仅仅是一个工具,同时也是一个平台,它以 RESTFul 风格的 API 将所有功能抽象为资源,然后由每种资源的 Controller 去将对象的实际状态同步到预期的状态。这意味着在 Kubernetes 的基础上你可以去添加自定义的资源和相应的 Controller 去拓展它的功能。

在对 Dockerfile 的抽象能力忍无可忍之后,今年我用 Node.js 为 Dockerfile 实现了一个简易的 DSL,主要是将 Dockerfile 分为多个段落,然后在每个段落中结构化地保存指令数据,以便在对 Dockerfile 的整个处理过程中随时向任何段落添加或修改指令,最后等到完成所有的处理之后再将结构化的指令数据生成真正的 Dockerfile。经过这样的过程生成的 Dockerfile 有着更规范的格式,更有利于跨应用甚至跨语言之间的缓存。

其实我们在生产环境使用容器技术已经很多年了,但很多时候只是将已有的程序跑在容器里而已,而没能做到 Container Native。例如我们实际上还有很多容器在依赖本地存储、没有有效的健康检查、不能正确地处理信号来实现平滑关闭。


我司今年发布了一个 游戏后端解决方案,它本质上是一个「消息转发服务」,帮助游戏的客户端之间来转发消息、同步状态。

但出于反作弊的需要,我们还需要提供一种在服务器端运行游戏逻辑的能力。对于暴露这种能力的方式,一开始我们内部有两种方案。我认为比较好的方案是将这种在服务器端运行的游戏逻辑也作为一个客户端去加入到消息服务中,以消息服务为中心与其他客户端进行交互。这样做的好处是:

  • 在服务器和客户端之间复用大部分的游戏逻辑
  • 单机游戏 => 动作同步 => 状态同步 的迁移过程非常平滑
  • 服务器端的游戏逻辑和消息转发服务解耦

为了验证这个方案的可行性,我花了一些时间制作了一个 Demo,实现了一个 简单的回合制卡牌游戏,这种模式后来也被我司发布为了正式的产品:Client Engine

在这个过程中我其实是第一次接触游戏后端的开发,其实我并没有去了解既有的游戏框架,但在不知不觉中也重新发明了一些轮子,例如「动作」和「状态」的概念,感觉去探索游戏的开发过程还是挺有意思的一键事情。


今年年初的时候我尝试为云引擎加上了 任务队列 的功能,因为云引擎本来已经有了基于 HTTP 的云函数功能,所以我想这个任务队列只提供一种调度的能力,而不提供计算资源,依然通过 HTTP 来调用原本的云函数。我认为这样的形式可以减少引入的新概念,降低介入的成本。

但低调公布之后的效果并不是很好,我觉得其中一个原因是是我自己就比较少用任务队列,所以比较难站在用户的角度去考虑他们需要任务队列有怎样的功能、希望超时和并发的控制是怎样的。在新的一年里我还会继续改进这个功能,去参考其他类似的云服务如何设计任何队列的功能。

大概是因为我的服务器端编程经验都在 Node.js 上,Node.js 中异步任务的成本很低,所以不需要出于减少线程开销的考虑去使用任务队列;同时我会通过 Redis 来维护一些关键状态,消除单点、保证因应用重启而中断的任务可以恢复,所以也几乎不需要任务队列去保证任务执行的连续性。

在这个功能的实现上我重度地使用了 Redis:使用 Redis 存储所有的状态、提供一致性保证,用 Node.js 去实现 Worker,调用 Lua Script 去实现原子操作。说起来 Redis 是我用过最好的服务器端软件之一,我认为 Redis 找到了一个非常好的切入点、找准了自己的定位,才使得它的设计看起来那么简单。

应该说任务队列的需求是非常多样化的,每种业务可能都会对任务队列有不同的需求,再加上很大程度上又是语言相关的,所以我觉得在这个方面如果能做一些开源项目也会有比较大的空间,例如我就觉得 Redis 5 中的 Stream 类型就是为任务队列设计的,想去写一个充分利用 Stream 特性的任务队列。

1

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

订阅推送

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

王子亭的博客 @ Telegram


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

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