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

Database Design Evolution

OTC 衍生品 · 19 JUL 2026 · 21 min read · 5,383 words
· · ·

ODTS 20 — 数据库设计演进:什么错了,为什么

对于业务人员来说,数据库是不可见的——它是”系统背后的系统”。但数据库出问题时,症状总是很显眼:下午 3 点交易簿记失败、午夜 EOD 批处理崩溃、监管报告数据对不上、新产品上线延迟两周……这些都是由数据库技术债 (technical debt) 引发的业务问题,不是技术问题。

本文通过这套 OTC 衍生品系统的数据库演进史,看三个设计选择的业务后果。


关于证据来源

以下分析基于代码仓库中的 1000+ 个数据库迁移文件、核心表 Schema 以及业务代码中的查询模式。每个判断都能在附录中找到对应文件。


一、一张大表 (pre-2016):从够用到不能用的三年

业务场景

2014 年,这个交易台只有 3 个产品类型:香草期权 (Vanilla Option)、互换 (Swap)、总收益互换 (TRS)。每月约 50 笔交易。交易员通过一个叫 AccessApp 的 Java 桌面应用直连 Oracle 数据库来簿记交易。

AccessApp 的数据库设计很直接:每个主要实体一张表。TRADE 表有 80+ 列,一笔交易一行。这对当时的业务量来说完全够用。

他们做了什么

他们建了一张”大表”——把所有产品类型的属性都塞进 TRADE 表,每个产品类型独占几列。核心模式是:TRADE 表包含 200+ 列,其中大部分列只对某些产品有意义。例如 STRIKE 只有期权用、BARRIER 只有障碍期权用、UNDERLYING_BASKET 只有一篮子产品用。其他列对于其他产品行就是 NULL——大约 70% 以上的行为 NULL。

业务增长击穿了设计边界

到 2016 年,业务变了——产品类型从 3 个增长到 15+ 个,月交易量从 50 笔增长到 500+ 笔,TRADE 表膨胀到 200+ 列。设计不再合用,出现了四个具体问题。

问题一:新产品上线 = 系统停机

每增加一个新类型的结构化产品,TRADE 表里就要加 5–10 列。虽然 Oracle 的 ALTER TABLE ADD COLUMN 本身不需要长时间锁表(11g 以后的版本支持快速加列),但配套的索引、约束、数据迁移、应用代码变更都需要在变更窗口内完成。而对于只有一个 TRADE 表的系统来说,任何 Schema 变更都意味着:评估影响范围要检查 200+ 列中哪些会被影响、全回归测试要覆盖所有已有功能、变更窗口只能在交易结束后进行、回滚方案要确保如果改错明早系统可用。一个新产品从业务提出需求到上线,仅数据库变更就需要 1–2 周。

业务代价: 销售签单后需要等 2–3 周才能上线,客户体验极差。如果竞争对手的系统更灵活,交易就会流走。

问题二:同一列被不同团队用在不同的地方

STRIKE 这一列,A 团队用作期权行权价 (strike price),B 团队用作互换票息率 (swap coupon rate)。没有任何机制阻止他们混用——数据库不检查,代码不校验。后果是风控报表上”STRIKE”这一列可能混杂着行权价和票息率,前台得到的数据前后矛盾,无法信任。运营团队要用 Excel 重新核验。

业务代价: 运营团队每周花 1–2 天做数据对账 (reconciliation),因为系统里的数据不敢信。监管报告提交前需要人工逐项核对。

问题三:没有文档,新开发靠猜

没有文档说明哪些字段可空 (nullable)、哪些字段是哪个团队用的、用的是什么单位(百分比还是小数)。新加入的开发只能去翻旧代码猜。猜错了就往数据库里写错误数据——比如把 15%(年化利率)当成 0.15(小数)存进去。运营事后人工修复。

业务代价: 数据质量问题 (data quality issues) 持续积累。每次人员变动都是风险。

问题四:查询性能下降(但不致命)

行宽越来越大,Oracle 读一个 block 能装的行数变少,全表扫描 (full table scan) 变慢。但在当时的数据量下,这不是核心痛点。真正致命的是上面三个。

崩溃点:一次业务流失触发重建决定

以上问题积累了两三年,但真正触发”重建”决定的,是一次具体的业务损失。

