Lazy loaded image
开源贡献
Dubbo Admin 流量规则版本控制功能测试报告
字数 3796阅读时长 10 分钟
2026-5-22
type
Post
status
Published
date
May 22, 2026
slug
dubbo-admin-reviewer-report
summary
tags
开源贡献
推荐
category
开源贡献
icon
password
日期: 2026-05-22

版本信息

组件
版本
dubbo-admin (branch)
feat/Support-version-history-and-rollback-for-traffic-rules @ 9596f7e
Go
1.21
Vue3 / ui-vue3
与 develop 同步
MySQL
8.0 (Docker issue1473-mysql, 127.0.0.1:3306)
ZooKeeper
3.9 (Docker issue1473-zk, 127.0.0.1:2181)
浏览器
Chrome 124
测试规则
demo.condition-router / demo.tag-router / demo.configurator (mesh = zk-smoke)

项目概述与价值

本项目围绕 Apache Dubbo Admin 的流量规则(Condition Route / Tag Route / Dynamic Config)落地不可变发布账本与一键回滚。核心思路是:在 Admin 层加一道旁路订阅者,把所有规则写入(UI 发布、注册中心推送、回滚)都统一捕获并 append 进版本账本;原有 CRUD 主链路(handler → governor → ZK)只字未动,失败时整条版本能力可一键关停。
对运维 / SRE 侧,价值是误改可恢复、上游变更有审计、并发编辑会被提示而不是静默覆盖;对开发侧,价值是版本订阅者完全旁路、不阻塞主链路、可通过单一开关 versioning.enabled 即时下线整个能力。

1. 架构概述

  • Admin UI: 在每条规则详情页提供 History 抽屉 + Monaco diff + Rollback modal。
  • Admin Backend: 新增 pkg/core/versioning 包;每种规则一组 4 个新 REST 端点(versions / versions/:id / versions/:id/diff / versions/:id/rollback),三种规则共 12 个新路由。
  • ZooKeeper: 注册中心,所有规则写入实际落到 ZK 节点,Admin 通过订阅事件回声入册。
  • MySQL: 承载 rule_version(不可变发布行)与 rule_version_meta(当前版本指针)两张表。
本次范围内支持三类规则:Condition RouteTag RouteDynamic Config (Configurator)。AffinityRoute 因不在 governor.RuleResourceKinds 中,本次不在范围。

2. 测试结果总览

#
测试项
状态
说明
1
Bootstrap 基线入册
PASS
启动后存量规则各产生一条 BOOTSTRAP 行,author=system:bootstrap
2
Admin 编辑入册
PASS
保存后 ledger 新增 ADMIN 行,isCurrent 切换
3
上游 ZK 推送入册
PASS
注册中心直推 → UPSTREAM 行,author=system:zookeeper
4
Diff 当前
PASS
/diff?against=current 返回 left/right 两侧 JSON
5
Rollback 生成 ROLLBACK 行
PASS
旧 snapshot 重发,新行 source=ROLLBACKrolled_back_from_id 指源
6
Rollback reason 必填
PASS
空 reason 返回 HTTP 400 InvalidArgument,不入册
7
乐观锁 409
PASS
expectedVersionId 不匹配返回 HTTP 409 VERSION_CONFLICT + currentVersionId
8
409 sticky 通知
PASS
前端 notification.warning({duration:0}) 不自动消失,带 Reload 按钮
9
Retention trim
PASS
maxVersionsPerRule=3 时只保留最新 3 行,version_no 不重用
10
FEATURE_DISABLED 端点
PASS
versioning.enabled=false 时所有版本端点返回 HTTP 503
11
跨规则类型回滚
PASS
tag-rule、configurator 与 condition-rule 行为一致
12
单测套件
PASS
29 个新单测,go test ./pkg/core/versioning/... 等全绿
13
go vet / npm run build
PASS
后端 vet 无新增告警;前端构建通过
14
端到端 drill 测试
PASS
TestE2ERollbackDrill 串联 bootstrap → admin → upstream → rollback → trim
15
Owner code review
PASS
19 个 finding,11 FIX + 2 DOCUMENT + 6 WON’T-FIX,全部入决策矩阵

