DDD技术方案落地实践 | 京东云技术团队-CSDN博客

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

1. 引言

从接触领域驱动设计的初学阶段到实现一个旧系统改造到DDD模型再到按DDD规范落地的3个的项目。对于领域驱动模型设计研发从开始的各种疑惑到吸收各种先进的理念目前在技术实施这一块已经基本比较成熟。在既往经验中总结了一些在开发中遇到的技术问题和解决方案进行分享。

因为DDD的建模理论及方法论有比较成熟的教程如《领域驱动设计》这里我对DDD的理论部分只做简要回顾如果需要了解DDD建模和基础的理论知识请移步相关书籍进行学习。本文主要针对我们团队在DDD落地实践中的一些技术点进行分享。

2. 理论回顾

理论部分只做部分提要关于DDD建模及基础知识相关可参考 Eric Evans 的《领域驱动设计》一书及其它理论书籍这里只做部分内容摘抄。

2.1.1 名词

领域及划分领域、子域、核心域、通用域、支撑域限界上下文

模型聚合、聚合根、实体、值对象;

实体

是指描述了领域中唯一的且可持续变化的抽象模型有ID标识有生命周期有状态用值对象来描述状态实体通过ID进行区分

每个实体对象都有唯一的 ID。我们可以对一个实体对象进行多次修改修改后的数据和原来的数据可能会大不相同。比如商品是商品上下文的一个实体通过唯一的商品 ID 来标识不管这个商品的数据如何变化商品的 ID 一直保持不变它始终是同一个商品。

在 DDD 里这些实体类通常采用充血模型与这个实体相关的所有业务逻辑都在实体类的方法中实现。

聚合根

聚合根是实体是一个根实体聚合根的ID全局唯一标识聚合根下面的实体的ID在聚合根内唯一即可

聚合根是聚合还原和保存的唯一入口聚合的还原应该保证完整性即整存整取

聚合设计的原则

  1. 聚合是用来封装真正的不变性而不是简单的将对象组合在一起

  2. 聚合应尽量设计的小主要因为业务决定聚合业务改变聚合尽可能小的拆分可以避免重构重新拆分

  3. 聚合之间的关联通过ID而不是对象引用

  4. 聚合内强一致性聚合之间最终一致性

值对象

值对象的核心本质是值与是否有复杂类型无关值对象没有生命周期通过两个值对象的值是否相同区分是否是同一个值对象

值对象应该设计为只读模式 如果任一属性发生变化应该重新构建一个新的值对象而不是改变原来值对象的属性

领域事件

在事件风暴过程中会识别出命令、业务操作、实体等此外还有事件。比如当业务人员的描述中出现类似“当完成…后则…”“当发生…时则…”等模式时往往可将其用领域事件来实现。领域事件表示在领域中发生的事件它会导致进一步的业务操作。如电商中支付完成后触发的事件会导致生成订单、扣减库存等操作。

在一次事务中最多只能更改一个聚合的状态。如何一个业务操作涉及多个聚合状态的更改可以采用领域事件的方式实现聚合之间的解耦在聚合根和跨上下文之间实现最终一致性。聚合内数据强一致性聚合之间数据最终一致性。

事件的生成和发布构建的事件应包含事件ID、时间戳、事件类型、事件源等基本属性以便事件可以无歧义地在不同上下文间传播此外事件还应包含具体的业务数据。

领域事件为已发生的事务具有只读不可变更性。一般接收消息为异步监听处理的后续处理需要考虑时序和重复发送的问题。

2.1.2 聚合根、实体、值对象的区别

从标识的角度

聚合根具有全局的唯一标识而实体只有在聚合内部有唯一的本地标识值对象没有唯一标识

从是否只读的角度

聚合根除了唯一标识外其他所有状态信息都理论上可变实体是可变的值对象是只读的

从生命周期的角度

聚合根有独立的生命周期实体的生命周期从属于其所属的聚合实体完全由其所属的聚合根负责管理维护值对象无生命周期可言因为只是一个值