2016 年中,销售团队接洽了一个新客户。客户需要的是一只有 30+ 个特殊属性的结构化产品 (structured product)——这些属性在现有的 TRADE 表里没有任何对应的列。评估方案是:加 30+ 列到 TRADE 表(这是唯一的选择),变更窗口需要 2 个晚上(因为需要对所有已有数据补充默认值、加索引、改应用代码),全回归测试需要额外 1 周。

承诺给客户的 1 周上线时间变成了 3 周。客户等不了,交易去了竞争对手 (the trade went to a competitor)。

这不是第一次因为系统不灵活丢单,但这是压垮骆驼的最后一根稻草。管理层意识到:每次 Schema 变更都在拖慢业务——不能灵活支持新产品,就是在丢客户、丢收入。TRADE 表的设计已经成为业务增长的瓶颈。

于是决定:重建新系统。这个决定本身是对的,但在接下来的选择中,他们选错了一条更痛苦的路。


二、EAV 方案 (2016-2017):一个副作用超出预期的选择

为什么选 EAV

2016 年底,新系统 EDS 的设计团队面对一个似乎很聪明的选择:不要用”一张大表”了,用 EAV (Entity-Attribute-Value) 模式,把所有交易的属性存成键值对。这样新产品类型不需要加列,加个字典项就行。

从技术角度看,这确实”解决”了”一张大表”的问题——任何新属性都不需要 Schema 变更了。但从业务角度看,它引入了一组更致命的伤害。

他们构建了什么

核心表结构是:EDS_contract 交易主表(只有几个公共字段)、EDS_contractElementValue 属性表(所有属性存成键值对,全部存在一个 VARCHAR2(30) 列里)、EDS_elementDict 属性字典。

一笔香草期权交易需要约 40 行在 contractElementValue 里。查询方式是:用 Oracle 的 PIVOT VIEW(透视视图)把键值对还原成行。这个视图是 1000+ 行的 SQL 文件,每次属性变化都要改。代码层通过 JFinal 的 Db + Record 模式执行裸 SQL 查询,拿到 Record 后再通过手工的 switch-case 转换 40+ 个属性到 Protobuf 对象,再交给上层逻辑。

从业务角度看这些问题

1. 数据没有类型检查——数据错误无法被数据库挡住

EAV 的 elementValue 列是 VARCHAR2(30)。名义本金 (notional) 是一个数字,它在 EAV 里存成字符串。这意味着:运营人员在界面上输入了 abc 作为名义本金,数据库欣然接受;输入了 2025-13-01 作为到期日(无效月份),数据库不报错;输入了票息率 3.14159265358979(小数点后 14 位),30 字符的限制悄悄截断了——没有警告,没有日志,就是精度损失。

业务代价: 数据错误只能在下游暴露。风控算出昨天的净敞口 (net exposure) 不对,运营去查,发现一周前簿记时某个字段就被截断了。要修正,得在数据库里手工更新那条 EAV 记录,然后重新跑 EOD。数据质量问题从”偶尔发生”变成了”家常便饭”。每次都要运营手动修复,耗费时间。

2. 查询一个简单问题要自连接三次

“查所有名义本金大于 1000 万的交易”——这个在正常系统里是一行 SELECT * FROM trade WHERE notional > 10000000。在 EAV 里是:先查出所有合约,再关联 contractElementValue 筛选 elementId = 'E000028'TO_NUMBER(elementValue) > 10000000。如果要在 3 个属性上用条件过滤(比如名目本金 > 1000 万 AND 货币 = HKD AND 状态 = LIVE),就需要 3 次自连接。

业务代价: 前端交易簿记页面加载缓慢。EOD 风险计算原本 2 小时的批处理,在 EAV 下跑了 6 小时。凌晨 2 点开始的 EOD,早上 8 点交易员到岗时还没跑完——交易员上班拿不到今天的风险敞口。业务决策没有数据支撑。

3. 无事务完整性——系统崩溃导致脏数据

一笔交易在 EAV 里是 40+ 行数据。如果系统在写入第 20 行时崩溃(中间件 OOM、网络闪断、应用重启),那这笔交易就只有一半的属性。数据库层面没有任何约束能阻止这个”半残废”的交易存在。