3. 版本账本核心功能验证

Bootstrap 基线:
Admin 编辑+Diff当前:
Rollback + 空 reason 拒绝:
并发 409 sticky 通知:
ZK 直推 → UPSTREAM 入账
默认5个版本:

3.1 Bootstrap 基线入册

启动后端,pkg/core/versioning/component.go::Start 扫描所有现存规则。RecordBootstraprule_version_meta 中尚无记录的规则插入一条 source=BOOTSTRAP 基线(幂等;若 meta 已存在则跳过)。
调用:
返回(01-bootstrap-versions.json,关键字段):
total=1,首次启动后恰好一条 baseline。

3.2 Admin 编辑入册

UI 上修改 priority 后保存。后端流程:handler → service.checkExpectedVersion → service.putAdminHint(SourceAdmin) → ResourceManager.Update → governor → ZK 写入 → 事件回声 → subscriber 命中 hint → InsertVersion(source=ADMIN)
响应:
随后查询 ledger,取自 02-versions-after-update-retry.json。该快照取在一次回滚验证之后,因此 ledger 中已含一条 ROLLBACK 历史行,这恰好印证 ledger 的 append-only 性质:
证据:02-versions-after-update-retry.json(total=3)。

3.3 上游 ZK 推送入册

通过 ZK 客户端直接修改注册中心节点(基础路径 /dubbo/config,节点名 = 规则名):
ZKConfigEventSubscriber 接到节点变更后写入本地 store,并发出带 Context()["source-registry"]="zookeeper" 的事件。RuleVersionSubscriber 接到事件 → hint 表未命中(非 admin 写入) → 以 source=UPSTREAM 落 ledger,author 取自 context。
ledger 顶部多出一行:
证据:07-upstream-versions.json

3.4 Diff 当前

返回:
前端 RuleDiffEditor.vue 用 Monaco diff 渲染为左右两栏。证据:03-diff-retry.json + UI 截图。

3.5 Rollback 生成 ROLLBACK 行

以联调实测为例,ledger 当前 head 为 v3 ADMIN(底层有 v2 ADMIN 与 v1 BOOTSTRAP),回滚到 v2:
服务端流程:service.Rollback → CheckExpectedVersion → PutAdminHint(source=Rollback, rolledBackFromID=2) → rm.Upsert(v2 snapshot) → 等订阅者把 ROLLBACK 行落盘后返回
ledger 新增一行(取自 04-versions-after-rollback-retry.json):
v2 ADMIN 行未被改写;ledger 上 v1 / v2 / v3 / v4 四行并存。v2 的 spec 通过新的 v4 行重新发布,而不是覆盖原 v2。
证据:04-versions-after-rollback-retry.json(total=4)。

4. 并发与边界

4.1 乐观锁 409

Tab A 与 Tab B 同时加载规则,两者持有相同的 expectedVersionId=5。A 先保存(PUT 携带 expectedVersionId=5)成功,current_version 推进到 6。B 再保存(仍带 expectedVersionId=5),服务端 CheckExpectedVersion 失败:
证据:05-409.http
注意:expectedVersionId弱 CAS / best-effort,不是严格 CAS。current_version 由订阅者异步更新 —— 从 admin 写入释放锁,到事件被 subscriber 处理并写完 meta,中间存在一段窗口;在这段窗口内,第二个请求拿到与第一个请求相同的 expectedVersionId 仍可能通过 check。当前实现足以覆盖”两个人同时打开 → 后保存的得到提示”的常见场景。强 CAS 是 v2 议题。

4.2 Rollback reason 必填