2.2 建模方法

2.2.1 事件风暴

事件⻛暴法类似头脑⻛暴简单来说就是谁在何时基于什么做了什么产⽣了什么影响了什么事情。

在事件风暴的过程中领域专家会和设计、开发人员一起建立领域模型在领域建模的过程中会形成通用的业务术语和用户故事。事件风暴也是一个项目团队统一语言的过程。

2.2.2 用户故事

用户故事在软件开发过程中被作为描述需求的一种表达形式并着重描述角色谁要用这个功能、功能需要完成什么样子的功能和价值为什么需要这个功能这个功能带来什么样的价值。

作为一个“网站管理员”我想要“统计每天有多少人访问了我的网站”以便于“我的赞助商了解我的网站会给他们带来什么收益。

通过用户故事分析会形成一个个的领域对象这些领域对象对应领域模型的业务对象每一个业务对象和领域对象都有通用的名词术语并且一一映射。

2.2.3 统一语言

在事件风暴和用户故事梳理过程及日常讨论中会有越来越多的名词冒出来这个时候需要团队成员统一意见形成名词字典。在后续的讨论和描述中使用统一的名称名词来指代模型中的对象、属性、状态、事件、用例等信息。

可以用Excel或者在线文档等方式记录存储标注名称描述和提取时间和参与人等信息。

代码模型设计的时侯就要建立领域对象和代码对象的一一映射从而保证业务模型和代码模型的一致实现业务语言与代码语言的统一。

2.2.4 领域划分及建模

DDD 内核的代码模型来源于领域模型每个代码模型的代码对象跟领域对象一一对应。

通过UML类图通过颜色标注区分聚合根、实体、值对象等、用例图、时序图完成软件模型设计。

2.3 整洁架构洋葱架构

整洁架构(Clean Architecture)是由Bob大叔在2012年提出的一个架构模型顾名思义是为了使架构更简洁。

整洁架构最主要原则是依赖原则它定义了各层的依赖关系越往里依赖越低代码级别越高。外圆代码依赖只能指向内圆内圆不知道外圆的任何事情。一般来说外圆的声明包括方法、类、变量不能被内圆引用。同样的外圆使用的数据格式也不能被内圆使用。

整洁架构各层主要职能如下

  • Entities实现领域内核心业务逻辑它封装了企业级的业务规则。一个 Entity 可以是一个带方法的对象也可以是一个数据结构和方法集合。一般我们建议创建充血模型。

  • Use Cases实现与用户操作相关的服务组合与编排它包含了应用特有的业务规则封装和实现了系统的所有用例。

  • Interface Adapters它把适用于 Use Cases 和 entities 的数据转换为适用于外部服务的格式或把外部的数据格式转换为适用于 Use Casess 和 entities 的格式。

  • Frameworks and Drivers这是实现所有前端业务细节的地方UIToolsFrameworks 等以及数据库等基础设施。

3. 落地实践

3.1 概述

在整个DDD开发过程中除了建模方法和理论的学习实际技术落地还会遇到很多问题。在多个项目的不断开发演进过程中循序渐进的总结了很多经验和小技巧用于解决过往的缺憾和不足。走向DDD的路有千万条这些只是其中的一些可选方案如有纰漏还请指正。

3.2 工程示例简介

目前我们采用的是内核整体分离如下图所示。

b2b-baseproject-kernel 内核模块说明

其中 b2b-baseproject-kernel 为内核的Maven工程示例 b2b-baseproject-center为读写服务汇总的中心对外服务工程示例。

图3-1 kernel基础工程示例

内核Maven工程模块说明