业务代价: 运营团队需要定期写脚本扫描表里是否存在”不完整”的交易。发现一笔,人工判断:这交易到底有没有簿记成功?如果簿记了,补上缺失的属性;如果没有,把那 20 行删掉。这种数据 cleanup 每个月都要做。

4. 报表系统成为瓶颈

监管报告需要 50 个交易属性。在 EAV 下生成的方式是:查 50 个属性 → PIVOT 回行 → 转 XML。而 PIVOT VIEW 的定义本身是一个 1000+ 行的 SQL 文件——每新增一个属性,视图定义就要手动加一行。2017 年的一次监管数据提交 deadline,因为报表 SQL 执行超时,错过了截止时间。合规部门向监管解释原因:“系统性能问题。“这在大行里是严重的合规风险事件。

崩溃点:EOD 批处理击穿运营底线

到 2017 下半年,EDS_contractElementValue 表已经积累了 5000+ 万行。EOD 批处理的 EAV 查询(把 40+ 行的属性还原成可计算的结构)越来越慢。慢到什么程度?一个典型的月度结算期间,EOD 需要处理的数据量是平时的数倍。EAV 的多次自连接导致查询计划在 Oracle 优化器里走偏,全表扫描把整个 5000 万行的表刷了一遍。

某个月底,EOD 从晚上 10 点开始,跑到第二天上午 11 点还没跑完。交易员 9 点到岗时系统显示的风险数字还是前一天的。这意味着:当天开市时,交易台不知道自己的风险敞口——不知道 Delta、Gamma、Vega 是多少,不敢报价、不敢交易。当天的交易量降到了平时的 30%。保守估计:半天停摆造成的交易收入损失约几十万人民币。

这次事件直接触发了 Phase 3 的”分区表”方案。注意——他们没有质疑 EAV 模式本身,而是在 EAV 上打了另一个补丁。


三、分区 EOD 表 (Late 2017):解决了表面问题,但绕过了根本原因

他们为 EOD 风险数据建了按日分区的表 (partitioned table by EOD date),每天一个分区。这样”查今天风险”只需要扫一个分区,避免了全表扫描。查询性能大幅提升。

但 EAV 本身的问题——数据无类型检查、查询需多次自连接、事务不完整、报表复杂——全都没解决。EOD 表只是绕过了 EAV 的问题,但没有解决 EAV 的问题。

更糟的是:这些 EOD 分区表是反规范化 (denormalized) 的——它们存储的是计算结果(Delta、Gamma、Vega),不是原始输入。这意味着如果发现某个风险计算有 bug,所有历史日期的数据都要重算;没有原始输入的快照,无法验证计算结果是否正确;数据不可重现 (non-reproducible)。

业务代价: 一次风险模型的 bug 修复后,需要重新跑过去 3 个月的 EOD 数据。这个过程跑了 4 天,期间运营无法确认历史风险敞口的准确性。

从 EAV 到混合模式——但治标不治本

2019 年后,新开发的 Spring Boot 模块(eds-web-app)开始在新表上使用传统的关系型设计——每张表有正确的列、类型、约束。但核心的 EDS_contractEDS_contractElementValue 表一直没动。

现状:

  • 老的 hedging-as(JFinal)继续读写 EAV 表——因为迁移所有交易到新表需要同时改 EAV 的写入代码和所有读取代码
  • 新的 eds-web-app(Spring Boot)用关系型表——但只用于新功能
  • 两套表结构共存——同一个交易的数据,一部分在 EAV 表里,一部分在关系型表里
  • 运营需要知道”这个属性在哪个表里”——没有统一的数据字典

最终状态: EAV 模式没有被取代。它在 2023 年仍然存在。8 年了,最初的”临时方案”变永久的。

核心问题:他们没有停下来质疑 EAV

团队的时间不断花在给 EAV 打补丁上,而不是问一个问题:“EAV 模式本身是不是选错了?”

为什么没人质疑?原因推测是:人力不够,团队在赶业务需求没有精力重构核心模型;积重难返,EAV 已经覆盖了所有交易、所有流程、所有报表;以及沉没成本谬误 (sunk cost fallacy)——已经在 EAV 上投入了一年,不想承认选错了。