修复前服务端正确抛了 bizerror.InvalidArgument,但 handler 默认分支将所有 bizerror 映射成 HTTP 200 + UnknownError,前端看到的是模糊的错误。修复方法:在 writeVersioningResp 增加 InvalidArgument → 400 + 原 code 映射,新增 TestWriteVersioningRespMapsInvalidArgumentToBadRequest 单测。
证据:04-empty-reason-rejected-fixed.http

4.3 Retention trim(maxVersionsPerRule)

配置 versioning.maxVersionsPerRule=3 后,连续编辑多次,ledger 总计累积到 v11,最终 ledger:
version_no 单调,trim 后不重用:v1–v8 已永久丢弃,后续插入只会推进到 v12、v13…而不会回收已释放的号段。
证据:06-retention-versions.json(total=3,因 maxVersionsPerRule=3 只保留最新 3 行)。

4.4 FEATURE_DISABLED 端点

versioning.enabled=false 重启后:
CRUD 端点行为完全不受影响。证据:01-versions-disabled.http

5. 错误处理与可用性

5.1 sticky 409 通知

前端处理 409 的逻辑(ui-vue3/src/views/traffic/_shared/ruleVersion.ts::notifyVersionConflict):
duration: 0 让通知持续显示直到用户主动关闭;Reload 按钮直接触发表单重新加载当前版本。

5.2 Rollback 空 reason 拒绝(UI 端)

UI 上点回滚,弹出 modal 后清空 reason 提交:

6. 代码审查发现

PR 进入合并阶段前,以项目 owner 视角对全分支做了一次代码审查,目标是排查”两个月后回头看会后悔的细节”。共 19 个 finding。

6.1 决策矩阵