1. b2b-baseproject-kernel-common 常用工具类常量等不对外SDK暴露
2. b2b-baseproject-kernel-export 内核对外暴露的信息为常量枚举等可直接让外部SDK依赖并对外减少通用知识重复定义可选
3. b2b-baseproject-kernel-dto 数据传输层方便app层和domain层共享数据传输对象不对外SDK暴露
4. b2b-baseproject-kernel-ext-sdk 扩展点(可选不需要可直接移除
5. b2b-baseproject-kernel-domain 领域层等也可以不划分子模块按需划分即可
   b2b-baseproject-kernel-domain-common 通用领域主要为一些通用值对象
   b2b-baseproject-kernel-domain-ctxmain 核心领域模型可自行调整名称
6. b2b-baseproject-kernel-read-app 读服务应用层(可选不需要可直接移除
7. b2b-baseproject-kernel-app 写服务应用层



b2b-baseproject-center 实现模块说明

图3-2 center基础工程示例

center Maven工程模块说明

对外SDK
1. b2b-baseproject-sdk 对外sdk工程
    1.1 b2b-baseproject-base-sdk 基础sdk
    1.2 b2b-baseproject-core-sdk 写服务sdk
    1.3 b2b-baseproject-svr-sdk 读服务sdk
基础设施
2. b2b-baseproject-center-common 常用工具类常量等
3. b2b-baseproject-center-infrastructure 基础设施实现层
   b2b-baseproject-center-dao 基础设施层的数据库访问层也可不分直接融合到infrastructure
   b2b-baseproject-center-es  基础设施层的ES访问层也可不分直接融合到infrastructure);

center服务层
4. b2b-baseproject-center-service center的业务服务层

接入层
5. b2b-baseproject-center-provider 服务接入实现

springboot启动
6. b2b-baseproject-center-bootstrap springboot应用启动层


备注对外SDK主要考虑适配CQRS原则将读写分为两个单独的module 如果感觉麻烦也可以合并为一个SDK对外用不同的分包隔离即可。



内核和实现的关联

使用内核和具体实现应用分离的划分是因为前期因为有商业化衍生出了多版本开发。当然目前架构组是不建议一个内核多套实现的而是建议一个内核加上一个主版本实现。避免因为多版本实现造成分裂徒增开发和维护成本改为采用配置和扩展点来满足差异化需求。

目前我们开发只保持一个主版本但是工程继续使用内核分离的方式即一个内核+一个主版本实现。

优点

  1. 内核和实现代码完全隔离得到一个比较干净存粹的内核

  2. 虽万不得已不建议多版本实现但是万一要支持多版本可以直接复用内核

  3. 某种意义上是一种更合理的分离保证了内核和实现版本的分离各自关注各自模块的核心问题

缺点

  1. 联调成本增加每次改完需要本地install 或者推送到远程Maven仓库

基于以上原因对于小工程不必做以上分离直接在一个Maven工程中进行依赖开发即可 从很多示例教程也是推荐如此。

CQRS命令与查询职责分离

CQRS 就是读写分离读写分离的主要目的是为了提高查询性能同时达到读、写解耦。而 DDD 和 CQRS 结合可以分别对读和写建模。

查询模型是一种非标准化数据模型它不反映领域行为只用于数据查询和显示命令模型执行领域行为在领域行为执行完成后通知查询模型。

命令模型如何通知到查询模型呢如果查询模型和领域模型共享数据源则可以省却这一步如果没有共享数据源可以借助于发布订阅的消息模式通知到查询模型从而达到数据最终一致性。

Martin 在 blog 中指出CQRS 适用于极少数复杂的业务领域如果不是很适合反而会增加复杂度另一个适用场景是为了获取高性能的查询服务。

对于写少读多的共享类通用数据服务如主数据类应用可以采用读写分离架构模式。单数据中心写入数据通过发布订阅模式将数据副本分发到多数据中心。通过查询模型微服务实现多数据中心数据共享和查询。

领域与读模型的联系与差异

领域模型以聚合根为唯一入口是承载本体变更的核心其是对业务模型的根本建模。若聚合根为每一个普通的人体聚合根主键就是身份证ID。假设人人生而自由不受人控制那么当一个人接受到合理命令后进行自我属性变更然后对外发送信息。

而视图层是人体和社会信息的投影就如我们的教育情况职业情况健康情况等一样。是对某个时刻对本体信息的投影。

视图因为基于消息传播的特性我们的很多视图可能是延迟或者不一致的。事例

1. 你已经阳了而你的健康码还是绿码;
2. 你已经结婚而户口本还是未婚
3. 你的结婚证上聚合了你配偶的信息



现实世界的不一致已经给我们带来了很多麻烦和困扰对于IT系统来说也是一样。视图的实时更新总是令人神往但是在分布式系统中面临诸多挑战。而为了消除领域模型变更后各种视图层的延迟和不一致就需要在消息传播和更新时机上做一些优化。但是在业务处理上还是需要容忍一定程度的延迟和不一致因为分布式系统是很难做到100%的准实时和一致性的。

3.3 问题及解决方案

3.3.1 领域资源注册中心

背景

一般来讲领域模型不持有仓库也不不持有其他服务是一个比较。这就造成领域模型在做一些验证的时候仅能进行内存态的验证。对于rpc服务以及涉及一些重复性验证的情况就显得无能为力。为了更好的解决这个问题我们采用了领域模型注册中心采用一个单例的类来持有这些服务

那我们在领域模型中从数据库重新加载回来的领域模型不需要通过spring进行数据封装就可以直接使用所依赖的服务。

基于此这些服务必须是无状态的通过输入领域模型完成数据服务。

/**
 * 租户注册中心
 *
 * @author david
 * @date 12/12/22
 */
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Setter
public class TenantRegistry {
    /**
     * 仓库
     */
    private TenantRepository tenantRepository;

    /**
     * 单例
     */
    private static TenantRegistry INSTANCE = new TenantRegistry();

    /**
     * 获取单例
     *
     * @return
     */
    public static TenantRegistry getInstance() {
        return INSTANCE;
    }

}




在领域模型进行数据保存的时候可用获取仓库或者验证服务进行数据验证。


 /**
     * 保存数据
     */
    public void save() {
        this.validate();
        TenantRepository tenantRepository = TenantRegistry.getInstance().getTenantRepository();
        tenantRepository.save(this);
    }



3.3.2 内核模块化

一般来讲主站因为服务的客户量广需求多样导致功能及依赖服务也会很庞大。然后在进行商业化部署的时候往往只需要其中10%~50%的能力如果在部署的时候全量的服务和领域模型加载意味着需要配置相关的底层资源和依赖否则可能启动异常。

内核能力模块化就显得尤为重要目前我们主要利用spring的条件加载实现内核模块化。如下

/**
 * 租户构建工厂
 *
 * @author david
 */
@Component
@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}")
public class TenantInfoFactory {
}

