Yearning DDL 一次提交生成多工单

一句话结论

需求很朴素:给团队用的 SQL 审核平台 Yearning 加一个功能——一条 DDL 工单能一次同时提交到多个数据源(比如国内库 + 海外库两个实例),各自生成一个工单、各走各的审批,省去两边分别手提一遍。

但落地一点都不顺,绕了一圈才认清两个硬约束:这个多环境能力社区版没有现成支持(属赞助版才有的功能,issue #423 至今 Open);而官方 Release 二进制的审核能力,无法仅靠当前公开仓库源码稳定复现——所以生产环境不改官方二进制。

最终选定的方案是——只改前端、用 nginx「覆盖」上线,后端二进制一行不碰

这篇把四个方案怎么一个个掂量、为什么最后选了 nginx 前端覆盖讲清楚,外加部署时本地复现踩到的三个真坑。所有结论都是实测出来的,不是纸上谈兵。

文中所有域名/路径/主机均为占位(example.com/data/app-web 等),替换成你自己的即可。


一、需求与约束

先把约束摆清楚,后面的方案选型才有意义:

  • 需求:DDL 工单一次提交到多个环境(多数据源),各建一单。
  • 约束 1:多环境工单社区版没有现成支持——据我实测/确认是赞助版才有的能力,且官方 issue #423 至今 Open(社区版长期未合入)。以当时版本为准,想要就得自己加。
  • 约束 2官方 Release 二进制的审核能力,无法仅靠当前公开仓库源码稳定复现(详见方案 A),所以生产环境不改官方二进制
  • 架构事实:Yearning 后端是 Go 二进制,前端是独立的 Vue3/Vite 工程,前端通过 go:embed 编进后端二进制里。

一句话:功能得自己加,但能动的料很有限。


二、方案选型:四条路逐个掂量

这是全文的核心。把四个方案和它们各自的"死因/选中理由"讲透,最后那个方案才显得是必然。

方案选型:四条路逐个淘汰到最后一个

上图按顺序点亮:方案 A ❌(公开源码复现不出官方审核能力)→ B ❌(反编译不出源码 + 法律风险)→ C △(可行但非原生入口)→ D ✅(留原装二进制、只换前端皮、可秒回滚)。

方案 A:从公开源码重编后端二进制(嵌入改过的前端)—— 死路

最直觉的想法:后端是开源的(AGPL),clone 下来,把改过的前端 dist 塞进 go:embed 目录,go build 出一个新二进制不就行了?

试了。编出来的二进制,"SQL 检测/执行"全是坏的。排查发现根因:

以我当时排查的版本/分支为准,公开仓库源码无法编出与官方 Release 等价的审核能力。

  • go.mod 里没有 TiDB parser 之类的 SQL 解析依赖;
  • 审核(Engine.Check)走的是外挂 RPC——源码只有"调用方",那个真正干审核的引擎(相当于一个独立服务)的实现不在当时的公开仓库里

也就是说:官方发布的二进制,不等于你从当时的公开源码能编出来的那个。你编出来的是个"没装引擎的壳",检测、执行自然全废。

一个值得单独拎出来的教训:别拿弱证据当结论

排查时我犯过一个错。一开始 strings 官方二进制,看到几个零星的 tidb/TiDB 字样,一度判断"引擎已经编进官方二进制了"。

后来做了次二进制指纹比对才推翻这个判断:我自己另写了个确定用 TiDB parser 的小程序,strings 一扒——pingcap/parser 包路径命中近 2000 次,外加解析器的报错模板、一大堆 TiDB 独有的语法符号。拿这套"指纹"去扒官方二进制:0 命中

# 我那个确定带 parser 的二进制:
strings my_engine | grep -c 'pingcap/parser'   # ≈ 1967
strings my_engine | grep -F 'column %d near'   # 有(解析器报错模板)

# 官方二进制:
strings official_bin | grep -c 'pingcap/parser'  # 0
strings official_bin | grep -F 'column %d near'   # 无

结论:官方二进制里那几个 tidb 字样是无关依赖顺带的——真把一个完整 SQL parser 编进去,会留下成千上万个特征串,不是零星几个。我先前的判断是被弱证据带偏了。

这条放之四海皆准:排障里最容易翻车的,就是拿"能自圆其说"的弱证据当结论。看到一两个符合预期的字样就下判断,往往是错的;要找的是只有该结论成立时才会大量出现的强特征。

至于官方二进制的检测为什么能用、引擎到底藏在哪——这个我没继续深究。对本次决策来说不重要:只要"当时公开源码编不出与官方等价的二进制"这一点成立,重编这条路就堵死了。

方案 A 出局:编出来复现不出官方审核能力,检测/执行全坏。

方案 B:反编译官方二进制,把引擎扒出来 —— 不可行

那能不能反编译官方二进制,把引擎抠出来塞进自己的版本?

不行,两头都堵:

  • 技术上:它是 Go 编译的原生机器码(不像 Java/.NET 有中间字节码能较干净地反编译),而且是 stripped(剥了符号表)。顶多用反汇编工具看汇编,反编译不出能 go build 的源码。从汇编还原一整套 SQL 审核引擎,约等于照着汇编重写一遍,比从零写还累、还更容易在"执行 SQL"这种关键路径上埋微妙 bug。
  • 法律上:那是官方未公开源码的组件,反编译提取再使用/分发,踩授权与知识产权的红线。

方案 B 出局

方案 C:不碰 Yearning,外挂一个工具调它的 OpenAPI —— 可行但体验割裂

Yearning 提供 OpenAPI。完全可以写一个独立小工具:输入一条 DDL + 勾选目标环境 → 调用它的建单接口,对每个环境各建一个工单。零侵入,不碰 Yearning 任何东西,风险最低。

唯一的缺点:入口是个外部小工具,不是 Yearning 里那个原生的提交页面。用户得换个地方操作,体验割裂、有学习成本。

方案 C 保留为备选,但不优先。

方案 D(选中):只改前端 + nginx 覆盖上线

转机来自一个观察:

这个功能本质是纯前端的。

"同时提交到多个数据源"无非是——前端多选几个数据源,提交时循环调用 Yearning 原生的建单接口各建一单。后端逻辑一行都不用改。

而前端是个独立的开源仓库(Vue3/Vite,AGPL),改它完全不受"后端二进制动不得"的影响。

剩下的问题只有一个:前端改完怎么上线? 既然前端是编进后端二进制的、而二进制动不得——

答案是用 nginx 把前端"覆盖"掉。 nginx 本就在 Yearning 前面,让它把前端静态资源路径(/front)指向我改过的 dist,其余请求(API / 登录 / WebSocket)照常 proxy 给原装二进制。

结果:

  • 原装那个带引擎的二进制原封不动,只把它对外暴露的"前端皮"换成我的;
  • 检测、执行、原有功能全不受影响,新功能生效;
  • 改 nginx 配置即可上下线,秒级回滚

四个方案横着一摆,选谁很清楚:

方案 思路 结论 关键原因 风险 / 代价
A 重编后端二进制 clone 开源后端 → 塞入改过的前端 distgo build ❌ 死路 当时公开源码复现不出官方审核能力go.mod 无 SQL parser、审核走外挂 RPC,引擎实现不在公开仓库),编出来检测/执行全坏
B 反编译扒引擎 反编译官方二进制,把引擎抠出来塞进自己的版本 ❌ 不可行 Go 原生机器码 + stripped,反编译不出可编译源码;且踩授权 / 知识产权红线 技术、法律双堵
C 外挂调 OpenAPI 独立小工具调原生建单接口,对每个环境各建一单 ✅ 可行 零侵入、不碰 Yearning,风险最低 入口是外部工具,非原生页面、体验割裂
D 前端改 + nginx 覆盖 只改前端;nginx 把 /front 指向新 dist,其余转原二进制 选中 功能本质纯前端;原装带引擎的二进制一行不动,只换前端皮 低,改 nginx 配置即可秒级回滚

