重建与重放
日志是唯一的事实。快照、读模型、数仓和检索索引都从它派生,所以它们中的任何一个都能从日志重建,不需要迁移脚本,也不用停止服务。本页按最常用的顺序列出这些做法。
读模型:一个新订阅
投影的位置就是它的订阅的检查点,所以重建读模型的办法,是给投影一个没有检查点的订阅:
- 用新的名字(一个新类,或
@Name)部署改过的投影,让它写入一张新表或一个新索引。没有检查点的投影从日志的开头开始,用历史填满它的存储;wow.subscriptions.[<context>.<processor.function>].start也可以让它从某个时刻开始:从每个分区中第一个提交时间不早于该时刻的条目开始,处理其后的每个条目,无论其提交时间。为找到这个条目,首次启动会从开头读取一次该分区,读到的条目不做处理。 - 等它追平:它的
wow.subscription.backlog降到零,wow.subscription.progress达到 1。 - 把读取切到新的存储上。
- 删除旧的投影,并删除它的存储。
在切换之前,新旧读模型并行运行,所以读取永远不会看到构建了一半的模型。反应器或 Saga 从不以这种方式重放:新的反应器或 Saga 从日志的末尾开始,所以部署它不会为过去重复效果或命令。
快照:提高状态修订号
当存储的快照不再能读成状态,或者不再包含状态现在要推出的东西时,提高聚合的 @AggregateRoot(stateRevision = n) 并部署(演进事件)。服务在后台原地重建存储的快照:
- 新修订号的节点以新修订号声明快照表。滚动升级期间,旧修订号的节点继续以它们的修订号写入,存储会阻止它们覆盖新修订号的快照。
- 同一时刻只有一个节点从日志逐页重写表中的快照,没有剩余的快照时就把表标记为已重建。
- 表被标记为已重建时,重建就完成了;节点记录日志
The snapshots of <type> in <store> are rebuilt at state revision <n>.
快照:按请求重建部分快照
因为其他原因出错的快照,例如因为一个后来修复了的缺陷,可以按请求从日志重写:
curl -X POST "http://<service>/<context>/<aggregate>/wow.snapshot.rebuild" \
-H "Content-Type: application/json" \
-d '{"ids": ["SO-261031-1", "SO-261031-2"]}'这个路由把系统命令 wow.snapshot.Rebuild 发给上下文内置的重建聚合,像任何命令一样经过准入,所以由授权器决定谁能发送它;没有命令授权器的服务在公开端口上拒绝它(403 wow.auth.forbidden),默认从不放行(授权命令)。随后一个内置的订阅从日志重写每个指定的快照,写入每个目标存储。
新的快照存储
给某个聚合类型增加一个快照目标(wow.snapshot.stores,或 wow.aggregates.[<context>.<aggregate>].snapshot.*),例如在 PostgreSQL 旁边加一个 Elasticsearch 索引,会从日志开头填充它:新存储自己的快照订阅从开头开始;如果是给一个订阅早已越过开头的存储新增聚合类型,则由一个它自己的订阅填充,目标追平后这个订阅就停止。切换主目标(即查询读取的那个)不会移动任何检查点。
访问属性
每条流都记下聚合在这次提交之后的访问属性(属主与空间也在其中),快照和各 Sink 的记录也一样。一次提交改变了它们时,内置订阅 wow.access 把新的属性盖到该聚合更早的流上,所以它的整段历史都按聚合现在的样子授权。Elasticsearch 存储的事件索引也同样跟随:它的 Sink 把变化盖到之前写下的文档上,下面的遍历连同日志一起为索引盖章。不论盖章以什么顺序到达,每一份副本都保留版本更晚的属性(accessVersion)。
从 9.x 导入时追加的流没有访问属性:盖章之前,访问策略下的事件查询不包含这些流;以所有者身份或在某个空间里行事的调用方,其事件查询也不包含这些流。每个聚合类型的访问遍历为它们盖章:
- 何时运行。 当类型的日志没有记录遍历,或者记录的是另一种访问模式(模型声明的属性、各自的类型以及是否多值)的遍历、而该模式的节点已一个不剩时,节点为该类型认领遍历。所以声明了新属性的滚动部署,会在上一版本的最后一个节点停下之后遍历日志。日志里还没有任何流的类型没有遍历,遍历也不为它创建集合或表:它的第一次追加创建它们。在此之前就看着该类型的节点,若期间没有另一种模式的节点服务该类型,就把它的遍历记为完成,不盖任何章:新存储里的每条流都是带着属性提交的,所以新的聚合类型或新的服务从不被遍历。节点在接受命令之前加入遍历的成员,所以追加的节点总能被看到。若期间有另一种模式的节点服务过该类型、导入认领了它的遍历,或者节点在存储已存在之后才启动,遍历就按上面的方式认领。对还没有存储的类型,节点隔一轮再读一次它是否已有存储,之后每次隔的轮数翻倍,最多 16 轮,按默认间隔约 80 秒。导入在追加某个类型的第一批流之后立即认领该类型的遍历,好让节点遍历它们;接受命令的节点可能在这次认领之前的几毫秒里把遍历记为完成,让这些流不带属性。所以只在节点待机时导入;如果流被导入到节点处于活动状态的服务中,就运行
migrate-data snapshot,它为这些流盖章。迁移之后,migrate-data snapshot自己为导入的历史盖章,并把每个类型的遍历记为完成,节点就不再遍历它(访问属性)。 - 做什么。 同一时刻只有一个节点逐页取出该类型的聚合,一个接一个地用每个聚合当前状态的属性为它的整段历史盖章,并重写它的快照,每个聚合都用各自的短语句:遍历不会从一个聚合到下一个聚合持有锁或连接,任一时刻最多占用日志存储的一个连接。遍历到达认领时日志各分区头之前的流;在这之后导入的、不带属性的流,由
wow.access读到时为其聚合盖章。 - 节奏。 遍历是后台工作,与命令共用同一个存储,所以无论负载如何,它只占其中有界的一份。每个聚合之后,遍历等待一段时间,使盖章所用的时间至多占总时间的
wow.access.walk-share(默认 25%):一个聚合用了 3 ms,之后就等 9 ms。存储繁忙时(无论来自哪个节点),遍历自己的语句变慢,节奏也随之变慢。遍历每秒读一次日志存储的写入是否受压,判据与该存储的自适应查询上限相同(有调用方在等连接,或追加明显慢于平常):受压期间份额减半,最低到walk-share的八分之一;不再受压后每秒加回八分之一。遍历读取每段历史时走日志存储的查询隔板,与查询和状态重建共用;被隔板拒绝的读取算作受压,遍历一秒后重读该聚合。每秒盖章的聚合数不超过wow.access.walk-rate(默认 500)。查询上限固定的存储(设置了wow.stores.<name>.query.max-concurrent或wow.query.max-concurrent)读不到压力:遍历保持它的份额。 - 用时。 按份额,遍历每秒盖章的聚合数约为
walk-share除以一个聚合的用时。在基准的 4 核 runner 上、每秒 200 条命令、每个聚合 10 条流时,默认份额下 PostgreSQL 每秒约盖章 150 个聚合,MongoDB 约 40 个:10 万个聚合分别约需 11 分钟和 40 分钟,1000 万个分别约需 19 小时和 3 天。写入受压期间降到八分之一,但仍会完成。遍历尚未到达的流不带当前属性:在它到达之前,访问策略下的事件查询不包含它们。 - 调优。 要在维护窗口里遍历得更快,调高
wow.access.walk-share,最高到1:遍历随即按存储和walk-rate允许的速度运行,写入受压时仍会让路。结束后请改回:整个集群是否空闲只有运维知道,任何节点都看不到。两种存储不一样。PostgreSQL 上,同一基准里全速遍历时命令的 p99 仍在噪声范围内,所以调高份额主要多花 CPU。MongoDB 上要谨慎:全速遍历使命令的中位延迟约翻倍,默认份额能避免这一点。若遍历期间(wow.access.walk.pending为 1)命令的延迟上升,就调低它。 - MongoDB 上新增访问属性之后。 每个聚合此后的第一次提交都会由
wow.access为它更早的流盖章,这不受walk-share限制,所以写入延迟会升高一段时间,直到正在写入的聚合都盖过章(基准的最坏情形里,每条命令都落在未盖章的聚合上,中位延迟约为平时的 7 倍):请避开业务高峰做这类变更。 - 续跑。 每页之后,遍历把进度记在日志的注册表里,所以重启后或换一个节点都从那里继续。一页的聚合数为按当前节奏约五秒能盖章的数量,至多
wow.access.walk-batch(默认 100)。 - 观察。 类型的遍历完成之前,
wow.access.walk.pending为 1,还没有遍历的类型不报告它;wow.access.walk.walked是日志注册表记录的遍历已盖章的聚合数,每个节点报的值相同;wow.access.walked计数本节点遍历过的聚合,wow.access.stamped按原因计数盖过章的流(指标)。遍历不统计类型的聚合数,所以没有指标给出剩余多少。估算时,先取该类型的聚合数:对它的快照做一次count查询({ "kind": "count", "filter": { "op": "match-all" } },查询),或者直接数日志(PostgreSQL 上SELECT count(*) FROM wow_log__<context>__<aggregate> WHERE version = 1,MongoDB 上对该集合countDocuments({ version: 1 }));减去wow.access.walk.walked即剩余数,再除以最近几分钟遍历的速率(各节点rate(wow_access_walked_total[5m])之和)即还需的时间。注册表保存每个遍历的模式、日志头、游标、计数与是否完成:PostgreSQL 上用SELECT context, aggregate, access_walk FROM wow_log_aggregate,MongoDB 上看wow_log_aggregate文档的access_walk字段。完成时,执行遍历的节点记录日志The access walk of <type> is completed: <n> aggregates stamped.
数仓
ClickHouse Sink 也是一个订阅:它的表从日志派生,同一次提交到达它两次时,它也能让表保持精确(ClickHouse)。
不是 Sink 以当前形状建的表(注释没有写明 Sink 的格式、列不同,或缺少计算列)会让 Sink 的写入失败,并指明是哪张表(schema 管理)。它保存的都是日志里有的内容,所以删掉它,再重置这个 Sink 的订阅,它就会从日志重新填充。
停放的聚合
被订阅停放的聚合不会丢失:恢复它时,从它停下的版本开始重新读取它的流,它的处理函数会按顺序看到它错过的每个事件(失败与重试)。