/**
 * 租户应用服务实现
 *
 * @author david
 */
@Service
@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}")
public class TenantAppServiceImpl implements TenantAppService {
}

//其它相关资源类似通过@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}") 进行动态开关



这样在applicaiton.yml 配置相关能力的true/false, 就可以实现相关能力的按需加载当然这是强依赖spring的基础能力情况下。

//appliciaton.yml 配置

b2b:
  baseproject:
    kernel:
      ability:
        tenant: true
        dict: true
        scene: true



可选进一步优化依赖

条件加载使用了spring的注解某种意义上导致内核和spring进行了耦合。然而项目中总有终极DDD患者希望内核中最好连spring的依赖也去掉。这个时候可以将spring的装配专门抽取到一个Maven的module作为starter由这个starter负责spring的相关的注入和依赖进行适配。对于模块化加载配置可以继续沿用conditional的配置本质上差异不大。

3.3.3 仓库层diff实践可选项

本案例仅在使用关系型数据库且为了提升更新时性能场景适用。如果能偏向于采用支持事务的NoSQL数据库那么本实践可直接略过。

如果不是受制于关系型数据库的更加流行的制约在面向DDD开发之后大家可能更偏向于NoSQL数据库可以将领域对象以聚合根的为整体进行整存整取这样可以大大的降低仓库层存取持久化数据的开发量。而现状是大部分项目都依赖于关系型数据库故而很多数据依然存在复杂的数据库存储关系。

