2、领域驱动设计入门:用DDD限界上下文划分量化业务边界,避免拆分后服务间“打架”

微服务拆分最怕什么?

怕拆完以后,两个服务互相抢数据、抢逻辑,最后谁也干不好活。

我见过太多团队,一上来就按「功能模块」拆——用户一个服务、订单一个服务、风控一个服务。结果呢?用户服务里偷偷写了风控逻辑,风控服务又依赖用户服务的内部表。拆完比不拆还乱。

为什么会这样?

说白了,就是没搞清楚「边界」在哪。

今天我们就聊聊DDD里的限界上下文。这东西,就是帮你画清楚服务边界的尺子。

2.1 什么是限界上下文?

限界上下文,英文叫Bounded Context。名字挺唬人,其实意思很简单:每个领域概念,只在特定上下文里有明确含义

举个例子。「用户」这个词,在量化系统里至少有三个意思:

  • 交易执行上下文:用户 = 一个交易账户,有资金、有持仓
  • 风控上下文:用户 = 一个风险实体,有最大亏损额、有交易频率限制
  • 通知上下文:用户 = 一个联系方式,有邮箱、有手机号

你看,同一个「用户」,三个上下文里关心的东西完全不同。如果你强行用一个User对象包打天下,那服务间必然打架——交易服务要改资金,风控服务要读资金,通知服务也要读资金,最后全挤在一个表上。

核心原则:每个限界上下文内部,模型是完整的、自洽的。上下文之间,通过明确的接口通信,绝不共享内部数据。

2.2 量化系统的典型限界上下文划分

我个人习惯,在量化系统里先画出这几个限界上下文:

限界上下文 核心职责 典型实体
行情上下文 接收、清洗、存储市场数据 K线、Tick、深度快照
策略上下文 运行策略逻辑,生成交易信号 策略实例、信号、参数
交易执行上下文 对接交易所,执行下单、撤单 订单、成交、持仓
风控上下文 检查交易合规性,拦截风险 风控规则、风控事件
账户上下文 管理资金、费率、权限 账户、资金流水
通知上下文 发送告警、报表、消息 通知模板、发送记录

每个上下文,就是一个独立的微服务候选。注意,我说的是「候选」——实际拆分时,你可能把行情和策略合并,也可能把风控拆成两个。但边界先画清楚,后面再调。

2.3 如何识别限界上下文?

我常用的方法,是「事件风暴」的简化版。不需要白板、不需要贴纸,就三步:

  1. 找事件:列出系统里发生的所有「事情」。比如:行情到达、信号生成、订单成交、风控触发、资金变动。
  2. 找聚合:每个事件,是谁产生的?谁负责处理?把相关的实体和逻辑圈在一起。
  3. 画边界:如果两个聚合之间,需要频繁同步数据、或者共享同一个数据库表,那它们很可能属于同一个上下文。反之,如果只是偶尔发个消息,那就是两个上下文。

举个例子。我在项目中遇到过,策略上下文和交易执行上下文一开始被分成了两个服务。结果发现,策略每生成一个信号,交易执行就要立刻查策略的配置表。两个服务之间,每秒要交换几百次数据。后来我们把它俩合并成一个上下文,性能直接翻倍。

小技巧:如果两个服务之间的通信延迟超过100ms就会出问题,那它们大概率应该在一个上下文里。

2.4 上下文之间的通信方式

边界画好了,怎么通信?

DDD推荐两种方式:

  • 事件驱动:上下文A发布事件,上下文B订阅事件。A不关心B怎么处理,B也不依赖A的响应。适合异步、松耦合的场景。比如:行情上下文发布「新K线到达」事件,策略上下文订阅后开始计算信号。
  • API调用:上下文A直接调用上下文B的接口。适合同步、强一致性的场景。比如:交易执行上下文调用账户上下文,检查资金是否足够。

我个人建议,优先用事件驱动。为什么呢?因为量化系统里,大部分操作都是「通知式」的——行情来了、信号来了、成交来了,通知一下就行,不需要等结果。用事件驱动,服务间几乎不打架。

但有一种情况必须用API调用:强一致性要求。比如扣钱,必须同步确认成功或失败,不能发个事件就完事。

