Learning
VOL. VII · NO. 58 · OTC Derivatives · 19 JUL 2026

Migration From Monolith

OTC 衍生品 · 19 JUL 2026 · 14 min read · 2,995 words
· · ·

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 定价。这意味着:

  1. 定价引擎必须 7×24 可用
  2. 定价引擎需要独享资源(8+ 核专用于 Monte Carlo)
  3. 前端需要独立更新

拆分就开始了:

┌──────────────────────┐    ┌──────────────────────┐
│   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 JDBCMyBatis + 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 页面逐步添加:

  1. 新功能 → 用 Vue SPA 构建
  2. 旧页面的 bug 修复 → 在 JSP 里修(正常工作,不重写)
  3. 页面需要大改 → 用 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-swapSwap 交易管理已构建、已部署、中等采用率
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(部署)

为什么没有完全采用

  1. 运维成本: Kubernetes + 5 个微服务 + Kafka 需要的 DevOps 能力超出团队拥有
  2. 延迟: 微服务对简单操作反而比单体慢(网络跳数)
  3. 数据一致性: 没有分布式事务 → 一大堆”最终一致”的 bug
  4. 认知负荷: 开发者要理解 5 个服务而不是 2 个项目
  5. 原系统能用: 没有出现迫使迁移的灾难性故障

我们最终采用的混合架构

┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│ 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 ActionStruts 类 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. 经验教训

  1. 先拆引擎和 Web。 把计算密集型的定价引擎拆出来 ROI 最高、风险最低(它内部本来就是被当服务调用的)。

  2. 不要一边建功能一边改架构。 2019–2020 年一边做新产品一边拆系统的尝试停滞了——两件事同时做,哪件都做不好。

  3. “够好”的单体优于”完美”的分布式系统。 当前架构(3 个主要组件)比 15 个服务的 Odyssey 愿景简单得多。

  4. 数据库就是集成点。 eds-web-app 和 hedging-as 共享同一个 DB 是务实的。一致性模型简单,回滚也很容易。

  5. 要绞杀,不要重写。 每次尝试大爆炸式重写要么失败,要么花了预估 3 倍时间。每次绞杀式迁移(一次一个 Action → 一个 Controller)都成功了。

  6. 在不疼的地方接受技术债。 剩下的 JSP 页面正常工作。旧的 ActiveMQ 正常工作。迁移它们会消耗开发者时间却没有业务价值。


自测

  • 为什么第一步选择拆分计价引擎,而不是别的东西?
  • 绞杀者模式 (Strangler Fig) 的核心思想是什么?它比大爆炸重写好在哪里?
  • Odyssey 微服务实验为什么没有被完全采用?
  • 你会在什么情况下考虑把剩下的 30% JSP 页面重写?

下一篇:10-How-to-Read-the-Code.md —— 调试和开发的实用指南