Migration From Monolith
ODTS 09 — 从单体到微服务:拆解巨兽
为什么需要这篇文档
作为一个新开发,你会发现 ODTS 的架构是……分层的。有些部分是 2022 年的 Spring Boot,有些是 2016 年的 JFinal,有一个模块至今在用 2015 年的 raw JDBC。这篇文档解释为什么——更重要的是,我们怎么迁移的和还剩什么没干。
1. 原始单体 (2014–2018)
它长什么样
2016 年,整个系统就是一个 WAR 包部署在 Tomcat 上:
single-eds.war
├── struts.xml — URL 路由
├── JSP 页面 — 前端 (edsWeb)
├── Action 类 — 控制器层
├── Service 类 — 业务逻辑
├── DAO 类 — Raw JDBC + 连接池
├── 定价引擎 — 嵌入在同一进程中
└── Protobuf 监听器 — 批处理作业
优点:
- 部署简单:一个 artifact,一台服务器
- 定价调用不走网络(同一 JVM)
- 事务一致性天然保证
缺点(迫使拆分的驱动力):
- 一个崩全部崩: 定价引擎的内存泄漏会拖垮 Web UI,反之亦然
- 无法独立扩缩容: 定价引擎需要 CPU(多核),Web 服务器需要内存(多连接),两者竞争资源
- 部署风险: 一个微不足道的 UI 改动用得上全系统重新部署——包括定价引擎
- JSP 编译在 Tomcat 中: 前端用 JSP,又慢又难维护
- 多团队互相踩脚: 15+ 开发者在同一个代码库、同一个发布周期上工作
单体架构的直接业务成本(2016-2017): 系统每月因部署或资源竞争导致的宕机约 2-3 次,平均恢复时间 30 分钟。按当时的交易量,每次 30 分钟宕机 ≈ 交易台无法报价 ≈ 丢失 1-2 笔询价。按每笔雪球 1 亿名义本金、3% 年化利差计算,每次宕机的机会成本约 1-2 万。虽不是天文数字,但”不可靠”的名声在客户群里传开了——销售反馈:至少有 2 个机构客户在早期因为系统稳定性放弃了和中金的合作,直到 2019 年才重新接触。
决定拆分的导火索
大约在 2017 年,业务需求变了:交易台需要实时报价 (real-time quotation),而不仅仅是 EOD 定价。这意味着:
- 定价引擎必须 7×24 可用
- 定价引擎需要独享资源(8+ 核专用于 Monte Carlo)
- 前端需要独立更新
拆分就开始了:
┌──────────────────────┐ ┌──────────────────────┐
│ edsWeb (WAR) │ │ hedging-as (JAR) │
│ - JSP 页面 │───▶│ - 定价引擎 │
│ - Action 控制器 │ │ - Payoff 库 │
│ - 业务逻辑 │◀───│ - 估值方法 │
│ - DAO (JDBC) │ │ - 行情数据缓存 │
│ │ │ - 保证金计算器 │
│ Port 8080 │ │ - CEP 引擎 │
│ Tomcat 部署 │ │ │
│ │ │ Port 8081 │
│ │ │ 内嵌 Jetty JAR 部署 │
└──────────────────────┘ └──────────────────────┘
│ │
└─────────── MQ ───────────┘
(ActiveMQ)
两边的通信通过 ActiveMQ:
- edsWeb 发送定价请求 → ActiveMQ → hedging-as 处理 → 回复到 ActiveMQ → edsWeb 接收
- 这很慢但是有意的选择——它强迫团队设计一个干净的异步接口
拆分的代价
| 代价 | 详情 |
|---|---|
| 延迟 (Latency) | 报价从 50ms 变成了 500ms(JVM→MQ→JVM 一次来回) |
| 复杂度 | 两套部署管线、两块监控面板 |
| 一致性 | 事务现在跨进程边界了 |
| 重复代码 | 领域模型必须在两个项目中都存在(共享 JAR 变成了 eds-utility) |
500ms 延迟的实际业务影响: 从 50ms 到 500ms 在技术上很大,但销售在电话报价时根本感觉不到(人的反应时间 200-300ms)。真正的影响是并发能力下降了——2017 年单台 Tomcat 可以处理约 50 个并发报价请求,拆分后因为 MQ 的线程模型限制,并发降到了约 30。好在 2017 年的交易量还没到同时 50 个询价的峰值。这个瓶颈到 2019 年才显现——雪球爆发时,报价请求翻倍,ActiveMQ 的消费端处理不过来了,报价队列开始堆积。修复方案不是改架构——是加了一台 hedging-as 实例做负载均衡。
2. eds-web-app:第二次拆分 (2018–2020)
为什么再拆一次
到了 2018 年,即使拆分出了 hedging-as,原来的 edsWeb 单体也已经变成了一个Action 汤:
- 200+ 个 Action 类,没有清晰的结构
- 有些 Action 3000+ 行
- 没有依赖注入(到处是
new FooAction()) - struts.xml 路由文件 2000+ 行
- 测试基本不可能——大部分 Action 用静态方法和全局状态
策略:绞杀者模式 (Strangler Fig)
我们没有重写。我们绞杀——慢慢地一个 Action 一个 Action 地替换成 Spring Boot Controller。
Phase 1: 共存
Tomcat (edsWeb) Spring Boot (eds-web-app)
┌─────────────────────┐ ┌────────────────────────┐
│ struts.xml 路由 │ │ /eds-boot/api/v1/... │
│ 旧 Action │ proxy──▶│ 新 Controller │
│ *部分* 新 Action │◀──proxy──│ 新 Service + DAO │
└─────────────────────┘ └────────────────────────┘
│ │
└─────────── 同一数据库 ──────────────┘
Phase 2: URL 重定向
Nginx 反向代理:
/eds-web-app/api/* → Spring Boot (eds-web-app)
/* → Tomcat (edsWeb)
关键决策:
- 同一数据库: 新旧系统都读写同一个
CtrContract表。这个决策有争议但很务实——不需要同步,回滚也很简单(切换 Nginx 代理就行) - 新 URL 命名空间: 所有新端点用
/eds-boot/api/v1/...前缀。旧路径保持不变。 - 没有大爆炸迁移: 每个 Action 独立替换。如果在旧区域需要新功能,开发者先把这个区域迁到 Spring Boot,再加功能。
我们得到了什么
| 改进 | 之前 (Action Soup) | 之后 (Spring Boot) |
|---|---|---|
| 依赖注入 | 手动 new X() | @Autowired |
| 测试 | 几乎不可能 | @SpringBootTest |
| 配置 | 属性文件 + 静态常量 | application.yml |
| 错误处理 | 每个 Action 里 try-catch | @ControllerAdvice |
| 指标 | 无 | Actuator + Micrometer |
| 数据库访问 | Raw JDBC | MyBatis + JdbcTemplate |
3. 前端迁移:Vue SPA (2019–2023)
从 JSP 到 Vue
原来的 edsWeb 前端是 JSP + jQuery。到 2019 年时它的状态是:
- 不可维护(JSP 文件嵌入 Java 代码、内联 JavaScript、内联 CSS)
- 慢(每次操作都要完整页面刷新)
- 丑(Bootstrap 之前的、现代 CSS 之前的风格)
- 不适合移动端
我们选了 Vue 2 + Element UI,因为:
- Vue 学习曲线平缓(团队没有 SPA 经验)
- Element UI 提供了一套完整的组件(表格、表单、弹窗、日期选择器)
- 中文文档优秀
共存模式
用户访问: /eds-boot/views/contract/list.html
Nginx 按路径路由:
├── /eds-boot/* → Spring Boot (REST API)
├── /static/eds-boot/* → Vue SPA (new-edsweb)
└── /* → Tomcat (edsWeb JSP)
Vue SPA 页面逐步添加:
- 新功能 → 用 Vue SPA 构建
- 旧页面的 bug 修复 → 在 JSP 里修(正常工作,不重写)
- 页面需要大改 → 用 Vue SPA 重写
时间线:
2019: 第一批 Vue 页面(登录、仪表盘、简单搜索)
2020: 合约簿记 (Contract Booking) 用 Vue 重写
2021: 确认、资金管理、EOD 操作迁移到 Vue
2022: 风险指标、对冲监控 (odts-option-web)
现在: ~70% 的页面是 Vue,~30% 仍是 JSP(主要是管理/报表页面)
第二个前端:odts-option-web
2021 年,另一个团队负责构建期权交易和风险监控系统。他们选择了:
- 同一技术栈(Vue 2 + Element UI)
- 同一组件风格(VxeTable、相同布局)
- 但作为独立的项目(odts-option-web)
为什么独立,而不是 new-edsweb 的一部分?
- 部署节奏不同(期权团队每周发布,核心 EDS 每月发布)
- 用户群不同(期权交易台 vs. 全品种交易台)
- 生命周期规划不同(odts-option-web 最初计划为一个原型,后续可能合并回来)
现状: 截至 2026 年仍是分开的。合并的代价已经超过了收益。
4. 微服务实验:Odyssey / Linear (2022–2024)
我们尝试了什么
Odyssey 项目是一次真正微服务的尝试——将业务能力拆分为可独立部署的服务:
| 服务 | 职责 | 状态 |
|---|---|---|
odyssey-swap | Swap 交易管理 | 已构建、已部署、中等采用率 |
odyssey-web | 统一前端网关 | 曾是原型,从未达到生产质量 |
odts-linear-web | 统一线性产品 UI | 已构建,交易台部分使用 |
odts-option-web | 期权 + 风险 UI | 活跃使用(对冲监控器) |
架构图:
┌─────────────────────┐
│ odyssey-web │
│ (前端网关) │
└──────────┬──────────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
┌───────▼───────┐ ┌────────▼───────┐ ┌────────▼───────┐
│ odyssey-swap │ │ odyssey-trs │ │ odyssey-option │
│ (Kotlin/RS) │ │ (Kotlin/RS) │ │ (Kotlin/RS) │
└───────┬───────┘ └────────┬───────┘ └────────┬───────┘
│ │ │
└──────────────────────┼──────────────────────┘
│
┌──────────▼──────────┐
│ hedging-as │
│ (定价引擎) │
└─────────────────────┘
Odyssey 用到的技术:
- Kotlin + Spring WebFlux(响应式)
- Rust(用于性能关键组件)
- Kafka(事件流,替代 ActiveMQ)
- Kubernetes(部署)
为什么没有完全采用
- 运维成本: Kubernetes + 5 个微服务 + Kafka 需要的 DevOps 能力超出团队拥有
- 延迟: 微服务对简单操作反而比单体慢(网络跳数)
- 数据一致性: 没有分布式事务 → 一大堆”最终一致”的 bug
- 认知负荷: 开发者要理解 5 个服务而不是 2 个项目
- 原系统能用: 没有出现迫使迁移的灾难性故障
我们最终采用的混合架构
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ new-edsweb │ │ odts-option- │ │ 旧 JSP │
│ (Vue 2 SPA) │ │ web (Vue 2) │ │ (遗留) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ ┌──────────┴──────────┐ │
│ │ Nginx / Web 服务器 │ │
│ └──────────┬──────────┘ │
│ │ │
┌──────▼─────────────────▼─────────────────▼──────┐
│ eds-web-app (Spring Boot) │
│ 核心业务: 簿记、确认、报表 │
└──────────────────────┬──────────────────────────┘
│ Protobuf
┌──────────────────────▼──────────────────────────┐
│ hedging-as │
│ 定价、估值、保证金、EOD │
└──────────────────────────────────────────────────┘
这就是截至 2026 年的架构。 它不好看,但能用。关键洞察:粗粒度服务 (eds-web-app + hedging-as) 比细粒度微服务好运维得多。
5. 还剩什么没迁移
| 组件 | 当前技术 | 迁移优先级 | 业务风险 | 备注 |
|---|---|---|---|---|
| 剩余 JSP 页面 (30%) | JSP + jQuery | 低 | 低——使用者少,不出错 | 能用,使用者少 |
| eds-web-app 非 Boot Action | Struts 类 Action | 中 | 中——修 bug 风险高,改错一处可能影响其他 Action | 还有 60+ 个 Action 没迁 |
| 配置管理 | 多个 YAML/properties 文件 | 中 | 中——配错可能导致定价异常,但历史上有补救手段 | 没有统一配置中心 |
| 消息队列 | ActiveMQ (旧) + Kafka (新) | 低 | 中——双跑中但 ActiveMQ 稳定性已知问题(见 06) | 双跑中,两者都正常 |
| 部署 | 手动 (copy_restart_jar.sh) | 高 | 高——人为部署失误导致过多次停机(见 02),直接影响交易台可用时间 | 核心系统没有 CI/CD |
| 监控 | 自定义日志 + Actuator | 中 | 中——事后排查够用,但无法主动预警 | 没有 APM |
5b. 内行才知道的:绞杀过程中”同一张表两套写入”埋过的雷
绞杀者模式的核心决策是”新旧系统共用同一个 CtrContract 表”(见第 2 节)。这很务实,但它藏着一个只在迁移中途才会出现的窗口期问题:在某个 Action 已经被迁到 Spring Boot、但依赖它的下游 Action 还没迁的过渡期,同一笔交易可能被两套代码各自写过一次。
真实事件:一笔交易被”双写”覆盖了
2019 年,簿记 Action 已经迁到 ContractController(Spring Boot),但它的下游”额度预占”Action 还留在老 Tomcat 里、读的是另一份内存缓存。新 Controller 写完 CtrContract 后通过 MQ 通知老 Action 去占额度;老 Action 占完额度,又把自己算出的”剩余额度”回写到了 CtrContract 的一个冗余字段——而这个字段在新系统里已经被另一个流程接管控了。
结果:新系统刚写进去的”交易状态 = 已确认”,被老 Action 的回写覆盖成了”额度占用中”,交易卡在了中间态。这笔交易在当天 EOD 里被当成”未确认”跳过,没参与估值,交易员第二天才发现自己的仓位少了一笔。
业务影响: 单笔 ¥8000 万名义本金的 TRS 漏估了一天。当天风险敞口低估,但没有触发强平或超标(当时组合足够分散)。真正的风险是流程性的:这类”过渡期双写”问题在绞杀完成前无法被单元测试发现,因为单个测试只看一条路径。团队后来定了一条铁律——迁移过渡期内,任何”老 Action 回写共享表”的改动都要先评审,并给 CtrContract 加了字段归属注释(“此字段归 Boot 管线,老 Action 禁止写”)。
为什么这个雷今天还在
截至 2026 年,还有 60+ 个老 Action 没迁完(见第 5 节)。只要过渡期没结束,这套”双写纪律”就不能松。这也回答了一个常见问题:为什么不全量重写一次性解决?因为每一次大爆炸重写都会让这种窗口期风险变成”全量暴露”,而绞杀把它控制在单个 Action 的局部——代价就是长长的、需要持续警惕的过渡期。
6. 经验教训
-
先拆引擎和 Web。 把计算密集型的定价引擎拆出来 ROI 最高、风险最低(它内部本来就是被当服务调用的)。
-
不要一边建功能一边改架构。 2019–2020 年一边做新产品一边拆系统的尝试停滞了——两件事同时做,哪件都做不好。
-
“够好”的单体优于”完美”的分布式系统。 当前架构(3 个主要组件)比 15 个服务的 Odyssey 愿景简单得多。
-
数据库就是集成点。 eds-web-app 和 hedging-as 共享同一个 DB 是务实的。一致性模型简单,回滚也很容易。
-
要绞杀,不要重写。 每次尝试大爆炸式重写要么失败,要么花了预估 3 倍时间。每次绞杀式迁移(一次一个 Action → 一个 Controller)都成功了。
-
在不疼的地方接受技术债。 剩下的 JSP 页面正常工作。旧的 ActiveMQ 正常工作。迁移它们会消耗开发者时间却没有业务价值。
自测
- 为什么第一步选择拆分计价引擎,而不是别的东西?
- 绞杀者模式 (Strangler Fig) 的核心思想是什么?它比大爆炸重写好在哪里?
- Odyssey 微服务实验为什么没有被完全采用?
- 你会在什么情况下考虑把剩下的 30% JSP 页面重写?
下一篇:10-How-to-Read-the-Code.md —— 调试和开发的实用指南