交易所-域划分的一些思考
做交易所系统的,迟早会遇到“域划分”这个问题。尤其是当你的系统从单机Demo演进到多机房、多集群、多团队协作的时候,域划分就不再只是代码目录怎么摆的问题,而是关系到整个系统的扩展性、可维护性、甚至合规性的顶层设计。我前阵子刚把一个老项目从早期的“一锅烩”重构成了按域拆分的架构,踩了不少坑,也梳理出了一些清晰的思路,今天就把这部分思考完整地分享出来。
1. 域划分的本质:从“功能模块”到“业务边界”
1.1 为什么“按功能拆包”不够用
很多团队一开始做交易系统,最喜欢也是最自然的做法,就是按技术功能来分模块,比如通用的工具类、数据访问层、Web控制器、消息队列消费者,这种分法在项目初期完全没问题,代码量小、几个人改起来也不冲突。
但一旦系统开始承载真实的交易逻辑——K线、深度、订单、撮合、清算、风控、行情推送,模块之间的依赖就会变得极其混乱。你可能遇到一个最典型的情况:行情模块的代码里直接写了订单模块的数据库表,或者撮合引擎为了计算风控,绕过接口直接调用对方内部服务。按技术分层划分的目录,本质上没有形成真正的隔离,只是“分文件夹”,不是“划边界”。
我推动域划分的第一个理由就是:技术分层解决不了业务膨胀,域边界才能。
1.2 交易域划分的核心目标
域划分要解决的不只是“代码放哪儿”,而是下面这几个具体问题:
- 依赖方向清晰:域与域之间只能通过明确的接口交互,不能直接穿透内部实现。
- 团队归属明确:一个域由固定团队长期负责,代码评审、变更通知都跟着域走。
- 故障隔离可控:一个域出问题(比如行情源卡顿),不能因为耦合拖垮整个系统。
- 演进独立可行:一个域可以单独做技术选型、数据库选型、扩容策略。
- 权限边界合规:交易、结算、运营等不同数据范围,在域边界上就能控制访问权限。
这些目标听起来都是常识,但真正落地的时候,最大的阻力往往不是技术,而是历史包袱——老代码已经耦合到没法看,甚至一个事务里既写订单又写结算,想要拆开只能靠经验慢慢来。
1.3 交易域划分的行业惯例
不同交易所的域划分细节千差万别,但大的方向基本一致。我梳理一下常见的顶层划分,非常具有参考意义:
| 域 | 核心职能 | 典型子域 |
|---|---|---|
| 行情域 | 管理交易标的的价格、K线、tick、深度、市场统计 | 数据接入、行情计算、行情推送、快照管理 |
| 交易域 | 用户下单、撤单、订单生命周期管理、撮合前状态维护 | 订单管理、提交网关、风控前置、活动/优惠 |
| 撮合域 | 订单匹配、价格发现、交易生成、盘口维护 | 匹配引擎、交易生成、内部队列、审计日志 |
| 结算域 | 交易生成后的清算与结算、账户余额变动 | 清结算、账务处理、费用计算、对账 |
| 资产域 | 用户各类资产(余额、持仓、冻结、充提记录) | 资产账户、充值/提现、额度管理 |
| 用户域 | 用户身份与登录状态、KYC合规、权限 | 用户注册、登录、KYC、OTA(账户权限) |
| 运营域 | 公告、活动配置、币种上市/退市、市场管理 | 公告中心、活动运营、币种管理 |
| 风控域 | 实时限制、大额监控、洗钱监测、交易行为风控 | 风控规则、限额、监控、人工审核 |
| 推送域 | 实时推送用户订单状态、行情变化、公告触达 | WebSocket推送、App推送、站内信 |
这些域之间会有依赖,比如交易域依赖行情域的快照来展示,依赖资产域来校验持仓,但依赖方式应该通过接口,而不是直接去查对方的数据表。
2. 核心细节解析:域划分的关键原则与取舍
2.1 按“业务对象”而非“业务流程”划域
最容易犯的错,是按一条业务链路去划域。比如用户下单这个流程,从用户请求、鉴权、查持仓、检查价格,到写入订单、推送给撮合,再把成交回传给用户,这个流程非常长。如果给它建一个“下单流程域”,你会发现后面撤单、改单、查询也都从这条链路里东拉西扯,怎么都归不进这个域,最终域变成了一个“伪域”——其实还是横跨多个系统的流程编排。
我建议按“业务对象”来划域:每个域围绕一组成熟稳定的业务对象展开(订单、资产、行情、用户),而跨域的流程(如“下单”)只是多个域通过接口合作完成的用例场景,不属于某一个域本身。这样设计出来的域边界更稳定,不会天天变。
2.2 双向依赖是毒药,单向依赖是框架
域划分之后要立刻检查依赖。规则很简单:不允许双向依赖,A域不能反过来依赖B域的内部类、内部表。遇到双向依赖只有两个出路:就一个域合并它们,或者把公共的部分下沉为一个更小的子域,然后两者都依赖这个子域。
举个实际例子:交易域需要“当前最新价”来做止损单判断,行情域需要“最近成交”来做指标计算。如果两边各自引用对方,循环依赖就产生了。我们的做法是把“市场交易对共识数据”(交易对基础信息、价格精度、数量精度)下沉成“标的域”或者“基础数据域”,这样两个域各自依赖基础数据域,问题就结束了,价格数据通过消息推送解耦,而不是强行拉引用。
2.3 数据域内自治,域间只走接口
这也是我这次重构中最大的一个调整——域内的数据表,只能由该域的代码写入和读取;其他域需要数据,必须通过该域提供的接口/服务/API。如果其他域为了省事直接查询你的表,哪怕只是只读,一旦表结构变化,对方也会崩,这种“数据耦合”比代码耦合更隐蔽也更危险。
我在排查老代码的时候,发现运营后台为了导数据,直接连了交易库的只读实例查询订单表,导致后来交易库加字段、分表,运营后台的SQL全部报错,排查了很久才发现。这是个血的教训。
正确的做法是:如果运营域需要导订单数据,交易域提供一个导出接口,带上筛选条件和分页参数,由交易域自己查自己库里的数据,再统一返回。这样至少有以下几个好处:接口稳定、权限可控、可以加审计、可以限制导出量,防止一把梭把库拖垮。
2.4 域的大小与自治程度需要平衡
域划得越细,自治性越强,但跨域通信的复杂度、团队的沟通成本、系统部署的粒度也会越高。在我做重构的时候,团队内一直争论要不要把“清结算”从“交易域”再拆出去。我的意见是:如果清结算的核心职责和交易域高度交织,但团队还没准备好独立运维一套系统,就先合在一个域里,但代码目录上做好边界划分,等稳定了再拆。
说实话,域划分不是越细越好。一个最基本的原则是:每个域至少要有明确的所有者(一个团队或一个人),并有完整的业务闭环能力。如果一个域拆出来之后,做任何一个功能都要跨三个域协作,那说明拆错了,应该合并。
3. 实操过程:从现状梳理到域边界落地
3.1 现状梳理:画清“现在到底有什么”
我在启动这次重构时,第一步不是写新代码,而是“考古”。把现有代码全量扫了一遍,画出所有的服务、数据库、接口、消息队列之间的依赖关系。这一步非常枯燥,但也是最重要的——如果你连现状的依赖都理不清,盲人摸象式的重构会把问题搞得更糟。
工具上我用的是:读代码逻辑 + 数据库元数据分析(哪些表被哪些服务查过)+ 接口调用链日志抽样。这些东西汇总完,画一张大图,把每条互相引用的线标出来。结果非常灾难——光订单相关的依赖就有几百条线,很多是“查询对方私有表”这种级穿透。
3.2 领域建模:用事件风暴找出核心域
领域建模我没有选特别重的方法论,而是用了一个比较轻量但有效的方式——“事件风暴”。找上产品和核心研发,把业务核心流程(下单、成交、撤单、充值、体现、结算)一帧一帧过,每个步骤标上:触发事件、产生命令、对应的聚合对象、最终产生的事件。这个过程非常能暴露业务理解的偏差,也是让团队达成一致的好途径。
所谓“聚合对象”,你可以简单地理解为一组在业务上必须保持一致的实体集合。比如订单与其订单详情、交易记录,这些必须在一个事务里更新,就放一个聚合里,在交易域内自治。而资产账户与持仓虽然是资产域内的对象,但和订单有交互,就通过“冻结、解冻、扣减”接口完成,而不是直接改表。
用事件风暴梳理下来之后,核心领域名单就水落石出了:订单域、资产域、行情域、结算域、用户域、运营工具域。每一个域都有了明确的业务边界和事件语义。
3.3 依赖改造:按“由内到外”的顺序切分
定完域之后,真正动手切分依赖,我是按“基础域 → 核心交易域 → 边缘辅助域”的顺序推进的。为什么这个顺序重要?因为核心交易域(订单、撮合)依赖的东西最多,如果不先把基础域(用户、行情、币种)稳定下来,后面一步三摇根本没法推进。
基础域改造的实操思路:
- 用户域:将用户基本信息、认证状态、权限角色的所有数据访问收拢到一个用户服务,提供统一的“查询、注册、鉴权”接口,其他域一律走接口。
- 行情域:将行情接入、K线合成、深度管理收拢到一个行情服务,外部只通过网关消费,不允许直接修改行情数据表。
- 币种/交易对域:新增一个轻量的基础数据服务,管理交易对、币种精度、合约参数,所有域需要这些基础数据时调一次接口并本地缓存。
顺序定好之后,每个域改造的流程都是:先把内部代码按子模块整理 → 对外开放接口 → 收拢数据访问 → 修改其他域的调用方 → 关闭对外数据库读权限。
3.4 数据库拆分:物理分库与逻辑分域
数据库层面,我们推荐“先逻辑分域,后物理分库”的渐进式方案。老系统最开始是一个核心大库,里面订单表、行情表、用户表全混在一起,改起来风险极大。我们先在代码里强制每个域只能访问自己的数据源配置,用读写分离代理把同一物理库的不同表集合隔离成多个“逻辑库”;等运行稳定后,再把核心域的表物理迁移到独立实例。
物理迁移这一步的关键点是:**迁移过程中要保证双写或者平滑切换,不能直接把表搬走,否则查询全挂。**我们用的流程是:
- 用工具做全量数据复制,并持续同步增量变更。
- 新逻辑库存开启只读验证,同时跑一段时间的规则校验(比如订单数和金额逐日对账)。
- 切换写入流量到新库,并保留一段时间的旧库只读,观察日志与告警。
- 确认稳定后,彻底下线旧库的读写,清理历史依赖。
这个过程中的痛苦是,一旦你发现某些查询“必须跨库JOIN”,就说明之前的域边界划分有问题,需要拆接口或者做数据冗余。这个过程是正常的,不要怕返工。
3.5 技术选型:域与域可以不同,但不宜过多
域拆分后,可能会有人提议:撮合域要用C++提升性能,资产域用Java写容易,行情域要上Go写高并发。原则上这没问题,但我不建议一个中小团队立刻开启多语言多技术栈模式。域划分的好处之一是允许独立演进,不代表所有域都要立刻异构。技术栈切换都是有代价的,统一的技术栈维护与招聘成本最低。
我们的选择是:核心交易、资产、结算域保持Java技术栈,Web框架轻量化,数据存储按需选用MySQL、Redis、Kafka;行情推送单独用Go写了一套基于WebSocket的推送服务,因为这块的并发模型更吃连接数,Go确实更好使。多语言从第二年后才引入,且每个新语言只承担单一职责。
4. 域接口设计的核心规范:对外稳定,对内灵活
4.1 接口协议:统一网关,避免点对点
域划分后,域之间的交互方式需要统一。我们采用的是RESTful + JSON作为主接口协议,内部异步场景用Kafka消息,实时性极高的场景再用短连接TCP(比如撮合结果回传)或长连接推送。强烈建议不要允许各个域之间直接使用对方内部RPC框架的私有协议,不然排查问题时,找依赖关系会想死。
接口设计上的几个原则:
- 每个接口必须有明确的语义,如“冻结资产”“查询订单快照”“推送成交回报”,绝不允许出现“万能查询接口”。
- 接口版本号从一开始就要带上,路径里带版本(如/api/v1/orders),否则以后做升级兼容就是一场灾难。
- 域间接口必须做限流与熔断,不能让一个下游慢查询把上游拖死。
4.2 事件的语义化:域与域通过“事件”解耦
除了接口请求/响应这种同步交互,域间还有大量异步事件:订单已创建、成交已发生、资产已冻结、行情已更新、结算已完成。这些事件非常有价值,因为它们天然服务于解耦。
但事件命名上要有规范。我遇到的常见问题就是事件名不规范,比如“OrderCreated”和“order_created”混用,语义模糊,导致消费者订阅判断很痛苦。我们的规范是:事件名采用“领域.对象.动作”的格式,比如“trade.order.created”,消息体里带上事件ID、时间戳、生产者、幂等键,便于对账和重试。
事件消息的版本兼容也是个大坑。消费者升级慢、生产者升级快,你的事件体一改字段,老消费者直接反序列化失败。我们用Protobuf序列化,并且只增字段不删字段,老版本消费者遇到未知字段直接忽略,这样降低了升级摩擦。
4.3 数据冗余与最终一致性
分域之后,“跨域查数据”是不被允许的,但业务场景又确实需要。最典型的有:成交记录表(交易域内)要展示下单用户的手机号(用户域),订单页面要显示价格精度(基础数据域)。如果每展示一个页面都跨3个域同步调接口,性能会崩掉。
我们的解决办法是“数据冗余+最终一致更新”:在需要展示的域内冗余一份对方域的基础数据快照(比如用户手机号、交易对名称),通过事件监听更新。查询走本地库,更新由事件异步推送给订阅方。这种方式牺牲了实时一致性,但换来了高可用和低延迟,用户体验完全感知不到差异。
唯一要特别注意的点就是冗余数据口径要对齐,比如“用户手机号脱敏后”存还是明文存,必须统一规范,否则会出现同一个用户在订单域和用户域看到的号码不一样,直接投诉。
5. 交易域划分下来之后踩过的坑:常见问题与排查思路
5.1 跨域事务:本地消息表 + 消息事务
域划分最直接的痛点就是跨域事务。以前在一个库里面,一个事务里把订单写进去了,同时扣了资产,现在域拆开后,一个操作跨两个库,没法保证原子性。
我的经验是:**能用最终一致性解决的,绝对不要硬上分布式事务。**比如“下单冻结资产”这个操作,正确流程是:交易域先写订单状态为“待冻结”,同时发一个“冻结请求”事件;资产域消费事件,扣减并冻结资产;资产域再发送“资产已冻结”事件;交易域收到后,将订单状态更新为“已冻结”。如果资产冻结失败,交易域会收到“冻结失败”事件,把订单标记为失败。这个流程全是异步事件驱动的,虽然状态多了些,但工程上非常稳定。
本地消息表的思路是:在每个需要发事件的域内,建一张“事件发送表”,本地事务里同时写业务数据和事件记录,由后台任务扫表发布到消息队列。这个做法比直接“事务内发消息”可靠得多,因为事务提交失败时消息不会发出,成功时消息不会丢。
5.2 幂等:交易系统一切重试的前提
分域后为了追求可靠性,消息重试是必然的,于是幂等就成了交易系统的“命根子”。几乎所有接口,只要可能重试,都必须支持幂等。比如订单创建接口要支持客户端幂等键,资产冻结接口要按冻结单号幂等,成交回报消息必须按交易ID去重。
幂等实现我推荐两种方式组合:数据库唯一约束(幂等键建唯一索引,冲突时报主键冲突)+ Redis SETNX(短时间窗口去重)。对于消息消费者,最简单可靠的做法是在本地建一张“消息消费记录表”,消息ID做唯一键,消费前先插入,插入成功才执行真正的业务逻辑,执行失败删除记录以便重试。这套幂等机制是在服务端做的,不要指望客户端每次都传对了,还是要去重校验。
5.3 历史数据迁移:表结构统一是关键
拆分数据库、按域划分后,历史数据的迁移是一个逃不掉的硬骨头。
我总结了几条经验:
- 迁移前先清理脏数据,否则很多规则对不平(比如既有金额精度不统一,导致对账差几分钱)。
- 写一个可反复执行的校验脚本,迁移过程多跑几轮对账,有问题及时回滚。
- 迁移脚本要留好执行日志,每一次执行的SQL都记录在案,方便回查。
- 迁移尽量在流量低谷执行,并做好一键回滚方案。我见过没做回滚方案就直接迁移,出了问题只能靠人工修复,那几天晚上都会失眠。
5.4 灰度发布与依赖升级
改接口、切数据源、旧系统下线,任何一个环节都可能影响线上用户。所以我们总体上遵循“宁可慢,不可错”的节奏:A域接口先灰度,观察旧调用方与新调用方是否有差异;切换数据源先对账,对不上就停止;下线旧服务先监控一周,无报错再下线。
这里要特别说一下:如果两个域分别归属不同团队,接口升级务必提前协商好排期,不要“突然改合同”。后端接口一变,前端页面、App、第三方客户端都会受影响,线上问题大多是这种“静默升级”引起的。每改一次接口,我都在发布群里同步“接口变更影响范围”,让下游团队自己确认依赖。
5.5 性能损耗:跨域调用多了,怎么顶住
不骗人,域拆分后,单次业务的跨域调用次数确实变多了。比如简简单单打开一个订单详情页,可能要聚合查询订单域、用户域、资产域、币种域的数据。如果每个都实时调接口,性能立刻崩。
我们的应对手段是:
- 数据冗余,尽量让查询以本地为主(前文已说)。
- 本地缓存(Caffeine)+分布式缓存(Redis)多级缓存缓解高频查询。
- 对外查询接口做结果合并网关,一次请求批量化获取多个域数据,减少网络RTT。
- 对极低频的数据(比如用户手机号),可以用异步预热缓存,避免页面卡顿。
实测下来,通过这套组合,域拆分后查询接口的P99不但没有恶化,反而因为缓存命中率高,比原来高并发时还要稳一些。
5.6 从研发配合到组织架构:人和代码要对齐
最后提醒一个容易被忽视的点:**域划分最终要跟团队职责对齐,否则代码划好了,但人还是一锅端,什么域都改,照样乱。**我们重构后,按域分配了Owner,每个域Owner对代码质量、线上稳定性、接口兼容负责。跨域改动需要先提设计稿,在评审会上说明影响范围,再进排期。
一个感触是:域划分不是只有技术架构师的事,它是组织结构、协作方式、变更流程的整体重塑。如果只做代码重组而不治理团队协作,过半年你会看到新代码又变成了“伪域”——表面分目录,内部依赖全部穿来穿去。
6. 一些心里话:域划分不是一劳永逸的银弹
做了这一轮域划分重构,我最大的感受是:域划分不是终点,只是起点。它划定的是“系统如何被理解、被变更、被扩展”的框架,但框架不会自动帮你解决所有问题——比如撮合性能优化、风控策略升级、复杂活动逻辑,这些还是得在各自的域内一点点磨。
另外,域划分的节奏一定要和团队成熟度匹配。如果团队对某个业务域的理解还不充分,硬拆反而会增加协调成本。我们当时有一个子域(运营后台)一开始拆得很细,后发现运营需求频繁且复杂,跨域协作成本居高不下,最后又合并成了一个后台域。所以我不建议一步到位拆到极致,最好是“边拆边稳,稳了再拆”。
再分享一个小技巧:代码仓库的目录结构要跟域划分严格一一对应,尽量避免“域A的代码混在域B的模块里”,这个从根目录就做得清晰,后期新人上手才快。我在新项目里直接采用了模块化多仓库结构(每个域一个独立仓库),依赖通过发布版本号管理,效果明显更好,但要求团队的CI/CD能力和组件仓库能力跟得上。
最后,不管你的域划分方案经过多少评审、用了多漂亮的理论模型,最终的检验标准只有一个:**线上出问题时,你是不是能在半小时内定位到影响域、责任人以及回滚方案。**如果每次故障你都要翻半天代码才能理清是谁的锅,那你们的域划分肯定还有很大的优化空间。希望你做域划分的时候,多想想“出问题怎么排障”这个视角,而不是只盯着“怎么画图好看”。
把域划分的功夫花在平时,交易系统才能在大促和极端行情下跑得稳、扛得住。