丰巢寄件系统基于DDD服务拆分的落地实践

声明:本文仅用于技术交流,部分内容已作脱敏。

一、背景

1.1 为什么要做服务拆分?

在探讨寄件系统的服务拆分方案之前,我们先从系统、业务和开发三个不同视角来理解服务拆分的必要性。

系统视角:服务拆分是提升系统质量的重要手段。首先,当系统面临可用性或性能瓶颈时,将业务流程拆分可以针对性地解决问题;其次,当系统内功能耦合度较高时,某个功能异常往往会影响其他功能,拆分能有效隔离故障;最后,当服务体量过大时,纵向增加资源的边际收益递减,横向拆分可以更高效地利用系统资源。

业务视角:服务拆分是支持业务增长的必然选择。首先,假设每日寄件订单量达到 1000 万,且按每年 30% 的速度增长,分而治之的策略才能满足这种规模的业务需求;其次,业务方希望快速上线新需求,服务轻量化能显著提升迭代的敏捷性。

开发视角:服务拆分是提升研发效率的关键举措。重量级服务给功能开发和代码维护带来了诸多阻碍,拆分后每个服务更轻量,也便于团队协作和知识传承。

1.2 寄件系统现状及目标

当前面临的问题:

寄件系统目前主要面临三类核心问题:

  • 高可用性挑战:服务体量庞大,核心与非核心场景耦合在同一服务中,给业务带来可用性隐患。
  • 功能耦合度高:代码改动牵一发而动全身,往往涉及多个业务场景,存在研发质量隐患。
  • 开发难度大:编译启动需要 10 分钟以上,单元测试难度大,严重影响研发效率。

从数据层面看,寄件系统有 130 多张数据表,全部放在一个数据库中,数据表与服务耦合严重。由于功能依赖于数据,要想实现功能的解耦,数据层也必须同步进行拆分。

基于以上分析,对寄件服务和数据表进行拆分已成为必然选择。

建设目标:

通过此次服务拆分,我们期望达成三个核心目标:

  • 提高寄件系统可用性,减少业务损失
  • 提升业务迭代敏捷性,降低开发工时
  • 提高研发质量,降低缺陷率

二、拆分方案

2.1 寄件流程概览

在制定拆分方案之前,我们首先梳理寄件业务的完整生命周期,如下图所示:

寄件完整流程主要分为三个关键环节:

第一步:寄件下单

第二步:投递入柜

第三步:快递员揽收

2.2 场景分析

基于上述业务流程,我们从三个维度进行场景分析:业务流(站在用户视角梳理完整流程)、能力(围绕业务流程,列举系统需要提供的能力)、事件(围绕业务流程,列举过程中产生的系统事件)。

场景一:寄件下单

场景二:投递入柜

场景三:快递员揽收

2.3 寄件领域模型

将上述三个场景的分析结果进行汇总,我们可以得到更全面的视图:

  • 绿色:业务核心(实体/业务动作)
  • 蓝色:相关能力(接口服务)
  • 红色:业务事件

基于汇总分析,我们最终得到寄件领域模型:

2.4 服务拆分

在进行服务拆分时,我们主要考虑两个核心原则:职责单一性 (每个服务只负责一个明确的业务子域)和软件包大小(控制服务的复杂度和规模)。基于这两个原则,我们将寄件系统拆分为四个独立服务:

  • 下单服务
  • 投递服务
  • 揽收服务
  • 基础数据服务(以下简称基础服务)

下单服务能力范围:

类别 (主要)接口/能力
用户 查询寄件人实名、查询寄件人登录手机号、实名认证
快递公司 查询快递公司列表
订单 下单、查询订单、查询订单详情、修改订单
支付 预锁格口支付、上门件支付、超重支付、微信支付分支付、支付回调
寄件码 生成寄件码
地址 保存地址
柜机 查看附近柜机、预锁格口、更换格口
其它场景 取消订单、用户评价

投递服务能力范围:

类别 (主要)接口/能力
寄件码 查询寄件码(查下单服务)
柜机 查询可用格口
支付 查询寄件钱包、查询优惠券、柜机支付、支付回调、柜机支付查询、退款、退款回调
运单 运力下单、物流轨迹注册、接收物流轨迹
订单 取消寄件、核验手机尾号、转单
其它场景 用户取消投递、用户柜机取消、用户取返

揽收服务能力范围:

类别 (主要)接口/能力
订单 查询待揽收列表、核验手机尾号、取消收件、接收巴枪揽收
运单 接收运费/重量
支付 KA支付推送
结算 结算
其它场景 快递员返件、快递员取消返件

基础服务能力范围:

类别 (主要)数据表
区域 省市区表、字典表
基础能力 熔断表、重试任务表

2.5 数据层拆分

服务拆分的同时,数据层也需要同步进行拆分,以实现数据与服务的解耦。

AS-IS(拆分前) :所有数据表都集中在一个数据库中

TO-BE(拆分后) :数据按服务边界进行拆分,每个服务拥有独立的数据库

三、架构设计

基于上述拆分方案,我们进一步设计完整的系统架构。

3.1 业务架构

业务架构从业务视角展现系统的整体结构:

3.2 应用架构

应用架构从技术视角展现服务的组织方式:

四、工程代码落地

有了清晰的架构设计,接下来我们来看如何在工程代码层面实现这些设计。

4.1 工程清单

每个服务都采用统一的工程结构,便于团队协作和代码维护。

css 复制代码
# 下单服务
order-service
  |- order-service-api           rpc接口定义
  |- order-service-app-server    web 模块,http接口接入层
  |- order-service-biz           biz 模块,存放核心业务逻辑
  |- order-service-dao           数据处理层
​
# 投递服务
post-service
  |- post-service-api           rpc接口定义
  |- post-service-app-server    web 模块,http接口接入层
  |- post-service-biz           biz 模块,存放核心业务逻辑
  |- post-service-dao           数据处理层
​
# 揽收服务
pick-service
  |- pick-service-api           rpc接口定义
  |- pick-service-app-server    web 模块,http接口接入层
  |- pick-service-biz           biz 模块,存放核心业务逻辑
  |- pick-service-dao           数据处理层
​
# 基础服务
basic-service
  |- basic-service-api           rpc接口定义
  |- basic-service-app-server    web 模块,http接口接入层
  |- basic-service-biz           biz 模块,存放核心业务逻辑
  |- basic-service-dao           数据处理层

每个服务内部采用统一的分层结构:

css 复制代码
{服务名}
  |- {服务名}-api                   [rpc接口定义]
      |- enums       枚举类
      |- event       消息事件
      |- facade      Dubbo接口
      |- model       接口请求响应对象
  |- {服务名}-app-server            [web 模块,http接口接入层]
      |- async       消息处理
      |- rpc         Dubbo接口实现
      |- web         Web接口实现
  |- {服务名}-biz                   [biz 模块,存放核心业务逻辑]
      |- bo          业务对象
      |- enums       枚举类
      |- exception   异常类
      |- remote      外部http远程调用
      |- service     业务实现
      |- util        工具类
  |- {服务名}-dao                   [数据处理层]
      |- entity      实体类
      |- mapper      MyBatis Mapper
      |- util        工具类

工程依赖关系:

4.2 通用功能包

为了避免重复建设,我们将各服务通用的功能抽取到公共模块中:

perl 复制代码
send-common
  |- send-common-app      应用通用包
  |- send-common-db       db模块通用包
  |- send-common-test     单元测试工具包

send-common-app 提供的特性:

  • 字典表缓存、渠道表缓存、区域表缓存
  • 参数检验过滤器(RequestValidationFilter)
  • 接口访问日志(Http/Dubbo)
  • 多数据源(MySQL/Redis)

send-common-db 提供的特性:

  • 动态切换数据源
  • 数据加解密
  • 影子库

4.3 使用示例

下面通过几个实际例子展示如何使用这些通用功能。

示例一:多数据源配置