选 D。 它在"能用、风险可控、保留原生体验"三者间最平衡。


三、前端改了什么(只动 2 个文件)

后端零改动,前端只改两个文件:

  • 提交页 UI:DDL 工单加一个「同时提交到」多选框(仅 DDL 显示、DML 不变);每勾选一个目标,动态拉取它自己的库列表让你选——因为不同环境的库名可能不一样,否则会把同一个库名误发到两边;
  • 提交逻辑:对 [主数据源 + 每个勾选的目标],各自拉它的审批流、用各自选定的库 + 同一条 SQL,循环调用原生建单接口,各生成一个独立工单、各走各的审批;
  • 数据源接口:补一个"所属环境"字段,多选项里好区分。

用的全是官方原生接口,后端零改动。核心逻辑提炼如下(省去校验、错误处理等细节,接口名沿用前端原生 API):

// ① 多选的「额外目标数据源」+ 每个目标各自的库(不同环境库名不同,必须分开存)
const extraTargets   = ref<string[]>([]);                     // 勾选的目标数据源 id
const targetSchemas  = reactive<Record<string, string[]>>({}); // 目标 → 它自己的库列表
const targetSelected = reactive<Record<string, string>>({});   // 目标 → 选中的库

// ② 勾选某个目标时,异步拉「它自己的」库列表(关键:不能套用主库名)
watch(extraTargets, async (ids) => {
  for (const id of ids) {
    if (!(id in targetSchemas)) {
      const { data } = await querySchemaList(id, true);       // 官方原生接口
      targetSchemas[id]  = data.payload;
      targetSelected[id] = '';
    }
  }
}, { deep: true });

