ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

交易系统域划分实战:先理清业务事件,再定边界

交易系统域划分实战:先理清业务事件,再定边界 做交易系统的这些年我发现自己被问到最多的问题不是撮合引擎怎么优化也不是缓存和队列怎么选型而是“域划分”。很多团队一开始都以为这是画架构图的事情接入、交易、清算、账户、行情各占一个框箭头连一下齐活。可真正动手才发现画图只解决了最表面的10%剩下90%的麻烦全在边界上。这篇文章不打算给一套标准答案我更想完整梳理一下我在交易所域划分这件事上的思考路径包括划分的原则、边界怎么定、跨域数据怎么流转以及那些踩过之后才知道疼的细节。如果你正在设计一套交易系统或者手上有一套已经跑了几年的交易所架构准备拆分重构这篇文章应该能帮你避掉不少坑。我讨论的域划分方法适用于股票、期货、大宗商品、数字资产等各类电子交易系统核心逻辑是一致的先把业务事件找清楚再谈限界上下文否则后面每一步都是在还债。1. 域划分的价值与整体思路1.1 为什么非做域划分不可很多人觉得域划分是架构洁癖是“为了好看”。实际上交易系统如果不做明确的域划分麻烦是具体而且残酷的。举个例子一个下单请求从接入层进来要经过登录态校验、资金检查、风控检查、委托写入、撮合、成交回报、行情推送、仓位更新、资金冻结扣减这一整条链路牵涉到的代码可能有几十个模块。如果不划清边界任何一个改动都可能顺着业务逻辑一路蔓延最常见的现象是你只是改了一个“手续费计算”的bug结果触发了一连串的行情异常、清算不平、账户余额对不上。域划分的核心价值不是“职责清晰”这种听着很虚的话而是三件事变更可控、故障隔离、性能可优化。边界划清楚了改账户域的逻辑不会影响撮合行情域挂了不会拖垮交易主链路下单链路上哪一段慢了能一眼定位。这些都是需要真金白银来兜底的不是画图漂亮就够。我习惯用一个类比交易所系统就像一家大型超市。域不是货架而是后勤区、收银区、仓管区、客服区。每个区域有自己的作业流程跨区域的货物流转要有单据任何区域之间不能直接翻了墙操作。没有域划分的系统就像超市的员工可以随便跑到收银台改价格、跑到仓库改库存短期看效率高长期看必然乱套。1.2 划分域的第一原则业务闭环域划分最先要确立的原则不是“按技术分层”而是“按业务能力闭环”。每个域要能独立完成一块相对完整的业务内部可以有多个服务但对外暴露的边界是明确的。比如账户域要能负责用户的资产查询、冻结、解冻、扣减、入账它内部自己管数据库、自己管事务对外只提供服务接口和事件不允许别的域直接操作它的表。反过来说一个域如果被迫依赖另一个域的内部数据才能完成核心业务动作那这个域就是不完整的。这种“不完整域”在交易系统里特别常见典型的例子是订单状态更新。有些团队把订单主表放在交易域但成交回报来了之后行情域的同事直接去更新订单主表的累积成交量。结果就是同样的字段多个域在写查问题的时候谁都不承认是自己写的。所以我划分域的第一步从来不急着画架构图。我会拉出完整的事件清单用户提交委托、委托通过风控、资金冻结成功、订单进入撮合队列、订单成交、部分成交、取消委托、资金扣减、持仓更新、日终清算、对账完成……然后把每个事件归属到唯一一个“所有者域”。事件归属清楚后域和域之间的依赖关系自然就出来了。1.3 先列事件再定边界具体操作上我建议用一张“业务事件归属表”来做启动材料。这张表不需要很花哨但每一行都要有争议就说明边界有问题。业务事件事件所有者域触发方式消费方域用户提交委托接入域用户请求交易域、风控域资金预冻结完成账户域账户域内部交易域委托进入撮合队列交易域交易域内部行情域委托成交交易域撮合引擎产生账户域、行情域、清算域委托取消交易域用户/系统请求账户域、行情域日终持仓快照清算域定时任务账户域、对账中心这张表做完之后再开始定义每个域内部需要哪些表、哪些服务。划分出的域在理想状态下应该满足一个检查任何一个业务事件都能找到唯一一个负责“拍板”的域其他域只能通过事件或接口被通知。如果做不到说明域边界还需要调整。2. 交易所核心域拆解与边界2.1 接入域协议、会话与流量入口接入域是系统最外层的门面负责所有外部用户连接进来的流量。它的职责范围包括协议解析FIX、REST、WebSocket、自定义TCP、登录认证、会话管理、请求鉴权、基础频率限制和流量整形。很多人容易把接入域和交易域混在一起觉得解析协议和订单处理都是“下单流程”的一部分不到万不得已不拆开。但接入域和交易域必须分开。原因很实际接入域天然是多语言多协议的适配层协议升级、接口兼容、连接保活这些事会频繁改动交易域的核心是状态机和撮合算法最怕被这些外部噪音打扰。我见过一个系统把会话状态和订单状态放在同一套服务里结果一次连接协议升级的发布把整个交易核心带崩了。两个域分离开之后接入域的流量抖动最多影响“连不上”不会伤到撮合核心。在接入域内部还要进一步区分“有状态会话”和“无状态请求”。WebSocket这种长连接天然有状态连接归属哪个网关节点就绑定在哪个节点上REST接口是无状态的可以任意负载均衡。实际做扩容的时候这种区分特别关键无状态请求随便加节点有状态长连接需要迁移方案。不做区分高峰期想扩容都找不到入口。2.2 交易执行域撮合与全生命周期管理交易执行域是交易所的心脏负责委托的真实状态流转新委托、部分成交、全部成交、已撤销、已过期。这个域内部运行着核心撮合引擎同时管理订单簿、盘口深度和逐笔成交。我对这个域的第一个要求是纯粹。撮合引擎在执行路径上不要做任何非必要的IO不查账户余额、不调外部服务、不输入数据库判断业务规则。为什么因为撮合引擎要追求极致吞吐和低延迟核心路径上的每一项额外开销都会被放大成千上万倍。撮合引擎只做一件事基于内存中的订单簿对匹配到的买卖单进行撮合输出成交事件并更新订单状态。第二个要求是状态机绝对统一。一个委托从进入交易域开始它的所有状态变更必须发生在交易域内部由同一个状态机驱动。外部域对订单的“查询”可以走读接口但任何“修改订单状态”的入口都必须回到交易域。这里有个容易踩的坑撮合引擎把委托状态变化通过消息发给行情域行情域为了推送消息方便自己也维护了订单状态然后为了“修正”某个状态直接改了库。这种双写状态的做法几乎必然导致数据不一致。2.3 行情域快照、增量与推送策略行情域负责对外发布实时行情包括盘口快照、逐笔成交、行情 tick 的序列化与广播。相对其他域行情域对正确性要求很高但也很有意思它不一定要求每一条数据都绝对可靠但要求“趋势正确、延迟低、吞吐大”。行情域最容易犯的错是试图自己去采集原始数据。正确的关系应当是交易域在撮合产生成交之后把成交事件发布出来行情域作为消费者订阅并加工成推送格式然后通过接入域维护的会话连接推送给用户。换句话说行情域是加工和分发中心不是数据生产中心。订阅关系的维护要特别注意。行情域负责“内容”接入域负责“连接”两者之间通过路由表关联。这样设计的好处是行情域的推送节点可以横向扩展接入域的网关节点也可以横向扩展互相不锁死。我见过团队把订阅关系直接存在各自的推送节点上结果用户重连之后订阅信息丢失行情再也不推送了。正确做法是把订阅关系做成可重建的用户ID加产品ID加主题重连时按这个键恢复。2.4 账户与资金域余额模型与可用冻结账户与资金域是所有交易系统中牵一发动全身的地方也是我建议放到独立域并且权限最收紧的一块。它的核心职责是维护用户的账户余额、可用余额、冻结金额、持仓头寸以及所有资金流水明细。关于资金处理最重要的一条原则是任何资金变动必须由账户域统一记账其他域绝对不允许直接修改账户表。交易域想要冻结资金唯一的办法是发一个事件或调用接口请求账户域“尝试冻结”。账户域根据自身状态判断是否成功并把结果返回给交易域。这样看起来多了一步交互但换来了账务的绝对可信。否则账户对不上账的时候你根本不知道是撮合算错了还是扣款扣错了。账户域内部要严格区分“可用余额”和“冻结余额”。下单冻结、成交扣减、撤单解冻这三件事是资金域的基础操作。设计这个域的时候我会把所有资金流水都保存起来要求流水只增不减余额是流水累加后的投影值。这样一旦发现余额不对可以通过回放流水来定位而不是靠人工去翻修改记录。2.5 风控域前置拦截与实时监控风控域很容易被设计成一个“边缘域”挂在旁路做个异步检查不参与主交易链路。我觉得这是交易系统里最危险的设计。要考虑清楚风控有两种工作方式一种是事前校验比如用户是否在黑名单、委托数量是否超过限额、价格是否超出涨跌幅这种必须前置在主链路里面拦在委托进入撮合之前另一种是事中监控比如交易频率异常、疑似异常交易行为这种可以走异步实时分析。所以实际划分的时候我倾向于把风控域拆成两层前置风控服务和旁路风控服务。前置风控服务嵌入在下单链路中接收交易域发来的“订单预检请求”返回放行或者拦截旁路服务订阅全量业务事件做流式分析发现异常后给运营端推送告警或触发熔断。这里要特别强调的是风控检查结果必须包含一个全局唯一的检查批次号这样风控域才能和交易域对账。有些系统风控域只返回“通过/拒绝”两个布尔值出了问题根本说不清是哪次检查放行的。2.6 清算对账域TN、账务调整与日终任务清算对账域是交易系统里最容易被低估的域。很多人觉得它不过就是跑批任务等日终把一天的成交数据汇总一下。但真要拉出来看清算干的事远比想象中多计算手续费、计算印花税或平台手续费、更新用户持仓成本、生成资金流水、生成结算单、进行账户间划转。清算域的业务特点是“批量、准确、可复盘”。它通常是异步执行的不要求与撮合同步实时但要求最终结果绝对正确。所以这个域的核心设计应该是“以账务流水为准以事件为起点”而不是直接把交易域的数据库拿过来改。我队里有个改不掉的强约束清算域必须有独立的数据存储不能和交易域共用同一个库。因为交易域的数据是“过程态”的成交一条算一条中间可能有取消有修正清算域的数据是“终态”的一旦生成了结算记录就是不可变的账务档案。两者语义不同混在一起日终跑批的时候很容易产生脏读和锁竞争。3. 域间协作与数据归属3.1 数据只有一个所有者域划分过程中最耗精力的不是画出哪些域而是想清楚每一份数据到底属于谁。因为交易系统是重数据的系统任何一份数据被多个域同时直接修改都会成为定时炸弹。我自己的经验法则是“谁产生谁负责谁唯一可写。”例如订单领域模型归交易域所有账户余额归账户域所有行情快照归行情域所有。其他域如果需要这份数据通过查询接口获取或者通过订阅事件在本地缓存一个副本。注意是副本副本不允许被跨域回写。实际操作中我用一个很土但有效的检查方法拉出数据库里的表清单给每一张表标注所有者域。如果发现一张表上面有两个域都标注了“可写”这意味着要么这块业务需要重新划域要么应该改成事件驱动方式让一个域拥有最终决定权。所谓“两个域共管一张表”在交易系统中几乎都会演变成“两个域互相甩锅”。3.2 跨域调用同步API只查数据异步消息管业务在明确了数据归属之后接下来要决定域与域之间的通信方式。交易系统里跨域通信大体上有两种同步调用和异步消息。我对两者的使用边界有自己的经验同步接口用于查询状态、获取数据异步事件用于传递业务动作和结果。以“下单”为例。用户请求到接入域之后同步调用交易域的预检查接口做基础校验同时异步发送一个“新委托请求”到消息队列交易域从队列拿到请求后通过同步接口请求账户域做资金冻结预检查账户域返回冻结结果交易域再把委托放入撮合队列。同步和异步可以嵌套但核心准则是凡是需要立刻拿到结果来决策的步骤用同步接口凡是“发出之后系统自己会继续推进”的步骤用异步消息。我在实际中见过很多团队迷信“全异步化”连资金冻结都异步导致用户下单后订单进入了“冻结中”状态还要等回调。这种设计在极端情况下会拉长下单路径而且让状态复杂度爆炸。同步还是异步取决于业务步骤的强依赖关系不要为了架构上的“整洁”牺牲掉清晰的流程。3.3 跨域事务与最终一致性交易系统天然是资金强一致的场景但我并不主张用强事务把多个域绑在一起。因为撮合是高并发写路径账户也是一天几百万笔流水如果所有跨域操作都被塞进一个大事务系统性能会被拖垮。更务实的方式是“每个域各自保证本地事务跨域事件通过消息推动最终一致性靠对账兜底”。比如“下单冻结资金”这个动作交易域生成了委托记录这是一个本地事务账户域收到冻结请求后执行冻结这是另一个本地事务。两个事务之间可能出现短暂的不一致窗口委托已生成但冻结还没完成。只要设置了超时和补偿逻辑系统最终都会进入一致状态。最怕的状态是“永远卡在中间”。所以我在所有跨域流程里都要求必须有状态机超时机制和补偿动作。最终一致性设计听上去好像有点“赌运气”实际上不是。只要每个事件都有唯一事件ID每个消费者都按ID做幂等处理再叠加日终对账一致性就是可以被验证、被修复的。3.4 对账是域划分的兜底不是补丁我见过很多团队把对账当作“出了问题再跑一下的检查工具”实际上对账在整个域划分架构里应该是高高在上的兜底机制。交易系统里再严格的架构设计也可能出现消息丢失、延迟、双写冲突这些不是架构上能完全消除的只能靠对账发现和修复。对账分为两个层级。第一层是事件对账检查消息有没有丢失、有没有重复、有没有乱序第二层是账务对账检查余额、冻结、成交、持仓和手续费这些财务指标是否平衡。日终对账跑完如果发现不平我要能够顺着“事件ID 流水号”全链路追踪回去。做不到这一点说明域边界内没有建立可审计的追溯机制架构上是有大洞的。4. 实操中的关键抉择与落地细节4.1 订单号到底谁生成订单号的生成看起来是个小事但在域划分中非常能说明问题。订单号作为业务主键必须能够唯一定位到一条委托。问题是它应该由接入域生成、交易域生成还是独立ID服务生成我最终选择的是交易域统一生成。理由很简单订单号要和订单状态、撮合结果、成交事件绑定在一起只有交易域能保证生成即有效。接入域可以先拿到用户请求生成一个“客户端订单号”或“流水号”但它不能取代交易域的单号。在我之前负责的系统里接入域为了让用户体验更好提前给用户返回了订单ID结果这笔委托后来因为风控拦截没有进入撮合日志里用户拿着单号来查查到的却是“订单不存在”这种低级问题的根源就是单号归属不对。设计上我建议接入域维护一个client_request_id用于返回给用户做业务追踪交易域维护一个exchange_order_id用于内部全链路事件关联。两个ID之间通过映射表关联对外展示用exchange_order_id排查日志时用client_request_id倒查。这个双ID模型虽然多了一步映射但让接入域和交易域都保持了自己的边界。4.2 行情推送到底该谁推行情推送的边界问题是我看到争论最多的地方。要不要把行情推送能力拆到独立域还是让接入域顺便把行情也推了我的建议是行情域和接入域分离但推送动作由接入域执行。具体流程是交易域产生成交事件 → 行情域接收事件并生成快照/增量数据写入内存消息队列或共享内存 → 接入域网关消费并推送到用户的长连接。这样的核心是“内容归行情域连接归接入域”。接入域知道自己维护了哪些会话连接行情域知道自己应该生产什么格式的数据两者通过主题订阅解耦。上线时还要考虑二房东问题同一个用户挂了多个设备多个连接那推送的时候要按用户去重还是按连接去重一旦行情域把“用户设备集合”的概念引入它的边界就膨胀了。更好的做法是由接入域管理连接集合行情域只负责“按主题推送”这样域间协作最干净。4.3 撮合结果的落地顺序先库后消息撮合引擎每次撮合会产出两类结果一是订单状态的变化二是成交记录的生成。这两类数据都应该先以本地事务方式持久化再发布消息给下游。最怕的是反着来先发消息通知行情域和账户域再写数据库。一旦写库失败下游已经把成交处理了系统立刻陷入不一致。正确的落地顺序是事务内写入订单状态变更表和成交记录表事务提交成功后以同一个事务产生的业务ID构造事件发布到消息队列。注意这里的发布动作不能在事务内同步完成因为消息中间件不一定和数据库形成分布式事务。更稳妥的做法是“发件箱Outbox”模式事务里写业务数据和事件记录到同一张表事务提交后由后台任务把事件记录投递到MQ。这个模式看起来多了一张表但它保证了“库里有的消息里也有消息里有的库里一定有”。4.4 资金冻结应该设计在账户域很多系统为了性能把资金冻结动作直接写到交易域里理由是“反正交易域的撮合引擎已经在内存里了余额也在内存顺手就扣了”。短期看性能确实快长期看是灾难。我在业务复盘时发现只要资金相关的逻辑一进交易域就会不可避免地出现重复扣减、冻结金额和可用金额对不平、保证金不足判定错误等问题。原因不是程序员不够细心而是交易域的开发思维是“算得快”账户域的思维是“记准账”。这两种思维放到一个域里一定会冲突。所以我设计上会坚持交易域向账户域发起“冻结请求”账户域独立完成判断和冻结并返回结果。哪怕这意味着一次RTT延迟增加这笔成本也远低于资金出错的代价。如果实在追求低延迟可以在交易域缓存一份账户余额快照做预校验但真正的账面冻结一定回账户域完成。5. 常见问题与排查实录5.1 风控没划进主链路导致大额漏单这个问题我们在线上升级时真实遇到过。当时的架构里风控更像是一个异步分析系统它跑在旁路实时吃着行情和订单流做着异常检测。表面看起来能力很强但当用户提交一笔巨量委托时风控的检查结果还在路上委托已经被撮合成交了。后果相当麻烦超大单直接冲击盘口最后只能人工干预并回滚交易。教训就是要把“风控决策点”显式放进主链路。之后的调整中我在交易域里增加了一个前置步骤订单在进入撮合引擎之前先通过同步接口触发风控检查拿到一个明确的结果码再继续。旁路风控仍然保留但它的定位从“唯一防线”变成了“增强型监控”。以后你们任何系统设计先把风控定位搞明白再谈别的。5.2 对账不平大概率是“数据归属不清”如果你是做交易系统的肯定经历过那种半夜被叫起来看对账不平的事。我处理这类问题的经验是先看是不是“同一份数据被两个域修改过”导致的。最容易出事的字段有四个可用余额、冻结金额、累计成交数量、持仓数量。我整理过一个简化的排查表对账项可能原因排查方向可用余额不平多个域扣款/入账不同步检查资金流水确认钱的写入方冻结金额不平冻结与解冻动作缺失或重复回放冻结事件定位二次解冻或漏冻结累计成交不平行情域/清算域自行更新了订单成交字段检查谁在写订单表持仓数量不平清算域和账户域各自维护持仓副本确认持仓的唯一写入方处理的办法不是研发出神奇的自动修复而是“归属明确 单向数据流 对账任务”。定位到所有者之后修改相关域的代码把“跨域直接改表”的地方全部改成事件驱动或接口调用。5.3 消息乱序与重复消费这样处理跨域消息在分布式环境下面临两个顽疾乱序和重复。乱序的典型场景是用户先撤单又立即重新下单撤单成功的消息反而比新委托的消息后到。重复的典型场景是消息发送超时后重发消费端收到了两条一样的成交事件。处理乱序我一般用“版本号分区键”的组合拳。所有订单相关消息都带上订单号和事件序列号消费者本地维护当前处理的订单状态和最大序列号只接受能推进状态机的消息旧消息直接丢弃。消息队列的topic或分区键也按订单号做哈希保证同一订单的消息尽量有序到达。处理重复则只有一个笨办法幂等。消费端在本地数据库里记录每个event_id的处理状态处理前先查重。重复消费不可怕可怕的是重复扣款、重复增加余额。所以我要求所有资金类事件消费者都必须带上业务幂等键让重复事件在账务层面被识别并跳过去。5.4 性能瓶颈跨域调用拖垮了下单链路域划分划分得很干净之后一度出现性能问题一次下单请求要从接入域同步调用交易域交易域又同步调用账户域和风控域链路总耗时从原来的18毫秒干到了120毫秒。看起来慢得离谱。后面定位发现真正的问题不是域划分本身而是把很多非关键的操作都放进了同步调用链。比如账户域每次冻结之前都去查一遍用户最近1000笔流水做风控判断这个查询哪怕是索引很好也有几十毫秒。我们做了分层处理资金域在核心链路上只做“余额减去冻结”这个判定更复杂的资金风控挪到旁路异步执行下单链路只保留强依赖的同步调用其余全部改为事件驱动。优化之后下单链路又回到了20毫秒左右。这事给我的体会是域划分定义了边界但边界之内也不是什么东西都往主链路上放。冷热路径要区分清楚热路径上只放必须的同步检查和状态变更其余都往后放。域划分解决的是“谁的责任”问题性能优化解决的是“什么放在热路径上”的问题两者要结合起来看。交易系统里的域划分说到底是把一套复杂的业务拆成多个可以独立演进、独立维护、独立故障的子系统。我的习惯是每半年做一次边界复盘因为业务规则会变事件清单会变原来的域边界未必还适应。在最近一次重构中我把所有散落在代码注释里的隐式约定全部收拢到领域事件定义里效果比画一百张架构图都管用。如果你正在做类似的事情我强烈建议你也先做同一件事把事件归属理清楚再谈其他。
返回列表