切换
切换把流量从 9.x 服务迁到它的 V10 服务。9.x 和 V10 从不同时写入同一份数据:它们之间没有混合版本的协议,所以切换就是一次写入停止,你要选择的是这次停止的时长。
- 离线,默认方式。 停止 9.x 的写入,导入全部数据,比对,写出快照,启动 V10。它的步骤最少,可能出错的地方也最少。
- 在线,大型系统的选项,用于导入时间超出可停机窗口的系统。数据导入和跟随期间 9.x 照常服务,V10 处于待机,写入停止缩短为一次最后遍历。
消费另一个 9.x 服务的事件、向它发送命令、调用它的路由或与它共用 api jar 的服务,不会单独切换:整组服务在一个窗口里一起迁移,见服务群的切换。
迁移数据描述 migrate-data 的每个命令;本页讲围绕它们的这次切换。
离线还是在线
用演练实测的导入时间与业务能接受的停机时间对比来选择,并记录这个选择。对 9.x 示例及其视图存储(四个租户之上的 50,000 个订单,其中一半打了标签,和 12,500 个视图)的演练,在同一个 runner 上走了两条路径:
| 路径 | 写入停止,从 9.x 停止到 V10 提供服务 | 时间花在哪里 |
|---|---|---|
| 离线 | 136.7 s | 完整导入 43.1 s,比对 25.1 s,快照 44.1 s,启动两个服务 18.0 s |
| 在线 | 102.8 s | 最后遍历 19.7 s,比对 27.8 s,快照 47.1 s,激活两个服务 1.8 s |
snapshot 还会为导入的历史盖上访问属性(访问属性),这发生在写入停止期间:在演练中测出它的用时;上表是在它这样做之前测得的。离线的停止随整个历史增长。在线的停止随双轨运行期间最后一次 tail 遍历、比对和快照遍历之后变化的数据增长,再加上激活。能在可停机窗口内导入完的历史,就走离线。
写入之前先盘点
为每个存储和每个流记录:
- 环境、负责人、数据库、限界上下文,以及 9.x 和 V10 的版本;
- 9.x 的存储(MongoDB 事件存储、
PrepareKey集合、补偿数据库)和 V10 的目标(日志、快照、预留、失败台账); - 每种聚合类型的流和聚合数量,以及其中有 9.x 快照的比例;
- 9.x 数据的每个写入者和读取者,以及各自怎样停止;9.x 事件总线的每个外部消费者,以及它要迁往的 V10 Kafka 事件流;
- 往 9.x 命令主题(
wow.<context>.<aggregate>.command)发命令的每个外部生产者,从主题的 ACL、broker 上的生产者 client id 和写着这些主题名的代码里找出,并写明它怎样迁移(迁移外部命令生产者); - 9.x 的主题和 V10 的主题,以及它们所在的 broker。9.x 的状态主题名为
wow.<context>.<aggregate>.state,V10 发布的事件流默认也是这个名字:在 9.x 用过的 broker 上,给 V10 设置wow.kafka.topics.prefix。不设的话,V10 的订阅会停在它读到的第一条 9.x 记录前,日志里写明主题和该设的前缀;回退之后 9.x 也会遇到 V10 的记录; - 每个 9.x 存储的备份,以及一次经过测试的恢复;
- 9.x 一侧必须提供的:tail 读取每个事件流集合所用的
(createTime, _id)索引,由存储的负责人创建(check会对每个缺少它的集合给出警告),9.x 服务的时区,以及它们的 CosId 号段状态; - 迁移映射,以及检查它时所对照的 V10 Manifest;
migrate-data在哪里运行,怎样访问每个存储:在存储旁边,例如作为它们所在 Kubernetes 集群里的 Job(在存储旁边运行),而不是在整个导入期间通过 port-forward 或 VPN。
存在未知的写入者、未知的负责人或未经测试的备份时,不要开始任何写入。
演练必须留下什么
在隔离的目标上、用生产规模的数据进行端到端演练,留下:
- 在生产映射上通过的
check,以及check --decode:每个 9.x 事件都能用 V10 模型读出; - 一次被中断后无重复地续跑的
import,以及一次什么都不写的第二次运行; - 每种差异都已解释并签字确认的
compare; - 在目标上启动的 V10 服务:对迁移来的聚合发出的命令在下一个版本追加,读取和查询回答迁移来的状态,投影已重建,停放条目已列出;
- 用只含演练所列配置的干净环境启动,这样 shell 继承来的变量不会把服务指向共享基础设施,并且日志里没有目标之外的地址;
- 在 9.x 服务的时区(
TZ)下运行,当领域代码用系统时区把日期换成时刻时; - 在迁移后的目标上第一次启动,每个分区都被获得并保持;
- 耗时:离线时是完整导入加上
compare和snapshot,在线时是最后遍历; - 两种回退都已演练,并附上无法回退的内容清单。
不再轻易回头的那一刻
在 V10 写入之后回退,需要 V10 写入的每个流都有 9.x 的形式。根据映射和 V10 的 Manifest,列出每个没有 9.x 形式的 V10 事件类型,把清单交给决定切换的人:
- 超出映射
toRevision的事件。 V10 的类升级到了 9.x 所存储形状之外,这往往就发生在迁移本身:以修订号 1 导入的 9.xOrderCreated,V10 以修订号 2 写入。V10 创建的每个新订单都会阻断回退。 - V10 新增的事件或聚合,没有任何 9.x 名字映射到它。
生产中第一次这样的写入就结束了回退窗口。要么接受从那次写入起切换是单向的,要么在窗口关闭之前,不让这些形状和类型进入 V10 的第一个版本。演练自己的拒绝说明了这一点:V10 以修订号 2 创建了订单和视图之后,export --dry-run 拒绝了这两种类型。
离线切换
- 公布窗口,停止 9.x 的写入。 停止 9.x 服务的入口流量,外部命令生产者也在内,排空它们的命令队列,并同时停止补偿服务。检查没有遗留的 9.x 写入者。
- 备份 9.x 存储并核实备份。
- 导入: 先
check,再import。 - 关卡:
compare只显示已签字确认的差异种类,没有无法解释的未解决的键或执行。否则就在这里停下并重启 9.x:V10 什么都没有写。 - 准备读取侧:
snapshot。 - 启动 V10 服务并探测关键路径:对迁移来的聚合发一条命令、一次读取、一次查询、一个订阅、停放条目。
- 迁移 9.x 总线的外部消费者到 V10 发布的事件流。
- 把流量切到 V10,外部命令生产者一起切(见下),然后扩容。让 9.x 保持停止,而不是删除。
- 观察约定的信号:错误和 problem 错误码、延迟、等待、订阅延迟、停放的聚合以及消费者。回退的决定依据它们做出。
在线切换
从第一次导入到切换之前,9.x 提供服务,V10 等待:tail 跟随 9.x 的写入,每个 V10 节点都以 wow.node.standby=true 启动。待机的节点提供对导入所写数据的读取和查询,以 503 wow.node.standby 拒绝每条命令,不运行任何订阅、停放重试、作业或快照维护,所以 V10 所做的任何事都不会写入。它报告就绪,所以切换之前就能对照生产数据检查读取侧。
导入完成、tail 追上之后,在双轨运行期间运行 compare 和 snapshot。两者各自遍历每个实例一次并记录起点,完整比对发现的差异在这时审阅并签字确认。
- 公布窗口,停止 9.x 的写入,与离线时相同。
- 做最后一次备份并核实。
- 最后遍历。 先停止 tail:最后遍历持有它的角色,并等待它的租约。tail 一直跟随 9.x 直到它停止时,运行
import --final:从检查点开始的一次 tail 遍历,并向回看,以捕捉较晚才可见的提交,然后再处理一次预留和未解决的执行。当导入不完整或 tail 已经落后时,它会拒绝,什么都不写;这时最后遍历就是import --restart,时长与完整历史相当。然后运行compare和snapshot;它们在双轨运行期间遍历过之后,只读取之后变化的部分。compare比对两侧有变化的实例和曾有差异的实例,得到与完整比对相同的结果(比对);它会点名每个要完整再比对一次的聚合,并说明原因。snapshot为 V10 日志有变化的实例写快照(写入快照)。关卡仍然需要这次compare:它的结果才是需要签字确认的那一份。 - 关卡,与离线时相同。否则回到 9.x:V10 节点仍在待机,什么都没有写。
- 对着生产目标探测待机节点。
- 迁移外部消费者。
- 激活每个 V10 节点: 在每个节点的内部端口上发送
POST /wow/node/activate,带上Wow-Service: <节点的服务>和{"by": "<operator>", "reason": "<why>"}。节点加入它的分组,开始它的工作,然后接受命令。激活结束了写入停止;GET /wow/node/standby显示每个节点对此的记录。 - 把流量切到 V10,外部命令生产者一起切,然后扩容。让 9.x 保持停止。
- 观察,与离线时相同。
迁移外部命令生产者
9.x 服务之外往 Kafka 命令主题写命令的生产者,例如批处理作业、网关或别的系统的连接器,不会随数据一起迁移:V10 不读任何 9.x 的命令记录,9.x 也不读 V10 的。生产者要在同一个窗口里随它的服务一起改,要改四处:
- 主题。 9.x 的命令主题是每个聚合一个,
wow.<context>.<aggregate>.command。V10 是每个上下文一个,wow.<context>.commands,设置了wow.kafka.topics.prefix时前面加上主题前缀,分区数与该上下文的日志完全相同。服务在接收其他上下文的命令(wow.kafka.command-intake.enabled: true)或把某个聚合类型路由到它时读取这个主题(Kafka)。 - 记录。 记录是
command类型的 Kafka 信封{format, kind, id, type, payload, transport},payload是命令的线路形式CommandWire:命令类型、id 和请求 id、聚合引用、正文、期望版本、作用域、操作者、请求头和因果。主题上的命令列出这些字段并给出一条记录。9.x 形式的记录不会被执行:它被记为错误日志;它的键指向某个聚合时,该聚合在失败台账里按这条记录的偏移量被停放,否则这条记录被跳过。 - 键和分区。 键是聚合引用的文本形式,记录要发到日志的分区器分配给该聚合的分区,而不是 Kafka 默认分区器选的那个:拥有这个分区的节点执行命令,所以落在别的分区里的记录会到达一个并不拥有其聚合的节点。哈希的算法见主题上的命令。
- 没有准入。 摄入端执行记录所说的内容。操作者、租户和作用域都是生产者自己的声称,不做认证,也不做路由检查,只做命令校验。谁能写这个主题,谁就能冒用任何操作者,所以要限制它(限制命令主题)。
- 访问策略。 有访问策略的聚合的记录要指明调用方(
caller:主体以及策略比较的声明),并以这个调用方受策略约束;既没有调用方、也没有causation的记录被拒绝。causation声称这是服务端由某次提交派生的命令,不带调用方也受信任,所以不是 Saga 的生产者不写它。9.x 的生产者不指明调用方:为它的记录写上它所代表的调用方,或者改走命令路由,由路由自己解析调用方。声明随记录传递,主题的 ACL 是唯一的边界。
最好离开这个主题。只需要发命令的生产者可以通过 HTTP 调用命令路由,由它认证并准入(HTTP 与 TypeScript 客户端);不是 Wow 服务的 JVM 应用则用 WowClient(调用其他服务)。必须写主题的生产者才保留 Kafka 生产者,并让它写 V10 的记录。
在切换中,生产者属于流量。它们在第一步随 9.x 的入口一起停止,在导入、关卡和探测期间保持停止,随流量切换用 V10 的形式重新启动,即两条路径的第 8 步(在线时是在第 7 步激活了每个节点之后)。绝不要更早地对着 V10 节点启动:V10 执行的命令就是它的第一次写入,此后回头的路只剩导出。到那时仍无法修改的生产者保持停止,因为它的 9.x 记录到不了任何 V10 服务,也没有什么会翻译它们。服务群的生产者随服务群迁移(一个窗口)。
回退
两条路径,绝不混淆:
- V10 还没有写入: 停在关卡处、仍在待机,或者在 V10 第一次提交之前切了回来。停止 V10,把流量切回,在未被改动的存储上重启 9.x。没有需要对账的东西。
- V10 已经写入。 9.x 没有 V10 的流,所以只重启 9.x 会丢失它们。停止 V10 的写入,让 9.x 保持停止,运行
migrate-data export --dry-run。它为每个上下文输出一个结论:切回、导出 N 个流、窗口已在某个时间关闭,或者 9.x 在切换之后被写入过,这需要先由人来解决。然后export按反向读取的映射,把 V10 新增的流写回 9.x 存储,可续跑且幂等,并检查每个 9.x 聚合保存的流数量与 V10 中该类型的流数量相同,然后 9.x 再次启动。在演练中,V10 写入的 16,914 条订单命令和 16,815 条视图命令在 52.9 s 内导出并完成检查。
只有事件流能回退:V10 做出的预留、它停放的条目和它的投影视图都没有 9.x 的形式,要手工对账。外部命令生产者随流量一起回退,回到 9.x 的主题和 9.x 的记录:把每个生产者的 9.x 构建或配置保留到切换最终确认为止。9.x 的快照显示的是导出的流之前的状态,直到 9.x 再次写入该实例或重新生成快照。一旦 V10 写入过,重新部署 9.x 就不是回退。
通过导出回退之后再次切换,会导入 9.x 在此期间写入的内容并重新记录切换点,位置越过 V10 在第一个窗口里写入的流。这个窗口里传输 Sink 在 V10 停止之前还没发布的流,届时会被标为导入的发布出去,对端的 Saga 会跳过它。为回退停止 V10 之前,先让每个订阅的积压降到零(wow.subscription.backlog)。
服务群的切换
服务群是一组不能分处两个主版本运行的 9.x 服务:彼此消费对方的事件、向对方发送命令、调用对方的路由,或者依赖对方的 api jar。服务群作为一个整体切换:所有服务共用一次写入停止,每个服务的数据都导入,每个节点都激活,回退也只做一个决定。与其他服务没有这类联系的服务,单独切换。
为什么一起切换
9.x 服务和 V10 服务之间什么都不互通:
- V10 不读取 9.x 的主题。 V10 服务只读取 V10 发布的事件流和命令主题,从不读取 9.x 的
.event、.state和.command主题。上游仍在 9.x 上的 V10 服务,收不到上游的任何东西。 - 9.x 不读取 V10。 没有 9.x 格式的 Sink,所以上游已迁走的 9.x 服务同样什么都收不到,它在 9.x 命令主题上发出的命令也到不了任何 V10 服务。
- 路由变了。 V10 服务只提供 V10 的路由和请求头(HTTP 与 TypeScript 客户端):它的命令、快照或事件路由的 9.x 客户端,在它切换时就失效。
- api jar 不能混用。 V10 服务从对端的 V10 api jar 读取对端的契约,9.x 服务需要对端的 9.x api jar,任何模块的类路径上都不能同时有两者。依赖另一个服务 api 的 api 会把它一起带进来:支付的 api 依赖订单的 api 时,迁到 V10 的订单服务就不能依赖 9.x 的支付 api,因为它会把 9.x 的订单 api 带回来。
9.x 主题和 V10 事件流之间没有桥接。调用服务自己编写的路由,且它的 V10 版本原样提供这个路由,这样的调用不会把两个服务绑在一起。
找出服务群
画一张图,每个 9.x 服务一个节点,下面每种联系一条边;服务群就是与你要迁移的服务直接或间接相连的所有服务。从构建、源码和配置画出这张图,再对照 broker 核实。
api 模块。 列出每个服务运行时类路径上、以及它自己的 api 模块类路径上,其他服务的 api jar(
com.example是这些服务的 group;同一构建内的模块显示为project :<name>-api):bash./gradlew -q :order-server:dependencies --configuration runtimeClasspath | grep -o -E 'com\.example:[a-z0-9-]+-api:[^ ]+' | sort -ubash./gradlew -q :order-api:dependencies --configuration runtimeClasspath | grep -o -E 'com\.example:[a-z0-9-]+-api:[^ ]+' | sort -u第一条列出服务运行时带的每个 api jar,第二条列出它自己的 api 带给消费者的 api jar。两个 api 模块直接或间接地互相依赖,就构成一个环。
事件。 9.x 的事件处理器、Saga 和投影(
@EventProcessor、@StatelessSaga、@ProjectionProcessor)中,@OnEvent或@OnStateEvent函数接收另一个服务 api 里的事件类或状态类的那些。命令和读取。 服务通过
CommandGateway或从 Saga 发出的、另一个服务 api 里的命令类,以及它对另一个服务的客户端:wow-apiclient接口(@CoApi)、快照查询客户端、缓存其状态的 CoCache。配置。 每个服务的配置和它的网关路由里提到的其他服务:基础 URL 和服务 ID(
lb://payment-service)。每一个都是第 3 步里要对上的一次调用。broker。 9.x broker 上的每个消费者组,以及它读取的主题:
bashkafka-consumer-groups --bootstrap-server kafka-9x:9092 --describe --all-groups某个服务的消费者组读取另一个上下文的
.event或.state主题,就是第 2 步的一条边。不属于你任何服务的消费者组是外部消费者,它在同一个窗口里改到 V10 发布的事件流。
把服务群记成一张表:
| 服务 | 消费谁的事件 | 向谁发命令或调用谁 | 它的 api 依赖 |
|---|---|---|---|
| 订单 | 支付 | 库存 | 支付的 api |
| 支付 | 订单 | 订单 | 订单的 api |
| 库存 | 订单 | — | — |
订单和支付互相消费事件,它们的 api 模块构成一个环;库存消费订单的事件。三者是一个服务群。调用其中任何一个的前端和网关都随它一起改,它们 9.x 主题的每个外部消费者也一样。
它们的源码迁移也一起进行:每个 V10 服务都依赖对端的 V10 api jar 编译,所以先按依赖顺序改写 api 模块,处在环上的一起改写。
一个窗口
服务群共用一次写入停止。每个服务仍按自己的导入时间选择离线还是在线,服务群的停止时长等于其中最慢的那个服务所走的路径:在线导入的服务在窗口之前就用 tail 跟随 9.x,离线导入的服务在窗口之内完成全部导入。
停止服务群的每个 9.x 写入者。 停止每个服务的入口:网关、前端、定时任务、外部生产者。让服务群排空,直到服务群的任何 9.x 消费者组在任何主题上都没有延迟(
kafka-consumer-groups --describe显示每个组的LAG),并反复检查,直到一轮检查都没有延迟为止,因为 Saga 发出的命令会产生别人要消费的事件。然后停止每个 9.x 节点和每个补偿服务,并记下最后一个写入者停止的时间:停止时刻。V10 不会接手 9.x 还没处理的东西:本上下文的 Saga 或反应器从它的日志末尾开始,另一个上下文事件流的 Saga 或反应器跳过导入的历史(第 4 步)。写入停止时某个 9.x 消费者还没处理的事件,就再也没有人处理。
备份服务群的每个 9.x 存储并核实备份。
导入每个服务。 每个 V10 日志存储由一次独立的
migrate-data运行迁移,有自己的配置、映射和检查点,所以这些运行可以并排进行:离线是check和import,在线是import --final。然后每个服务过关卡:compare只显示已签字确认的差异种类;接着运行snapshot。任何一个服务没过关卡,整个服务群就停下:重启每个 9.x 服务,因为没有任何 V10 服务写入过。给发布的主题留出容纳历史的空间。 每次导入都会在服务的日志里记下导入的历史在哪里结束,即它的切换点。服务一旦处于活动状态,它的传输 Sink 就发布它的全部历史,切换点及之前的每条记录都标为导入的(发布与消费事件流)。对端的 Saga 或反应器跳过导入的记录,因为 9.x 已经对它们采取过行动;对端的投影则用它们构建读模型。给发布的主题足以容纳这段历史的保留期。
以待机方式启动每个 V10 节点,离线导入的服务也一样(
wow.node.standby=true),并对照导入的数据探测每个服务的读取和查询。把每个服务 9.x 主题的外部消费者改到 V10 发布的事件流。
激活每个服务的每个节点:发送
POST /wow/node/activate,与在线切换相同。作业和 Saga 在激活时开始,而待机的节点拒绝命令,同步调用也在其中:在同步调用没有环的地方,先激活被同步调用的服务(SagaContext.call,以及经wow.peers的CommandClient和QueryApi),再激活调用它的服务。命令主题上发出的命令会在那里等待,直到它的上下文被激活。每个服务的每个节点都处于活动状态之后,把服务群的流量切到 V10,前端、网关和外部命令生产者一起切,然后扩容。让每个 9.x 服务保持停止。
观察每个服务,以及跨服务的流程:订单被支付,它的库存被预留。
演练服务群
先按演练必须留下什么演练每个服务,再把服务群作为一个整体演练:在隔离的目标和隔离的 broker 上,用同一时刻取得的每个服务的生产数据副本。
- 按上面的顺序端到端走完整个窗口,每个服务的
migrate-data运行并排进行,并计时:服务群的停止时长,以及每一步所占的部分。 - 没有 Saga 或反应器对导入的历史采取行动:从激活到第一个请求之间,发出的命令只有定时任务的命令。
- 跨服务的流程:新订单被支付、它的库存被预留,每个服务都通过主题和调用回应其他服务。
- 服务群的回退,两条路径都演练,并附上每个服务
export --dry-run的结论。
服务群的回退
服务群要么整体回退,要么不回退:单独回到 9.x 的服务,已经失去了与仍在 V10 上的其他服务的联系。
- 还没有任何 V10 服务写入: 停止每个 V10 服务,把流量切回,在未被改动的存储上重启每个 9.x 服务。
- 某个 V10 服务已经写入。 停止每个 V10 服务的入口,让服务群排空,直到每个订阅的积压都为零(
wow.subscription.backlog),再停止每个 V10 节点。在每个服务的日志存储上运行export --dry-run。只有每个结论都允许时,服务群才能通过导出回退:第一个关闭窗口的服务就关闭了服务群的窗口,所以服务群不再轻易回头的那一刻是其中各服务最早的那一刻。然后对每个服务运行export,再启动每个 9.x 服务。
9.x 不发布导出的流,所以没有任何 9.x 消费者能看到 V10 在窗口内写入的东西。V10 的 Saga 为此所做的事已经在它们写入的那些服务的导出流里,但 9.x 投影的读模型缺少这些流:与其他不能回退的内容一起手工对账。
报告
记录离线还是在线以及原因;对服务群,记录它的服务、把它们绑在一起的联系以及停止时刻;盘点结果;演练的证据,即每个命令及其退出码和计数;已签字确认的差异种类;无法回退的类型以及对它们做出的决定;窗口的各项耗时;以及切换中实际执行的步骤。数据已导入、V10 已提供服务、消费者已迁移、回退已演练,是四个独立的事实:任何一个都不意味着下一个。