前面已经介绍了DDD分层架构的实体和值对象,本文将介绍聚合以及与其高度相关的并发主题。程序员
我在以前已经说过,初学者第一步须要将业务逻辑尽可能放到实体或值对象中,给实体“充血”,这样可让业务逻辑高度内聚,并为你提供业务逻辑的惟一访问点。而聚合则是第二步,它将多个相关业务概念包装到单一的概念中,从而大幅简化系统设计,因为受传统数据建模思惟影响,我在聚合方面吃过大亏,花了将近一年才真正用起来,为了你少走弯路,我会把一些要点总结出来供你参考。数据库
聚合包装一组高度相关的对象,做为一个数据修改的单元。编程
聚合最外层的对象称为聚合根,它是一个实体。聚合根划分出一个清晰的边界,聚合根外部的对象,不能直接访问聚合根内部对象,若是须要访问内部对象,必须首先访问聚合根,再导航到聚合的内部对象。缓存
聚合表明很强的包含关系,聚合内部的对象脱离了聚合,应该是毫无心义的,或不是你真正关注的,它是聚合的一个组成部分,这与UML中的组成聚合概念相近。安全
在刚开始接触DDD时,咱们受到传统数据建模思惟影响,根据范式要求设计出多张表,会很天然的每张表映射成一个实体,每一个实体都是聚合。我最初就是这样使用的,干了一年左右才醒悟过来,虽然这样也能够实现功能。架构
那么这有什么问题?并发
当把每一个表映射成独立的聚合时,咱们在思考问题的时候,会把每一个表做为独立对等的概念进行思考,从而使你的大脑分不清主次,淹没在错综复杂的表关系中。框架
如今若是系统有100张数据库表,每张表以任意方式关联,映射成100个聚合。你在进行思考时,以相同方式对待这100个聚合,很快就会头晕目眩。有经验的开发者知道经过切割模块能够下降复杂度,但各个模块之间错综复杂的关系依然存在。ide
若是经过聚合的方式进行思考,状况则大不相同。把高度相关的概念封装到一个聚合中,而且将聚合中的对象尽可能使用值对象建模,不只能够减小表数量,在概念上也更加简单和清晰。如今假定仍是100张表,每5张表映射到一个聚合中,那么具备20个聚合。咱们在思考问题时,整个聚合成为一个独立思考的单元,聚合内部的附属对象已经成为二等公民,你并不须要随时想到它们。因为聚合根外部对象只能直接访问聚合根,因此复杂的关系被封装到聚合内部。咱们如今只须要考虑聚合根之间的关系,整个系统设计会大幅简化,系统的耦合度获得控制。性能
另外一方面,聚合对仓储产生影响。因为仓储表明的是聚合的集合,换句话说,每一个聚合应该拥有一个仓储。若是每一个表都映射为聚合,那么会致使大量的仓储,哪怕采用了依赖注入框架,整个系统的依赖复杂度仍是很是高。
若是一组相关对象须要知足某些业务规则,而且这几个对象是离散的独立对象,那么实施一致性规则就很是困难。你可能须要在每一个用到的地方进行各类判断,从而致使复杂度和冗余。
我几乎在每篇文章都给你反复强调充血模型的重要性,是想激起你的注意。对于上面的问题,其实是须要一个统一的验证点。可以给你提供惟一的业务逻辑访问点的位置就在实体中,因此把这一组相关对象组合为一个聚合,并在聚合上强制实施验证规则能够很好的解决问题。
从聚合的定义能够看出,聚合不只是一组对象的抽象概念,并且还要作一些实际工做,即做为一个总体更新数据。数据更新很容易就会碰到并发问题,聚合有义务提供相关支持来解决并发冲突,这是经过使用乐观离线锁来完成的。
并发是一个复杂的问题,仅了解一点乐观离线锁并不能顺利完成相关工做。有些业务场景须要使用悲观离线锁进行补充。另外,数据库也有本身的并发模型,一样有乐观和悲观模式,那么,聚合中使用的并发模型与数据库中的并发模型关系怎样?
对并发问题认识不清,轻则致使系统性能低下,重则致使数据错乱,因此我将在本文对开发中可能碰到的并发问题进行简单介绍。
不少程序员都喜欢追求设计的“正确性”,好比他会问,这一堆对象中哪一个才是正确的聚合。只要是设计问题,因为每一个人理解不一样,确定答案不同。更有经验的开发人员可以获得更好的设计,更接近于“标准答案”,但那是创建在充分理解的基础上。若是一个高手告诉你某个类应该是聚合,你却没有真正理解他的用意,这种状况可能致使你设计出一个艰涩的系统。因此正确性是因人而异的,你应该因地制宜,而不是人云亦云。
另外,高手告诉你的聚合也不见得是合适的,由于他不必定了解你的业务实际状况,聚合不只受逻辑上的概念影响,而且还受到并发、性能等因素制约。
下面介绍选择聚合的通常性规律,能够帮助你进行一些决策。
第一步,寻找具备包含或组成关系的相关对象。
某些对象有附属的子项,好比订单Order和订单项OrderItem,它们具备包含关系,订单包含订单项的集合,或者能够认为一个订单是由N个订单项组成的。
找到的N组相关对象成为聚合的候选,能不能成为聚合须要通过后面的筛选。
第二步,考虑聚合内部的子对象集合,是否须要被聚合根外部的对象直接访问,若是须要,将其从聚合中移出,并建模为独立聚合。
虽然一个对象可能从概念上被另外一个对象包含,但若是这种包含关系很弱,通常意味着子对象离开该聚合可能仍然有意义,外界对象但愿可以直接和它打交道。
第三步,聚合内部致使并发冲突严重时,进行聚合拆分。
前两步是从概念上选择聚合,但聚合还受到其它因素影响,好比并发、性能等。
经过乐观离线锁能够保证,两次提交的聚合不会发生更新丢失。若是聚合只包含它自己,出现冲突的可能性就很小。但因为聚合中每每包含集合,甚至是多个集合,因此各个集合之间的修改可能致使并发冲突很严重。
好比一个聚合中包含两个实体集合,用户A正在编辑聚合的第一组实体集合,与此同时,用户B 开始编辑同一个聚合的第二组实体集合,第一我的提交成功,第二我的将更新失败。
若是用户常常须要对聚合内的不一样集合进行单独编辑,这就说明聚合中的概念可能具备独立性,应该拆分出来。当聚合内部集合常常致使更新失败时,果断进行拆分是必须的。
设计一个大型聚合,除了可能常常致使并发冲突外,还可能致使低下的性能。好比酒店包含不一样的房型,每一个房型包含不一样的价格政策,每种价格政策的价格又不一样,价格可能每隔几天都会变化,若是把酒店做为一个大型聚合,把其它都做为集合包含进来,建立一个酒店聚合的开销可能很惊人。
当聚合中的子对象集合的层级超过2级,好比子对象又包含孙对象集合,须要考虑是否会致使并发和性能问题。另一个聚合中包含子对象集合的数量也须要控制,好比一个聚合包含10个子对象集合,出现冲突的可能性就会很大。还有一个问题是,包含的子对象集合的元素个数也要考虑,好比一个商品,须要记录商品的价格变更历史,因为价格是商品的一个属性,因此可能会把价格变更历史也放到商品中。若是价格常常变更,好比天天2次,一年就会产生700条记录,能够看到,有些子对象集合刚开始数据量不大,但会持续增长,这种状况也须要进行聚合拆分。
若是一个聚合良好表达了一个总体概念,把附属信息都封装起来,而且没有致使并发冲突常常发生,还性能良好,能够认为设计至关成功了,固然,这很不容易。
上面介绍了聚合的基本概念,因为聚合更新与并发密切相关,下面将介绍应用程序开发中随时可能碰到的并发问题,并讨论相关解决方案。同时,将应用程序级别的并发模型与数据库事务级别的并发模型进行比较,这样能够对并发解决方案有更清晰的认识。
若是多个操做同时集中在同一条数据上,就可能形成并发,致使数据不一致。并发产生的数据不一致现象主要有如下几种:
1. 脏读
当事务A正在更新数据,但还未提交,另外一个事务B获取了正在更新的数据,发生脏读。因为当前数据处于中间状态,若是事务A更新失败,则发生回滚,将致使事务B读取的数据是错误的。
脏读有百害而无一利,应该尽可能避免。
2. 不可重复读
事务A读取了须要的数据,另外一个事务B对这些数据进行了更改,当事务A准备用这些数据进行计算时,实际上数据已经被改变了,这种状况称为不可重复读。换句话说,在同一个事务中,两次发出相同条件的Select语句获取的结果不一样。
不可重复读大部分时候都不是问题,在一次计算中,应该使用老版本的数据,仍是必须使用最新的数据进行计算,这是一个业务问题。
3. 幻读
事务A使用范围条件读取了须要的数据,另外一个事务B在该范围添加了一些数据,当事务A准备用刚才获取的数据进行精确统计时,但实际上还有漏网之鱼,这种状况称为幻读。
绝大部分的系统都不须要考虑这个问题,避免幻读只在某些高精度的场景下才须要,好比银行对账。
4. 丢失更新
前三种问题主要发生在数据库事务级别,丢失更新则发生在应用程序业务级别。丢失更新的概念很简单,就是后一我的把前一我的的操做覆盖了,致使前一我的的更新丢失。
客户Customer,它有三个属性:标识Id,名称Name,描述Description,其中一条数据为:Id=1,Name=”a”,Description=”Hello”。
如今张三把Id为1的客户编辑界面打开,而后就吃饭去了。
李四对Id=1的客户进行编辑,修改了Name为“b”,保存成功。
张三吃完饭回来,继续干活,他把Description改为”Haha”,保存以后,李四修改的Name=”b”又变回Name=”a”,李四的工做白干了。
丢失更新是严重的数据修改错误,应该坚定避免。
5. 重复更新
重复更新是前面几种问题的变体,因为危害很大,因此我专门把它拿出来讨论。
重复更新在概念上也很简单,原本只容许执行一次的操做,如今执行了屡次。
考虑一个在线充值的场景,如今用户在第三方支付平台支付了100元,第三方支付平台向你的系统发送了一个支付成功的确认,你的系统如今须要为充值编号为1对应的客户余额增长100。假定你开启了一个数据库事务来完成这个操做,正在执行的过程当中,第三方支付平台系统抽筋,又向你的系统重复发送了一次支付确认请求,以下图所示。
上面的过程执行完毕,你的系统给客户充值200元,客户很是满意,觉得你买一送一。
从上面能够看到,该程序员虽然不懂并发,但仍是有防护编程意识,在事务开始的最前面,经过充值状态判断来防止重复充值。
经过状态判断的方式通常能够抵挡大部分的重复更新操做,只在运气极背的时候碰上并发而致使错误,因为并发极难重现,并且在数据量比较大时也不容易经过肉眼观察出来,因此碰到这种问题通常都是不了了之。
若是你的系统须要和钱打交道,那么增强并发知识的学习就很是有必要,这可让你的公司少赔一点钱。
观察前三种并发问题,都是读和写之间并发形成的。Sql Server数据库为了解决读写并发冲突,首先引入了悲观并发模型,经过锁进制来解决读写冲突。
前面说过,脏读是必需要避免的问题。Sql Server数据库在读取前经过获取共享锁来解决这个问题,在更新数据时会获取独占锁,因为共享锁与独占锁没法共存,致使读取数据时,更新被阻塞,或在更新数据时,读取被阻塞,从而解决了脏读。
虽然脏读被解决了,但却引入了读写阻塞的问题,在有一些数据量和并发量的系统上,性能可能表现得很低下。有一些程序员发现能够经过添加锁提示With(NoLock)得到更好的性能,这实际上是走回了老路。With(NoLock)锁提示将默认的事务隔离级别(读已提交)下降为读未提交,读未提交事务隔离级别在读取数据前不获取共享锁,因此不会阻塞,但它会致使脏读。更好的方法是经过添加缓存机制,以及数据读写分离,将频繁的查询从主库卸载。
从Sql Server 2005开始支持乐观并发模型,它经过在修改或删除数据前将数据的老版本存储到临时数据库TempDB的版本存储区来解决读写并发致使的不一致,并解决了读写阻塞问题。Sql Server为乐观并发提供了两个新的事务隔离级别——快照隔离级别和读已提交快照隔离级别。
快照隔离级别解决了不可重复读和幻读的问题,但须要牺牲更多的更新性能(由于在修改或删除数据前须要先备份到版本存储区)和TempDB存储空间。因为大部分系统不可重复读和幻读都不是大问题,因此通常推荐使用读已提交快照隔离级别,它不只开销更小,并且行为上与悲观模型更兼容。
悲观并发模型还包括另外两个事务隔离级别,可重复读隔离级别经过把共享锁生命周期延长到事务结束来解决不可重复读的问题,而可序列化隔离级别经过键范围锁或表锁来限制查询范围内的添加,解决了幻读。这两个事务隔离级别通常不要使用,由于将共享锁的持续时间延长会致使更大范围的阻塞,另外延长共享锁持续时间可能致使转换死锁。能够经过使用更新锁或快照隔离级别来代替这两个事务隔离级别。
在上面重复更新的例子中,进行充值状态判断是防止重复更新的关键,该范例之因此抵挡不住并发,是由于在获取充值记录时,默认获取的是共享锁,因为多个事务都可以获取共享锁,且共享锁默认生命周期很是短暂,因此让另外一个事务有了可趁之机。解决办法很简单,在获取充值记录时添加锁提示With(UpdLock),这样在充值记录L1上获取到更新锁,更新锁的特色是只有一个事务可以获取更新锁,生命周期持续到事务结束或成功转换为独占锁,这样在事务1获取到充值记录L1时,该记录被更新锁锁定,事务2在开启事务后,准备获取充值记录L1时就被阻塞,直到事务1提交事务。当事务1成功提交事务时,充值状态已改成“已充值”,因此事务2进行判断时就会跳出事务,后续充值不会被执行。
使用With(UpdLock)解决重复更新须要手工编写存储过程,对于面向对象开发很明显不太适用。
聚合经过引入乐观离线锁能够解决丢失更新和重复更新的问题。
观察上面丢失更新的例子,张三把操做界面一打开就吃饭去了,请问如何经过数据库事务解决这个问题?
数据库事务在开启以后,会锁定大量资源,若是它在某些数据上获取了独占锁,在事务提交以前不会释放,因此对事务的一个基本要求就是执行要快。很明显,你不能在张三把界面一打开的时候,就开一个事务等待他输入,在保存的时候再提交事务,由于他的输入时间不肯定,可能致使一个很长时间的事务。
能够看到,数据库的并发模型也不是万能的,对于上面的场景须要使用应用程序级别的并发控制。若是张三和李四不会常常修改同一条记录,就可使用乐观离线锁来解决更新丢失的问题。
乐观是指并发冲突机率很低,离线是指操做不是在同一个数据库事务中完成的,好比打开编辑页面时使用一个事务进行读取,中间则与数据库事务无关,在保存时会开启另外一个事务进行更新,能够看到这个过程是跨数据库事务的操做。乐观锁的优点是最大化系统并发度。
乐观离线锁经过为每行数据添加一个版本号来识别当前数据的版本,在获取数据时将版本号保存下来,更新数据时将版本号做为Where中的过滤条件,若是该记录被更新,则版本号会发生变化,因此致使更新数据时影响行数为0,经过引起一个并发更新异常让你了解数据已经被别人更新。
乐观离线锁不只能够解决丢失更新,并且一样能够解决重复更新。当第二个操做得到充值聚合时,若是充值状态为“未充值”,它继续后面的步骤。第一个操做更新完成后版本号发生改变,当第二个操做试图提交更新时,就会检测到并发冲突。在并发异常处理中,甚至对第二个操做进行重试都是安全的,由于它从新获取充值聚合时,充值状态已经为“已充值”,这样就拦截了非法操做。能够看到,重复更新的问题,无论用哪一种方法,都须要根据状态判断进行防护编程。
Sql Server数据库提供了Timestamp的数据类型来支持乐观离线锁,每当有数据插入或更新,这个字段会自动生成版本数据。
与此同时,Entity Framwork也提供了IsRowVersion来配置乐观离线锁。
从上面的描述能够看出,乐观离线锁是应用程序级别的并发模型,与数据库的乐观并发模型没有什么关系,虽然Sql Server数据库的乐观并发模型也有行版本的概念。这也意味着你在应用程序级别使用的是乐观锁,而Sql Server数据库中却使用的是悲观锁。
使用乐观离线锁的前提是并发冲突机率很低,若是冲突机率很高,使用乐观离线锁虽然不会致使系统数据错乱,但会致使用户十分抓狂,由于每次保存成功都须要运气。
对于冲突机率很高的场景,须要引入悲观离线锁,下面继续介绍。
一个100人的客服团队,他们的工做是对某种申请单进行处理。客服处理一个申请单的时间大体5分钟,每成功处理一个申请单可提成1元,每当用户提交一个申请单,全部客服均可以看见。
一个编号为1的申请单过来了,为了争取拿到那一元钱提成,100名客服争先恐后的打开业务处理界面并开始授理。一名18岁的小妹眼明手快,只花了3分零2秒就提交了,“耶,1元到手”。另外一名小妹花了3分零8秒,提交的时候,系统弹出一个友情提示“因为你的动做较慢,1元提成已经被人捷足先登了”。以后,连续不断的失败,你们只能感叹本身运气很差,另外有点走神,但愿下一次能够拿到提成。
故事说完了,该系统采用乐观离线锁设计,虽然整个操做没有致使数据出错,但整个客服团队的办事效率低得吓人,近乎串行操做。
解决上面的问题,有两个常见办法。
一种办法是经过一套自动调度策略开发一个申请单自动分配服务,申请单一来,未处理前就已经肯定好由谁处理了,这样就不会形成激烈的竞争,使用乐观离线锁也许就能知足需求。
另外一种办法是使用悲观离线锁,开发一个锁管理器,锁管理器须要在数据库中建表,记录锁定时间,锁定人,业务编号等信息,在申请单列表界面的每行都放一个“锁定”按钮,当第一我的点击“锁定”按钮时,向锁管理器添加锁记录,一旦被锁定,其它人不能编辑操做界面或进行提交,界面控件应该处于冻结状态,更严格的甚至不能打开编辑界面。
使用这种方案有一些问题,在点击“锁定”按钮时可能存在并发问题,这能够经过为锁管理器的业务编号创建惟一索引,保证不会在同一个业务编号上插入两条锁定记录,固然,这要求你的业务编号多是Guid,否则惟一性须要添加更多属性来识别。
既然容许锁定,就须要有解锁功能,解锁能够经过简单的删除锁定数据来完成。当编辑完成时,还须要对该业务编号自动解锁。也可能须要根据角色权限进行解锁,当某个客服锁定数据后就下班回家了,这致使其它人没法处理,因此更高级别的小组长可能容许对他的下级锁定的数据进行解锁。
若是须要强大的锁管理器,你能够仿照Sql Server悲观锁进行设计,加入锁模式、锁粒度、持续时间等要素。
能够看到,悲观离线锁,在实现和操做上并不简单,它只应该成为乐观离线锁的补充。
你能够把乐观离线锁放到每一个实体中,但这样太复杂,把乐观离线锁放到聚合根上,则整个聚合均可以得到并发控制能力,这称为粗粒度锁。
另外,能够在聚合根和映射的层超类型上将乐观离线锁封装起来,称为隐含锁。
因为聚合根自己是一个实体,因此大部分的操做在实体层超类型中已经实现,惟一须要增长乐观离线锁的支持。另外还须要增长几个接口,用于后面的泛型约束。
namespace Util.Domains { /// <summary> /// 实体 /// </summary> public interface IEntity { } /// <summary> /// 实体 /// </summary> /// <typeparam name="TKey">标识类型</typeparam> public interface IEntity<out TKey> : IEntity { /// <summary> /// 标识 /// </summary> TKey Id { get; } } } namespace Util.Domains { /// <summary> /// 聚合根 /// </summary> public interface IAggregateRoot : IEntity { /// <summary> /// 版本号(乐观锁) /// </summary> byte[] Version { get; set; } } /// <summary> /// 聚合根 /// </summary> /// <typeparam name="TKey">标识类型</typeparam> public interface IAggregateRoot<out TKey> : IEntity<TKey>, IAggregateRoot { } } namespace Util.Domains { /// <summary> /// 聚合根 /// </summary> /// <typeparam name="TKey">标识类型</typeparam> public abstract class AggregateRoot<TKey> : EntityBase<TKey>, IAggregateRoot<TKey> { /// <summary> /// 初始化聚合根 /// </summary> /// <param name="id">标识</param> protected AggregateRoot( TKey id ) : base( id ) { } /// <summary> /// 版本号(乐观锁) /// </summary> public byte[] Version { get; set; } } } using System; using System.ComponentModel.DataAnnotations; using Util.Validations; namespace Util.Domains { /// <summary> /// 聚合根 /// </summary> public abstract class AggregateRoot : AggregateRoot<Guid> { /// <summary> /// 初始化聚合根 /// </summary> /// <param name="id">标识</param> protected AggregateRoot( Guid id ) : base( id ){ } /// <summary> /// 验证 /// </summary> protected override void Validate( ValidationResultCollection results ) { if ( Id == Guid.Empty ) results.Add( new ValidationResult( "Id不能为空" ) ); } } }
总结
3. 《实现领域驱动设计》第10章——聚合,它提供了不少关于聚合方面的最佳实践和指导原则。固然我不必定严格按它的要求去作,我会用本身以为最简单的方法,当这些方法出现问题时,它给我指出方向。
本篇继续发扬个人代码少,废话多的风格,因为数据库并发主题很是复杂,我也只挑了一些以为对你们有帮助的部分简单介绍。若是有错误的地方,还请高手批评指正。
.Net应用程序框架交流QQ群: 386092459,欢迎有兴趣的朋友加入讨论。
谢谢你们的持续关注,个人博客地址:http://www.cnblogs.com/xiadao521/
下载地址:http://files.cnblogs.com/xiadao521/Util.2014.12.4.1.rar