// ③ 提交:目标 = [主数据源] + [每个勾选的目标],对每个各建一单
async function postOrder() {
  const baseSql = editor.value.GetValue();
  const targets = [
    { source: main.source, source_id: main.source_id, data_base: main.data_base },
    ...extraTargets.value.map((id) => ({
      source: sourceName(id), source_id: id, data_base: targetSelected[id], // 各用各的库
    })),
  ];
  for (const t of targets) {
    const order = { ...main, ...t, sql: baseSql };
    // 每个数据源各自的审批流(审核人可能不同)
    const { data } = await queryTimeline(t.source_id, '');
    order.relevant = (data.payload || []).flatMap((i) => i.auditor);
    await userPostOrder(order);   // 官方原生建单接口 → 各生成一个独立工单
  }
}

一句话:循环 [主源 + 各目标]、每个目标带自己的库、调一次原生建单接口——多工单就这么来的,后端一行没动。

一个前提:多环境之间表名要相同、库名可以不同;SQL 用不带库名的对象ALTER TABLE users ... 而非 ALTER TABLE db_a.users ...),库靠每个目标各自的执行上下文区分。


四、上线:nginx 覆盖 + 三个坑 + 灰度切流

nginx 前端覆盖架构:原装二进制不动,只换前端皮

架构很简单:nginx 把 /front/* 指向我改过的前端 dist,其余请求(API/登录/WebSocket)照常转给原装那个带审核引擎的二进制——二进制一行不改。

部署前我在本地把这套覆盖完整复现了一遍:本地起一个 nginx,/front 指向我的 dist,其余 proxy 给本地跑的 Yearning。一跑就连踩三个坑——幸亏在本地踩,没直接上生产

坑 1:根路径要 302 跳到 /front/,否则白屏

前端资源是带 hash 的(/front/assets/index.<hash>.js)。直接打开根路径 /,nginx 把它 proxy 给后端二进制,后端吐回的是它内嵌的旧前端index.html,引用的是旧 hash;而 /front/assets 走的是我的新 dist,那个旧 hash 的文件根本不存在 → 404 → 白屏。

:让根入口也用我的前端。

location = / { return 302 /front/; }

坑 2:静态资源缺失要"干净 404",不能兜底成 HTML

SPA 需要 try_files 兜底到 index.html(这样刷新深层路由不会 404)。但如果把 /front/所有缺失请求都兜底成 index.html,问题就来了:用户浏览器里旧缓存引用的旧 hash 资源(已不存在)会被返回一个 HTML → 浏览器把 HTML 当 JS 执行 → 报 Unexpected token '<' → 白屏。

:把 /front/assets/ 单独拎出来、不兜底(缺了就干净 404),只让 SPA 路由兜底到 index.html

坑 3:WebSocket 要带升级头

部分实时更新接口(我这版是工单列表,不同版本路径/场景可能不同)走的是 WebSocket。nginx 默认 proxy 不升级协议,握手直接 400 Bad Request

:proxy 段加上 proxy_http_version 1.1Upgrade/Connection 升级头。

最终配置(占位,替换成你自己的)

# 以下 map / upstream 放在 http {} 块里(和现有配置同级);现有 nginx 已有 upstream 就复用、可省。
# map: 让非 WebSocket 请求走 keep-alive,只有 WS 才升级——配合 upstream keepalive 才正确。
map $http_upgrade $connection_upgrade {
    default upgrade;   # WebSocket 请求 → Connection: upgrade
    ''      '';        # 普通请求 → Connection 置空,配合 keepalive 复用后端连接
}
upstream backend_app {
    server 127.0.0.1:8000;   # Yearning 原装二进制监听的端口
    keepalive 128;
}

server {
    listen 80;
    server_name app-new.example.com;

    # 访问控制照抄你现有站点(IP 白名单 / 真实客户端 IP 等),此处略

    # 坑1:根入口跳 /front/,让 index 和资源 hash 自洽
    location = / { return 302 /front/; }

    # 坑2:静态资源缺失要干净 404,不兜底成 HTML(否则旧缓存白屏)
    location /front/assets/ {
        alias /data/app-web/assets/;
        try_files $uri =404;                                  # 显式:缺文件就干净 404,不进 SPA 兜底
        add_header Cache-Control "public, max-age=31536000, immutable";  # 带 hash,可长缓存
    }

    # SPA 路由兜底
    location /front/ {
        alias /data/app-web/;
        try_files $uri $uri/ /front/index.html;
        add_header Cache-Control "no-cache";                  # index.html 不缓存,保证拿到新 hash
    }

    # 其余请求转后端二进制 + 坑3:WebSocket 升级头
    location / {
        proxy_pass http://backend_app;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

关键认知:访问新的 index.html 后正常就会加载新 hash;真正需要手动刷新的,是发布后仍停留在旧页面的用户(旧 JS 还在跑)或浏览器/代理缓存了旧 index 的情况——刷新一次即可拿到新版入口 HTML。坑 1/坑 2 + 上面的缓存头保证的是:刷新前不白屏成乱码、刷新后立刻正常。挑低峰发。

灰度上线:新域名,不碰现有

没有直接去改现有域名的配置(那一动,所有现用户立刻受影响)。而是新开一个域名,指向加了上面那段覆盖配置的 nginx;现有域名 / 现有前端完全不动

  • 验证:用新域名把"提交 → 生成两个工单 → 两边各自执行 → 两个库里都建出表"完整走一遍;
  • 没问题再推广;出任何岔子,删掉新域名那段 server 配置 / 路由规则即可回滚,现有站点全程零感知。

这本质是一次 负载均衡 / nginx 层的蓝绿灰度——把风险关在一个新入口里。


快速参考

方案选型一句话:官方能力无法用当前公开源码等价复现、生产二进制动不得 → 重编(复现不出官方能力)、反编译(不可行)都死 → 功能本质纯前端 → 改前端 + nginx 覆盖,保留原装二进制

nginx 前端覆盖三铁律

现象
根路径没跳 /front/ 后端旧 index 引用旧 hash → 404 → 白屏 location = / { return 302 /front/; }
资源缺失兜底成 HTML 旧缓存旧 hash 被当 JS 执行 → 白屏 /front/assets/ 单独配、不 try_files
WebSocket 没升级头 握手 400 proxy 段加 Upgrade/Connection 升级头

几条经验

  • 改不动的东西,绕过它而不是硬刚:后端二进制动不得,就在它前面的 nginx 层做文章,原装的留着用。
  • 上线前先在本地用同样的组件复现一遍——这三个坑全是本地踩到的,直接上生产就是事故。
  • 灰度用新入口(新域名/新规则),别动现有的,回滚成本趋近于零。
  • 别拿弱证据当结论:判断"引擎在不在二进制里",用"只有该结论成立才会大量出现的强特征"(指纹比对),而不是零星几个字样。
  • 改完顺手沉淀:前端 fork 进内部 Git + 一份维护文档(来源 / 构建 / 合上游 / 部署踩坑)+ 把「改码 → build → 传生产 → nginx 覆盖」固化成可复用流程,下次谁接手照着走。
posted @ 2026-06-16 14:07  Hello_worlds  阅读(11)  评论(0)    收藏  举报