如果聚合根下关联多个实体那么在更新的时候比较简洁的方式是整体覆盖即使数据行没有发生变更。有时候为了提升数据库更新的性能就需要按需更新这时候就需要追踪实体对象是否发生变更。

对实体对象的变更追踪有两个方式

A -> 保存更新前快照使用反射工具深度对比值是否变更
B -> 使用RecordLog 作为数据状态跟踪



在过往项目中A/B方案均采用过A方案的代码侵入较少但是需要保留更新前完整快照使用反射情况下性能会略有影响。 B方案不需要保持更新前完整快照 也不用反射但是需要在需要diff的实体对象中增加RecordLog值对象标记数据是新增、修改、或者未变更。

目前我们主要采用B方案在涉及实体变更的入口方法顺便调用RecordLog的更新方法这样在仓库层既可以判断是新增、修改、还是没有发生变更。仓库层在执行保存的时候则可用通过recordLog值对象的creating, updating判断数据的状态。

/**
 * 日志值对象用于记录数据日志信息
 *
 * @author david
 * @date 2020-08-24
 */
@Getter
@Setter
@ToString
@ValueObject
public class RecordLog implements Serializable, RecordLogCompatible {

    /**
     * 创建人
     */
    private String creator;
    /**
     * 操作人
     */
    private String operator;
    /**
     * 并发版本号不一定以第三方传入的为准
     */
    private Integer concurrentVersion;
    /**
     * 创建时间不一定以第三方传入的为准
     */
    private Date created;
    /**
     * 修改时间, 不一定以第三方传入的为准
     */
    private Date modified;

    /**
     * 创建中
     */
    private transient boolean creating;
    /**
     * 修改中
     */
    private transient boolean updating;

    /**
     * 创建时构建
     *
     * @param creator
     * @return
     */
    public static RecordLog buildWhenCreating(String creator) {
        return buildWhenCreating(creator, new Date());
    }

    /**
     * 创建时构建传入创建时间
     *
     * @param creator
     * @param createTime
     * @return
     */
    public static RecordLog buildWhenCreating(String creator, Date createTime) {
        RecordLog recordLog = new RecordLog();
        recordLog.creator = creator;
        recordLog.created = createTime;
        recordLog.modified = createTime;
        recordLog.operator = creator;
        recordLog.concurrentVersion = 1;
        recordLog.creating = true;
        return recordLog;
    }

    /**
     * 更新
     *
     * @param operator
     */
    public void update(String operator) {
        setOperator(operator);
        setModified(new Date());
        setUpdating(true);
        concurrentVersion++;
    }

}




// 实体变更的时候需要同步标记recordLog

public class TenantInfo implements AggregateRoot<TenantIdentifier> {

 /**
     * 失效数据
     *
     * @param operator
     */
    public void invalid(String operator) {
        setStatus(StatusEnum.NO);
        recordLog.update(operator);

    }

    /**
     * 发布
     *
     * @param operator
     */
    public void publish(String operator) {
        setBusinessStatus(TenantBusinessStatusEnum.PUBLISH);
        recordLog.update(operator);
    }
    