类别
数量
处理
FIX
11
已在本 PR 内修复
DOCUMENT
2
在文档说明,不改语义
WON’T FIX (v1)
6
显式不做,理由记入 PR
详细列表(节选最具代表性的几条):
#
类别
finding
决策
1
死码
pruneExpiredLocked 写了从未被调用
FIX
2
死码
泛型 manager.List[T] 零调用
FIX(删)
3
死码
versionedResource 仅是 coremodel.Resource 的换皮别名
FIX(内联)
6
冗余
versioning Config 自带空 PreProcess/PostProcess
FIX(嵌 config.BaseConfig)
8
冗余
subscriber 自行实现的预查重与 store 内部 dedup 重叠
FIX(删)
12
冗余
"source-registry" magic string 重复声明
FIX(提到 events.SourceRegistryContextKey)
13
并发
AdminHintRegistry 内存泄漏(TTL 形同虚设)
FIX
14
并发
expectedVersionId 弱 CAS,非严格
DOCUMENT
15
并发
预查重只比 hash 不比 op,DELETE → 空 CREATE 会被误判为重复跳过
FIX(随 #8 一并删除)
16
并发
events.Context() 返回可变 map
DOCUMENT(加注释,不 clone)
17
并发
Subscriber.FlushAll 未接 graceful shutdown
FIX
18
并发
Monaco diff setModel 未释放旧 model
FIX
19
并发
RuleDiffEditor 默认 editorId 全局 getElementById footgun
FIX(改 template ref)

6.2 关键修复(详)

6.2.1 Hint registry 内存泄漏

pkg/core/versioning/hint.go::pruneExpiredLocked 定义在文件里,但 grep -rn pruneExpiredLocked pkg/ 整个仓库零调用。TTL 形同虚设。两种孤儿场景:
  1. rm.Update 写失败 → hint 已 Put,事件永不到来 → 孤儿。
  1. 2s coalesce window 内 admin 写 hash H1 → 上游事件 H2 覆盖 pending → subscriber Take(H2) → H1 永不被消费。
修复:在 PutTake 持锁路径开头各调一次 pruneExpiredLocked()。新增单测 TestAdminHintPrunesExpiredOnPutAndTake

6.2.2 FlushAll 未接 graceful shutdown

Subscriber.FlushAll 在生产代码零调用,仅测试调用。进程收到 SIGTERM 时,coalesce window 内的 pending 事件会被直接丢弃 —— 审计 ledger 在最不该掉数据的时刻反而漏数据。
修复:component.Start(rt, stop) 起一个 goroutine 监听 stop:
新增单测 TestComponentFlushesPendingVersionsOnStop

6.2.3 Monaco diff 内存泄漏

RuleDiffEditor.vue::render() 在 props 变化时 monaco.editor.createModel(...) 两次后 diffEditor.setModel(...),旧 model 未 dispose。每次切换版本对比都泄漏两个 ITextModel
修复:setModel 前 dispose 旧 model 对;editorId 由 prop 改为 Vue template ref。

6.3 显式不修(WON’T FIX)

#
finding
理由
7
GormStore 全局 sync.Mutex 串行化所有 ledger 写入
admin 写入 QPS 在个位数,锁竞争不构成瓶颈;保留作为 SQLite/gorm 兼容兜底
9
Sanitize/Validate 部分判断重复
职责不同(改默认 vs 报错),loader 调用次序明确
10
NewService / NewServiceWithRollbackWait 两个构造器并存
测试便利 vs 生产 API,合并收益 < 改动成本
11
三层 XxxRule → WithOptions → Unsafe 命名”Unsafe”误导
分层有用(向后兼容 + 锁/无锁分离),仅命名差,延后重构
预存在的 configurator_rule.go::PutConfiguratorWithRuleNamereturn
不是本 PR 引入的回归
rollbackWait 500ms 兜底魔数
v1 默认值,有配置可调

7. 复现步骤

7.1 环境准备

7.2 配置文件

后端配置 dubbo-admin.yaml 关键部分(与本次联调实际使用的 dubbo-admin-smoke.yaml 同构):

7.3 启动服务

7.4 验证服务就绪

7.5 验证 BOOTSTRAP 基线生成

7.6 验证回滚

8. 配置注意事项

配置项
推荐值
说明
versioning.enabled
true
false 时所有版本端点返回 503,CRUD 不受影响
versioning.maxVersionsPerRule
5
trim-on-insert;<=0 自动回退默认
versioning.coalesceWindowMs
2000
收敛上游突发;过小→ledger 噪音,过大→事件延迟入册
versioning.adminHintTTLSec
30
比 coalesce window 大一个数量级;通常无需修改
versioning.rollbackWaitTimeoutMs
5000
回滚 API 等待订阅者把 ROLLBACK 行落盘的超时
store.type
mysqlmemory
memory 仅适合开发;生产请用 MySQL/Postgres
expectedVersionId 在所有 mutation 端点(PUT/POST/DELETE)与 /rollback 上都是可选:不传 → 行为完全不变;传 → 触发弱 CAS 检查。

9. 结论

  1. 版本账本核心功能完整:Bootstrap、Admin、Upstream、Rollback 四类来源全部正确入册,version_no 单调且不重用。
  1. 并发与边界保护:乐观锁 409 在前端以 sticky 通知 + Reload 呈现;reason 必填以 HTTP 400 + InvalidArgument 返回;FEATURE_DISABLED 关停为 HTTP 503。
  1. 跨规则类型一致:Condition / Tag / Dynamic Config 三类规则共享同一套 ledger、API、UI 组件。
  1. 上游回声不冗余:(content_hash, operation) 在 store 内部去重,Bootstrap 后注册中心 echo 不产生重复行;DELETE 与非 DELETE 不互相折叠。
  1. 代码审查决策矩阵公开:19 个 finding 中 11 个已修复(含 hint 内存泄漏、FlushAll 未接 shutdown、Monaco model 泄漏);2 个仅文档说明语义;6 个显式不改并记录理由。
  1. 不在范围:AffinityRoute、Nacos rule subscriber、两阶段审批工作流、被 trim 行的软删除留存。其中 AffinityRoute 由 @mochengqian 负责后续跟进,其余暂无 owner。
本次测试覆盖功能正确性、并发边界、错误响应与代码质量四个维度,全部测试项通过。
回到首页