业务视角: 管理层在 2018 年问过”为什么数据库迁移项目还没完?“——技术团队的回答是”EAV 太复杂,重写所有交易逻辑需要一年”。管理层说”那我们今年不做,先上别的功能”。这个决定在当时是合理的(有限资源做最有产出的事),但 5 年后回头看,当初的”先不做”累积成了一个更重的技术债。


四、对冲持仓表的重建 (Feb-Mar 2017):需求没搞清楚就动手

EDS_hedgePosition 表在两个月内被重建了三次:2 月 27 日首次创建,3 月 15 日重建加了 PREVIOUSQTYCURRENTQTYUSABLEQTY,3 月 29 日再次重建,加了更多列,改了数据类型。

每次重建不是因为技术选型错误,而是业务需求没搞清楚就开始写了。第一次创建时,开发以为”持仓就是持仓”;到运营实际使用时,发现需要区分”可用股数和冻结股数”——补了一版;再后来发现还需要区分”昨日持仓和今日持仓”——再补一版。每次重建意味着把旧表数据复制到临时表、删旧表、新建表、把数据拷回来、重命名——期间系统只读或停机。

业务代价: 每次”重建-数据迁移”都需要停机窗口。如果迁移出错(比如某列数据类型变了,精度丢失),数据就损了。运营团队每次都要做前后数据核对,确保没有数据丢失。

更深层的问题是:开发团队和业务团队之间没有需求确认的环节 (requirements elicitation gap)。开发听了业务说”我们要一个对冲持仓表”就开始写了,没有追问”持仓有哪些场景?可用和冻结的区别?历史持仓需要吗?“——这在项目早期造成了大量返工。

在 PM/BA 的视角看,这是项目治理问题 (project governance issue),不是技术问题。技术上的”重建表”只是表面症状。


五、基础设施:那个自己写的迁移框架

他们用自己写的 Shell 脚本来执行 SQL 文件。命名规则是 EDS{YYYYMMDDHHMMSS}_{ACTION}_{DESCRIPTION}.sql。目录结构包含 arch、V1.3 到 V1.8、hotfix 等文件夹,总计 1300+ 个 SQL 文件。

这为什么是业务问题?下面每个事件都直接让业务停摆过:

事件:迁移中途失败,没人知道失败在哪。 这个 Shell 脚本没有版本追踪表——不像 Flyway 那样有个 flyway_schema_history 表记录每个 SQL 的执行状态。如果脚本执行到第 800 个 SQL 时失败了(比如某列已经存在),脚本就停了。运维人员看不出失败了哪个,只能把日志打开一行行翻、找到出错 SQL、手动修复、再重跑。一次迁移失败 = DevOps 加班 2–3 小时修复。

事件:没有回滚,删了就没了。 某个 hotfix 里 DROP 了一列,事后发现下游报表还依赖这列。没有 ROLLBACK,数据已经没了。只能从备份恢复(如果有备份),停机时间约 4–6 小时。

事件:Hotfix 只在生产环境执行了,Dev 和 UAT 没有。 Hotfix 目录里的 SQL 绕过了正常迁移流程。一个月后 Dev 重装数据库,发现有部分表结构对不上。谁都不记得那个 hotfix 做了什么。环境不一致 → Dev 测不出来的 bug → 上线到生产环境出问题。


六、Oracle 特定的历史包袱

VARCHAR2(30) 主键: 每张表都用 VARCHAR2(30) 做主键。字符串关联比整数关联慢,30 字节 × 千万行 × 10+ 索引 = 大量存储浪费。每个外键也是 VARCHAR2(30)——存储成本翻倍。

NUMBER(14) 存时间戳: 时间戳存成 YYYYMMDDHHMMSS 格式的数字。没有时区支持。不能用 Oracle 原生的日期函数,必须手动 TO_DATE() 转换。时间相关的查询(如”查今天簿记的所有交易”)需要做数值比较而不是日期比较。运维写的临时查询经常漏数据——因为 20250716 这个数字是北京时间的日期,程序里处理时用的是数据库服务器时区。当时区配置不一致时,有些交易的时间就”跑”到了前一天。