      /**
     * 保存到仓库
     *
     * @param tenantInfo
     */
    @Override
    @Transactional
    public void save(TenantInfo tenantInfo) {
        TenantInfoPO tenantInfoPO = TenantInfoAssembler.convertToPO(tenantInfo);
        RecordLog recordLog = tenantInfo.getRecordLog();
        //创建diff判断
        if (recordLog.isCreating()) {
            tenantInfoMapper.insert(tenantInfoPO);
        } else if (recordLog.isUpdating()) {            //更新diff判断
            UpdateWrapper<TenantInfoPO> updateWrapper = new UpdateWrapper<>();
            updateWrapper.lambda().eq(TenantInfoPO::getTenantId, tenantInfoPO.getTenantId());
            tenantInfoMapper.update(tenantInfoPO, updateWrapper);
        }
        //将领域事件转换为taskPo 并在一个事务之中保存到数据库以便保证最终被消费
        tenantInfo.publish(localTaskEventFactory.buildEventPersistenceAdapter(event -> TaskAssembler.tenantEventToTaskPO(event)));
    }



3.3.4 读服务设计

一个完整的领域服务只是写入没有读取是不够的只写不读会出现信息黑洞导致领域变更无法被外部感知和使用。如前面所述读服务是面向视图的其需要的是容易检索索引服务宽表冗余关联信息摘要信息。且读服务不对源数据进行修改无需进行加锁更注重响应快速。

目前内核能相对标准化的读服务主要针对聚合根进行基本的详情检索如通过聚合根主键返回基本视图信息、列表检索等其他个性化定制化的查询参数和响应结果可以依据需求自行设计和扩展如果是比较定制的查询服务可以不必落地到内核之中。

在b2b-baseproject-kernel工程的 read-app 模块中我们定义了读服务的接口和约束返回对象则在实现的center工程中主要实现底层的读仓库和SDK接入层即可可通过ES, 关系型数据库 redis 等来提供底层的检索服务。

读服务接口

/**
 * 租户应用查询服务
 *
 * @author david
 **/
public interface TenantInfoQueryService {

    /**
     * 通过租户code查询
     *
     * @param req
     * @return
     */
    TenantConstraint getTenantByCode(GetTenantByCodeReq req);
}

/**
 * 通过租户编码查询租户信息请求
 *
 * @author david
 */
@Setter
@Getter
@ToString
public class GetTenantByCodeReq implements Serializable, Verifiable {
    /**
     * 租户编码
     */
    private String tenantCode;

    @Override
    public void validate() {
        Validate.notEmpty(tenantCode, CodeDetailEnum.TENANT);
    }
}






/**
 * 示例租户读服务约束接口
 *
 * @author david
 * @date 4/15/22
 */
public interface TenantConstraint extends RecordLogCompatible {
    /**
     * 租户id
     */
    Long getTenantId();

    /**
     * 租户id编码
     */
    Integer getTenantCode();
  
    // ...
}




/**
 * 租户应用查询服务内核实现
 *
 * @author david
 **/
@Service
public class TenantInfoQueryServiceImpl implements TenantInfoQueryService {

    //租户读仓库
    @Resource
    private TenantReadRepo tenantReadRepo;

    /**
     * 通过租户id查询
     *
     * @param req
     * @return
     */
    @Override
    public TenantConstraint getTenantByCode(GetTenantByCodeReq req) {
        req.validate();
        return tenantReadRepo.getTenantByCode(req.getTenantCode());
    }
  
  //...
}



3.3.5 领域事件发布

如果不依赖binlog和事务性消息组件 为了保证领域事件一定被发送出去就需要依赖本地事务表。我们将领域对象保存和领域事件发布任务记录在一个事务中得以执行。在领域事件推送消息中间件MQ中在数据库保存完毕后先主动发送一次容许失败如果发送失败再等待定时调度扫描事件表重新发送。如下图所示

一般情况下领域事件都是在业务操作的时候产生此时我们将领域事件暂存到注册中心。待入库的时候在一个事务包裹中进行保存。发布者如下所示如果聚合根需要使用此发布者事件注册服务只需要实现此Publisher接口即可。因为内部使用了WeakHashMap 作为容器如果当前对象不再被应用之前注册的事件列表会被自动回收掉。

/**
 * 描述发布者接口
 *
 */
public interface Publisher {
    /**
     * 容器
     */
    Map<Object, List<DomainEvent>> container = Collections.synchronizedMap(new WeakHashMap<>());

    /**
     * 注册事件
     *
     * @param domainEvent
     */
    default void register(DomainEvent domainEvent) {
        List<DomainEvent> domainEvents = container.get(this);
        if (Objects.isNull(domainEvents)) {
            domainEvents = Lists.newArrayListWithCapacity(2);
            container.put(this, domainEvents);
        }
        domainEvents.add(domainEvent);
    }