ini 复制代码
#主库
send.main.datasource.url=jdbc:mysql://localhost:3306/send?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&useSSL=false
send.main.datasource.username=root
send.main.datasource.password=[ENCRYPT]974UEpo2O6+KF79hjq7JCu9D+6OM/hk2
#备库1
send.backup.datasource.url=jdbc:mysql://localhost:3306/send?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&useSSL=false
send.backup.datasource.username=root
send.backup.datasource.password=[ENCRYPT]974UEpo2O6+KF79hjq7JCu9D+6OM/hk2
#备库2
send.report.datasource.url=jdbc:mysql://localhost:3306/send?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&useSSL=false
send.report.datasource.username=root
send.report.datasource.password=[ENCRYPT]974UEpo2O6+KF79hjq7JCu9D+6OM/hk2
#影子库
send.shadow.datasource.url=jdbc:mysql://localhost:3306/send?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&useSSL=false
send.shadow.datasource.username=root
send.shadow.datasource.password=[ENCRYPT]974UEpo2O6+KF79hjq7JCu9D+6OM/hk2

示例二:动态切换数据源

java 复制代码
@Test
public void query_switchDataSource() {
    DictionaryEntity dictionary = SwitchDataSourceHelper.start(DynamicDataSourceType.SOURCE_SALVE_01)
            .execute(() -> dictionaryMapper.selectByPrimaryKey(2));
    Assert.assertEquals("17:00:00", dictionary.getDictValue());
}

示例三:单元测试

在进行单元测试时,我们可以通过以下方式灵活配置环境:

  • 可通过配置 spring.profiles.active=dev 来运行 dev 环境的单元测试;
  • @SpringBootTest 中的配置项可覆盖 Disconf 的配置,下面示例中切换了备库的数据源;
less 复制代码
@RunWith(SpringRunner.class)
@SpringBootTest(properties = {
        // 这里的配置项可覆盖 Disconf 的配置
        "spring.profiles.active=uat6",
        // 将备库地址换成其他实例
        "send.backup.datasource.url=jdbc:mysql://localhost:3306/send?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&useSSL=false",
        "send.backup.datasource.username=root",
        "send.backup.datasource.password=[ENCRYPT]974UEpo2O6+KF79hjq7JCu9D+6OM/hk2"
})
public class SendDictionaryServiceImplTest {
​
    @Autowired
    private FcboxSendDictionaryMapper dictionaryMapper;
​
    @Test
    public void query_switchDataSource() {
        FcboxSendDictionary dictionary = SwitchDataSourceHelper.start(DynamicDataSourceType.SOURCE_SALVE_01)
                .execute(() -> dictionaryMapper.selectByPrimaryKey(2));
        Assert.assertEquals("17:00:00", dictionary.getDictValue());
    }
}

封面

相关文章

相关推荐
小赖同学啊1 小时前
基于MCP与主流AI技术架构 水利 发电 公园中的应用
人工智能·架构
●VON2 小时前
AtomGit Flutter鸿蒙客户端:首页与仓库列表
flutter·华为·架构·harmonyos·鸿蒙
段一凡-华北理工大学2 小时前
工业领域的Hadoop架构学习~系列文章18:制造业Hadoop应用实践 - 从数据到智能的完整闭环
大数据·人工智能·hadoop·分布式·学习·架构·高炉炼铁
贵慜_Derek2 小时前
《从零实现 Agent 系统》连载 20|MCP 与 Code Execution:协议、档位与 Sidecar
人工智能·设计模式·架构
Sunia2 小时前
《AgentX 专栏》08-工作流引擎:AgentWorkflow怎么把工具记忆流程串成一条流水线
java·架构
pe7er2 小时前
AI为啥会写出if(obj != null && obj.ifEnabled)这样的代码
前端·后端·架构
zhangfeng11332 小时前
把权重写死在芯片的架构 Taalas(HC1)芯片:车载 GPU / 智能驾驶 / 机器人 / 算力卡适配总结
人工智能·深度学习·语言模型·架构·机器人·gpu算力·芯片
heimeiyingwang2 小时前
【架构实战】日志体系设计:从ELK到可观测性的演进
分布式·缓存·架构
luoganttcc3 小时前
Hopper 架构的核心变化
架构