避坑指南:我曾经在一个项目里,把所有通信都改成了事件驱动。结果风控拦截订单时,因为异步延迟,订单已经发出去了。后来我加了一条规则:涉及资金变动的操作,必须用同步API。

2.5 实战:用限界上下文重构一个量化模块

假设我们有一个「策略管理」模块,原来长这样:

// 原来的代码:一个类干所有事
public class StrategyService {
    public void executeStrategy(String strategyId) {
        // 1. 查行情
        MarketData data = marketDataRepo.findLatest(strategyId);
        // 2. 算信号
        Signal signal = calculateSignal(data);
        // 3. 下订单
        orderService.placeOrder(signal);
        // 4. 记风控
        riskService.check(signal);
        // 5. 发通知
        notificationService.send(signal);
    }
}

这代码,看着就头疼。一个方法里,耦合了行情、策略、交易、风控、通知五个上下文。改一个地方,全得测。

用DDD重构后,变成这样:

// 策略上下文:只负责生成信号
public class StrategyContext {
    public Signal generateSignal(MarketData data) {
        // 只做策略计算
        return new Signal(data);
    }
}

// 交易执行上下文:只负责下单
public class ExecutionContext {
    public void execute(Signal signal) {
        // 只做下单逻辑
        orderService.placeOrder(signal);
    }
}

// 通过事件解耦
public class StrategyExecutionOrchestrator {
    @EventListener
    public void onMarketDataReceived(MarketDataEvent event) {
        Signal signal = strategyContext.generateSignal(event.getData());
        // 发布信号事件,交易执行和风控各自订阅
        eventPublisher.publish(new SignalGeneratedEvent(signal));
    }
}

你看,每个上下文只关心自己的事。行情来了,策略生成信号,然后发个事件。谁想处理这个信号,谁自己去订阅。互不干扰。

2.6 限界上下文的粒度把握

拆得太粗,服务还是大泥球。拆得太细,服务间通信成本爆炸。

怎么把握?我有个经验法则:

  • 一个上下文,应该能独立部署、独立演进。如果两个服务必须同时发布才能工作,那它们应该合并。
  • 一个上下文,内部应该有高内聚。所有实体和逻辑,都围绕同一个核心概念。比如交易执行上下文,核心概念就是「订单」和「成交」。
  • 一个上下文,对外暴露的接口应该稳定。接口变了,其他上下文不需要跟着改。

嗯,这里要注意:不要为了DDD而DDD。如果你的系统只有三个微服务,那限界上下文可能也就两三个。强行拆成十个,只会增加复杂度。

2.7 总结

限界上下文,说白了就是给每个微服务画个圈。圈里的事,自己说了算。圈外的事,通过事件或API沟通。

我见过太多微服务拆分失败的案例,根源都是边界没画清楚。两个服务抢同一个数据模型,最后只能靠「加锁」「加缓存」来勉强维持,越搞越复杂。

而DDD的限界上下文,就是帮你从一开始就把边界画清楚。虽然前期要多花点时间做领域分析,但后期省下的沟通成本、维护成本,绝对值。

一句话记住:限界上下文 = 微服务的「宪法」。宪法写清楚了,服务间才不会打架。

量化系统限界上下文划分示意图 行情上下文 K线 · Tick · 深度 接收 · 清洗 · 存储 发布:行情到达事件 策略上下文 策略实例 · 信号 计算 · 参数管理 发布:信号生成事件 交易执行上下文 订单 · 成交 · 持仓 下单 · 撤单 · 对账 发布:成交事件 风控上下文 风控规则 · 风控事件 检查 · 拦截 · 告警 订阅:信号/成交事件 账户上下文 账户 · 资金流水 资金管理 · 费率 提供:资金查询API 通知上下文 通知模板 · 发送记录 邮件 · 短信 · 推送 订阅:各类事件 事件总线(Event Bus)—— 上下文间通过事件异步通信

这张图,就是量化系统限界上下文的典型划分。每个框是一个独立上下文,框之间通过事件总线通信。注意看,没有两个框直接连数据库——这就是边界清晰的表现。


公众号:蓝海资料掘金营,微信deep3321