   /**
     * 获取事件列表
     *
     * @return
     */
    default List<DomainEvent> getEventList() {
        return container.get(this);
    }
  
 // 更多代码...略
  
}



简化方案

如果一些简单的应用不需要使用MQ消息队列进行事件中转也可以将本地事件表的发送状态作为任务处理状态。这样可以简化一些网络开销如在一个应用内借助guava的EventBus组件完成消息发布-订阅机制。即简化为订阅处理器如果全部执行成功才更新消息表为已发送也可以认为是已执行。

在实际开发中实际上我们很多领域事件都是基于此简化方案进行处理的因领域事件的部分处理功能简单使用简化方案能节省很多开发时间和代码量。

3.3.6 SAGA事务

概述

采用DDD之后虽然还是可以从应用层采用基础的事务性编程保证本地数据库的事务性。然而当处于微服务架构模式我们的业务常常需要多个跨应用的微服务协同采用事务进行一致性保证就显得鞭长莫及。

即使不采用DDD编程 我们过往已经开始采用BinlogMySQL的主从同步机制或者事务性消息来实现最终一致性。在越来越流行的微服务架构趋势下应用资源的分布式特性通过传统的事务ACIDatomicity、consistency、isolation、durability保证一致性已经很难现在我们通过牺牲原子性atomicity和隔离性Isolation转而通过保证CD来实现最终一致性。

解决分布式事务有许多技术方案如两阶段提交XA)、TCC、SAGA。

关于分布式事务方案的优缺点有很多论文和技术文章为什么选择SAGA 正如 Chris Richardson在《微服务架构设计模式》中所述

  1. XA对中间件要求很高跨系统的微服务更是让XA鞭长莫及XA和分布式应用天生不匹配

  2. TCC 对每一个参与方需要实现Try-confirm-cancel)三步侵入性较大

  3. SAGA是一种在微服务架构中维护数据一致性的机制它可以避免分布式事务带来的问题。通过异步消息来协调一系列本地事务从而维护多个服务直接的数据一致性

  4. SAGA理论部分, 可以参考分布式事务SAGA模式Pattern: Saga

SAGA 理论

1987年普林斯顿大学的Hector Garcia-Molina和Kenneth Salem发表了一篇Paper Sagas讲述的是如何处理long lived transaction长活事务。Saga是一个长活事务可被分解成可以交错运行的子事务集合。其中每个子事务都是一个保持数据库一致性的真实事务。 论文地址sagas

Saga的组成

  • 每个Saga由一系列sub-transaction Ti 组成; 每个Ti是保证原子性提交

  • 每个Ti 都有对应的补偿动作Ci补偿动作用于撤销Ti造成的结果; Ti如果验证逻辑且只读可以为空补偿即不需要补偿

  • 每一个Ti操作在分布式系统中要求保证幂等性可重复请求而不产生脏数据

    Saga的执行顺序有两种

    • T1, T2, T3, …, Tn 理想状态直接成功

    • T1, T2, …, Tj, Cj,…, C2, C1其中0 < j < n 向前恢复模式一般为业务失败

Saga补偿示例 如果在一个事务处理中Ti为发邮件 Saga不会先保存草稿等事务提交时再发送而是立刻发送完成。 如果任务最终执行失败 Ti已发出的邮件将无法撤销Ci操作是补发一封邮件进行撤销说明。

SAGA有两种主要的模式协同式、编排式。

A 事件协同式SAGAEvent choreography)

把Saga的决策和执行顺序逻辑分布在Saga的每个参与方中他们通过相互发消息的方式来沟通。

在事件编排方法中第一个服务执行一个事务然后发布一个事件该事件被一个或多个服务进行监听这些服务再执行本地事务并发布或不发布新的事件。当最后一个服务执行本地事务并且不发布任何事件时意味着分布式事务结束或者它发布的事件没有被任何 Saga 参与者听到都意味着事务结束。