没有外键: contractId 列没有声明外键约束 (foreign key),理由是”性能考虑”。没有外键的后果:当一笔合约被删除后,它的保证金记录、结算记录、EAV 属性值都留在了数据库里——成为孤立行 (orphan rows)。运营在查保证金时,查出来的结果里混着已经终止 (terminated) 的合约的数据。必须写额外的 status != 'TERMINATED' 条件来过滤,但不是所有查询都记得加——于是报表偶尔多出一笔莫名其妙的数字。

命名混乱: Schema 里可以同时看到 EDS_contract(驼峰)、EDS_CTRCONTRACTEOD(全大写)、eds_contractelementvalue(全小写)、EDS_contractElementValue(混合大小写)。每个开发选自己的命名规范。运维写查询时经常要猜表名和列名,频繁出错。


七、代码层:JFinal + Protobuf 的翻译断层

每次读取一笔交易的数据,数据流是:SQL 查询 EAV Pivot View → JFinal Record(无类型)→ 80 行 switch-case 手工转换 → Protobuf Object(有类型)→ 业务逻辑。

问题在于:所有查询是裸 SQL 字符串,列名打错了在运行时才暴露;每增加一个属性就要在 switch 里加一个 case,经常出现”A 类已经加了新属性,B 类忘记加了”的情况;EAV 的 elementValueVARCHAR2(30)Double.parseDouble(elementValue) 在每处散落,如果有非数字字符就抛异常。

一个具体案例:运营在界面簿记交易时,某个字段里带了一个不可见空格,EAV 存储时没问题(VARCHAR2 不检查空格),但读出来 parseDouble 时抛异常。就因为这个空格,前端页面白屏 (white screen of death),交易员无法继续工作。数据错误升级成了系统不可用。


八、总结:三个选择的业务代价

设计选择它试图解决什么它实际造成了什么业务代价
一张大表最简单,3 个产品类型够用新产品上线 = 停机,同一列多人混用,丢失客户交易
EAV 模式新产品不需要加列数据类型全丢失,查询慢到 EOD 跑不完,报表常错,当天不敢交易
自建迁移脚本省钱省时间迁移失败无法恢复,数据被误删,环境不一致导致生产故障
不用外键性能更好孤立行积累,报表数据污染,运营需要手动过滤

累计代价:每年大约 1 个开发月的浪费在 EAV 相关的性能调优和数据修复上;运营团队每周花 1–2 天做数据对账(因为不敢信系统里的数据);至少一次因系统问题导致的客户交易丢失;至少一次因 EOD 跑不完导致上午无法交易;多次因数据质量问题导致的合规风险事件。

根本原因:缺乏业务视角的技术决策

这些问题不是开发能力差。开发在技术上做出了在当时看似合理的选择。真正的问题是:每个选择都只从”技术怎么省事”出发,没有问”业务真正需要什么”。建大表的时候没有问未来 3 年业务会增长到什么规模;选 EAV 的时候没有问交易数据需要类型安全吗、监管报表能接受吗;不设外键的时候没有问数据一致性对风控和结算意味着什么。

如果说对 PM/BA 转行者有什么启示,那就是:在设计评审中,你不是去替技术团队做选择——你是去帮他们意识到,每个技术选择的背后都有一个业务代价。


附录:关键文件索引

文件/表路径
迁移脚本根目录~/odts1/eds-web-app/db/EDS/
首次建表 (Dec 2016)db/EDS/arch/V1.3/EDS20161228170001_INIT_EDS_TABLES.sql
合约表重建 (Feb 2017)db/EDS/arch/V1.3/EDS20170222200001_RECREATE_CONTRACT.sql
对冲持仓表第三次重建db/EDS/arch/V1.3/EDS20170329160003_RECREATE_HEDGEPOSITION.sql
EOD 分区表db/EDS/V1.8/EDS20171107170003_CREATE_CTCONTRACTEOD.sql
PIVOT VIEWdb/EDS/code/views/v_contract.sql
EAV 属性查询代码hedging-as/.../ContractQueryHelper.java
Protobuf 合约模型hedging-as/.../contract/model/ContractProto.java
JFinal DAO 代码hedging-as/.../dao/DaoBase.java
保证金表db/EDS/arch/V1.3/EDS20170311140001_CREATE_MARGIN.sql
资金变动表db/EDS/arch/V1.3/EDS20170217100001_CREATE_CASHMOVEMENT.sql