type
Post
status
Published
date
May 22, 2026
slug
dubbo-admin-reviewer-report
summary
tags
开源贡献
推荐
category
开源贡献
icon
password
日期: 2026-05-22
Issue: apache/dubbo-admin#1473
版本信息
组件 | 版本 |
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 Route、Tag Route、Dynamic 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 扫描所有现存规则。RecordBootstrap 对在 rule_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 形同虚设。两种孤儿场景:rm.Update写失败 → hint 已Put,事件永不到来 → 孤儿。
- 2s coalesce window 内 admin 写 hash H1 → 上游事件 H2 覆盖 pending → subscriber
Take(H2)→ H1 永不被消费。
修复:在
Put 和 Take 持锁路径开头各调一次 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::PutConfiguratorWithRuleName 不 return | 不是本 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 | mysql 或 memory | memory 仅适合开发;生产请用 MySQL/Postgres |
expectedVersionId 在所有 mutation 端点(PUT/POST/DELETE)与 /rollback 上都是可选:不传 → 行为完全不变;传 → 触发弱 CAS 检查。9. 结论
- 版本账本核心功能完整:Bootstrap、Admin、Upstream、Rollback 四类来源全部正确入册,
version_no单调且不重用。
- 并发与边界保护:乐观锁 409 在前端以 sticky 通知 + Reload 呈现;reason 必填以 HTTP 400 +
InvalidArgument返回;FEATURE_DISABLED关停为 HTTP 503。
- 跨规则类型一致:Condition / Tag / Dynamic Config 三类规则共享同一套 ledger、API、UI 组件。
- 上游回声不冗余:
(content_hash, operation)在 store 内部去重,Bootstrap 后注册中心 echo 不产生重复行;DELETE 与非 DELETE 不互相折叠。
- 代码审查决策矩阵公开:19 个 finding 中 11 个已修复(含 hint 内存泄漏、FlushAll 未接 shutdown、Monaco model 泄漏);2 个仅文档说明语义;6 个显式不改并记录理由。
- 不在范围:AffinityRoute、Nacos rule subscriber、两阶段审批工作流、被 trim 行的软删除留存。其中 AffinityRoute 由
@mochengqian负责后续跟进,其余暂无 owner。
本次测试覆盖功能正确性、并发边界、错误响应与代码质量四个维度,全部测试项通过。
分享
