一致性
1.聚合内事务实现
误区
许多DDD的实践者在处理聚合内事务控制时,往往将事务控制的逻辑放到应用层,这是不合理的,原因如下:
- 应用层职责过多:应用层原本的职责是协调领域对象和基础设施以完成业务操作,不应该过多地涉及数据访问和事务控制的细节,会使得应用层复杂度增加,不利于系统的可维护性和扩展性
- 影响性能:应用层往往需要协调基础设施,例如,调用防腐层(外部服务网关)从外部上下文的RPC服务中加载数据。RPC操作是非常耗时的,如果在应用层中进行事务控制,就会导致本地事务由于迟迟得不到提交,进而使得数据库连接无法及时释放。在流量较大时,连接池的数据库连接有可能全部被占用,导致其他请求获取不到数据库连接,也就无法进行数据读写,从而影响业务的正常开展,同时当获取不到数据库连接时,往往也会导致应用CPU和内存持续飙高,极易造成生产环境事故
伪代码
/**
* 应用层
*/
public class ApplicationService {
/**
* 与B外部上下文进行RPC交互
*/
@Resource
private BContextGateway bContextGateway;
/**
* 与C外部上下文进行RPC交互
*/
@Resource
private CContextGateway cContextGateway;
/**
* 演示应用层进行事务控制
*/
@Transactional
public void commandHandler (Command command) {
String entityId = command.getEntityId():
// 1.从B上下文读取数据,假设耗时200ms
SomeValueB someValueB = bContextGateway.getSomeValueB(entityId);
// 2.从C上下文读取数据,假设耗时150ms
SomeValueC someValueC = cContextGateway.getSomeValueC(entityId);
EntityId entityId = new EntityId();
// 3.加载聚合根,假设耗时20ms
Entity entity = domainRepository.load(entityId);
entity.doSomething(someValueB,someValueC);
// 4.保存聚合根,假设耗时20ms
domainRepository.save(entity);
}
}
上面这个代码,事务总耗时为390ms。如果B和C两个RPC接口的性能出现抖动,耗时会更长,从而严重影响性能
正确的实现思路
在Repository保存聚合根的时候,聚合根会被转换为数据对象,然后进行保存。在保存的过程中,可以调用类似于Mybatis的ORM框架,这样可以将事务控制放在Repository中
通过将数据库操作的细节封装在Repository中,应用层调用Repository时就不需要了解事务控制的细节了。将事务控制放到Repository的伪代码如下:
/**
* 应用层
*/
public class ApplicationService {
/**
* 与B外部上下文进行RPC交互
*/
@Resource
private BContextGateway bContextGateway;
/**
* 与C外部上下文进行RPC交互
*/
@Resource
private CContextGateway cContextGateway;
/**
* 演示应用层进行事务控制
*/
public void commandHandler (Command command) {
String entityId = command.getEntityId():
// 1.从B上下文读取数据,假设耗时200ms
SomeValueB someValueB = bContextGateway.getSomeValueB(entityId);
// 2.从C上下文读取数据,假设耗时150ms
SomeValueC someValueC = cContextGateway.getSomeValueC(entityId);
EntityId entityId = new EntityId();
// 3.加载聚合根,假设耗时20ms
Entity entity = domainRepository.load(entityId);
entity.doSomething(someValueB,someValueC);
// 4.保存聚合根,假设耗时20ms
domainRepository.save(entity);
}
}
/**
* Repository的实现类
*/
@Repository
public class DomainRepositoryImpl implements DomainRepository {
/**
* 保存聚合根
*/
@Override
@Transactional
public void save (Entity entity) {
// 省略
}
}
在这种情况下,数据库事务的执行时间只与保存聚合根的耗时有关,即20ms。事务控制的耗时也从390ms变成了20ms,从而极大地提高了数据库的读写性能
2.聚合根并发更新问题
并发更新的问题
应用服务执行业务操作的过程是这样的:
- 通过Repository从数据库加载聚合根
- 聚合根完成业务操作
- Repository将修改状态后的聚合根保存到数据库中
Repository在保存聚合根时进行了事务控制,将保存聚合根的操作放在一个事务中,以确保业务数据的准确性
如果只有一个命令请求,那么这样做是没有问题的;如果同时有多个命令请求对聚合根进行更新,就会出现数据一致性问题
下图展示了并发更新问题的执行过程:
乐观锁解决并发更新问题
为了解决这个问题,可以引入数据库乐观锁,具体实现思路如下:
- 在数据库表中增加一个version字段和你,加载聚合根的同时获取version的值
- 每次修改数据时必须对比聚合根的version与数据库行记录中的version是否相等,如果相等,则执行修改操作并将version加1,如果不等,则抛出异常终止本次操作,或者在应用层加入重试逻辑,重新加载数据,尝试再次执行业务
乐观锁的技术实现
乐观锁得实现有基于版本号和时间戳得两种方案。这里介绍基于版本号得实现方案,其基本思路如下:
- 通过在数据库表中添加一个版本号(version)字段,用来标识当前记录得版本
- 更新数据时,比较当前数据得版本号与数据库中的版本号,如果不一致,则表示数据已被修改
- 如果当前数据的版本号与数据库表中的版本号相等,则更新数据,并将版本号加1
在Mybatis中实现乐观锁的SQL语句如下:
update table_name set column_name = new_value,version = version+1
where id = #{id} and version = #{version}
重试技术的实现
在更新数据库时,由于乐观锁冲突导致执行失败后,可以重试业务逻辑。重试的技术实现有很多,可以在项目中引入Spring Retry组件,然后在引用曾进行重试。之所以在应用层使用重试,是因为domainRepository.save()执行失败时,需要重新加载聚合根和version的值,使用聚合根的最新状态执行业务逻辑
以下是使用注解式重试的示例代码:
/**
* 应用层的方法
* value表示重试的异常
* maxAttempts表示第1次执行失败后最多重试1次
*/
@Retryable(value=OptimisticLockingFailureException.class,maxAttempts=2)
public void modifyTitle (ArticleModifyTitleComd cmd) {
ArticleEntity entity = domainRepository.load(new ArticleId(cmd.getArticleId()));
entity.modifyTitle(new ArticleTitle(cmd.getTitle()));
domainRepository.save(entity);
}
使用重试的注意事项:
- 重试涉及的相关服务要支持幂等操作:比如上游业务接口必须支持幂等,重试操作发起的重复调用不应被视为新的业务请求
- 重试不适合频繁更新的热点数据:会造成其他请求频繁重试,反而会降低性能
- 重试的次数要提前规划好:一般根据响应时间的要求来确定重试的次数。例如,一个接口如果有RPC调用,重试多次会造成响应时间过长,并且也会给RPC服务提供者造成流量压力,一般推荐重试一次即可。如果需要重试多次,则说明待更新的数据锁竞争很激烈,建议考虑其他的实现方式
- 重试的次数最好可配置:主要用于数据库压力过大时进行降级,可以调整重试次数,例如,系统压力大时配置执行失败不重试的策略,以打倒保护数据库的目的。另外,也要明确发生了什么类型的异常才重试,一般是数据库乐观锁的相关异常才触发,其他异常是否重试,则要根据实际情况进行分析
3.数据库读写的性能思考
问题
对于命令操作,每次都从数据库中加载聚合根,并封装完整的聚合根以执行业务逻辑,然后将状态更新到数据库。然而,如果涉及读写大字段,这种做法可能会导致性能问题
优化思路
针对读写操作的性能优化,业界有许多优化思路
针对可能存在的读取性能问题,可以采用懒加载的方式,只有在真正使用某个字段时才加载它,这样可以避免在加载聚合根时将大字段加载到内存
针对可能存在的写入性能问题,可以采用脏跟踪的方式,只有被修改的字段才会更新到数据库
业界实践
实际上,上面说的优化思路并没有被广泛采用,不是因为技术上无法实现,而是因为我们可以通过其它方法来规避或环节这些性能问题,比如加入缓存,搜索引擎,消息队列,数据库读写分离,等等
4.跨聚合事务实现
介绍
聚合有一个原则,即每个事务只能更新一个聚合,在受此原则约束的情况下,不应在同一个事务中同时更新两个聚合
在聚合内部,可以使用本地数据库事务来确保数据的一致性。然而,在跨聚合的场景下,事务处理就变得复杂了
目前,许多应用所依赖的数据库都实现了分库分表。因此,两个聚合根不一定存储在同一个数据库中,无法保证在同一个数据库事务中保存多个聚合根,因此,跨聚合的事务控制实际上式处理分布式事务的场景
本地消息表
我们在领域事件的文章中详细讲过,这里就不多说了,一般用这个也就行
其它方案
其他的分布式事务这里就不多说了,会有单独的分布式事务专栏
总结
- 对于实时性要求不高,仅需要确保最终一致性的场景,可以使用本地消息表或者最大努力通知方案
- 对于实时一致性要求比较高的事务场景,例如,订单下单的同时扣减库存并使用优惠券的场景,可以采用TCC
- 对于长事务的场景,或者涉及外部系统,遗留系统,可以考虑Saga
- 在实现跨聚合事务时,如果无法通过领域事件的方式确保最终一致性,则可以考虑定义领域服务,将事务控制的复杂性封装到领域服务的实现类中