① 优点

  • 避免中央协调器单点故障风险

  • 当涉及的步骤较少服务开发简单容易实现

② 缺点

  • 服务之间存在循环依赖的风险

  • 当涉及的步骤较多服务间关系混乱难以追踪调测

  • 参与方需要彼此感知上下耦合关联性无法做到服务单元化

B 命令编排式SAGAOrder Orchestrator

中央协调器Orchestrator简称 OSO以命令/回复的方式与每项服务进行通信全权负责告诉每个参与者该做什么以及什么时候该做什么。

① 优点

  • 服务之间关系简单避免服务间循环依赖因为 Saga 协调器会调用 Saga 参与者但参与者不会调用协调器。

  • 程序开发简单只需要执行命令/回复(其实回复消息也是一种事件消息)降低参与者的复杂性。

  • 易维护扩展在添加新步骤时事务复杂性保持线性回滚更容易管理更容易实施和测试。

② 缺点

  • 中央协调器处理逻辑容易变得庞大复杂导致难以维护。

  • 存在协调器单点故障风险。

命令编排式SAGA示例—— 非订单聚合提票开票申请

Saga在发票开票申请的案例如下所示提票申请被拆分为2个主要的SAGA协调器。

① 在接收到【母申请单已经创建事件】即触发生成协调器1调度——开票申请SAGA协调器 用于参数验证、订单锁定、占用应开金额和数量、最后按开票规则拆分为多个子申请单一个子申请单对一张实际的发票。在多个子申请单完成创建后 会发布【子申请单已创建】事件。

② 在接收到【子申请单已经创建事件】即触发生成协调器2调度——子申请单提票SAGA协调器 用于子申请单预占流水记录、提交财务开票、接收财务状态同步子申请单状态。

​ 使用编排式Saga, 对每一个步骤的调用也不一定是同步的也可以发送处理请求后挂起协调处理器等待异步消息通知。通过消息中间件如MQ收到某个步骤的处理结果消息然后再恢复协调器的继续调度。假设Saga事务的每个步骤都是异步的那么编排式协调器和事件协调器就非常类同唯一的好处是整个业务处理的消息收发均要通过Saga协调器作为中枢。当前在哪一步骤下一步要做什么可以由SAGA协调器统一支配。

​ 对于一个比较复杂的长活事务从业务的完整性和排查问题的方便性考虑我们推荐使用Saga编排式事务来收敛业务的调度复杂度以免在消息发送接收网络中迷失。编排式事务有时候类似一个状态机当前任务执行到哪个步骤哪个状态能够被保存和复原且条理性更加清晰。

​ 在编排式Saga事务中我们需要使用到eventSource类似的事件记录以便记录每一个步骤的执行情况和部分上下文信息。除了手动建表之外目前我们采用的方案也有很多成熟的框架可供选择如alibaba的seata微服务架构设计模式推荐的eventuate 。

风险

当然在使用saga中还需要考虑隔离性缺失带来的风险尤其是在交易和金融环节。这不是saga能直接解决的问题这需要通过语义锁未提交数据加字段锁防止脏读)、交换式更新、版本文件、重读值等方案进行处理。

4. 参考资料

4.1 参考书籍

Domain-Driven Design《领域驱动设计》–Eric Evans

MicroServices Patterns《微服务架构设计模式》 -- Chirs Richardson

《DDD 实战课》 – 欧创新

_4.2_网络资料

领域模型核心概念实体、值对象和聚合根

聚合根、实体、值对象精炼思考总结

DDD(Domain-Driven Design)领域驱动设计在互联网业务开发中的实践

DDD落地实践

https://www.jianshu.com/p/91bfc4f21caa

https://www.jianshu.com/p/4a0d89dd7c20

领域驱动设计(2) 领域事件、DDD分层架构

https://my.oschina.net/lxd6825/blog/5485465

saga分布式事务_本地事务和分布式事务-tencent

作者京东零售 张世彬

来源京东云开发者社区 转载请注明来源

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6