本文将介绍聚合以及与其高度相关的并发主题。
我在之前已经说过,初学者第一步需要将业务逻辑尽量放到实体或值对象中,给实体“充血”,这样可以让业务逻辑高度内聚,并为你提供业务逻辑的唯一访问点。而聚合则是第二步,它将多个相关业务概念包装到单一的概念中,从而大幅简化系统设计,由于受传统数据建模思维影响,我在聚合方面吃过大亏,花了将近一年才真正用起来,为了你少走弯路,我会把一些要点总结出来供你参考。
什么是聚合?
聚合包装一组高度相关的对象,作为一个数据修改的单元。
聚合最外层的对象称为聚合根,它是一个实体。聚合根划分出一个清晰的边界,聚合根外部的对象,不能直接访问聚合根内部对象,如果需要访问内部对象,必须首先访问聚合根,再导航到聚合的内部对象。
聚合代表很强的包含关系,聚合内部的对象脱离了聚合,应该是毫无意义的,或不是你真正关注的,它是聚合的一个组成部分,这与UML中的组成聚合概念相近。
聚合的作用
简化系统设计
在刚开始接触DDD时,我们受到传统数据建模思维影响,根据范式要求设计出多张表,会很自然的每张表映射成一个实体,每个实体都是聚合。我最初就是这样使用的,干了一年左右才醒悟过来,虽然这样也可以实现功能。
那么这有什么问题?
当把每个表映射成独立的聚合时,我们在思考问题的时候,会把每个表作为独立对等的概念进行思考,从而使你的大脑分不清主次,淹没在错综复杂的表关系中。
现在如果系统有100张数据库表,每张表以任意方式关联,映射成100个聚合。你在进行思考时,以相同方式对待这100个聚合,很快就会头晕目眩。有经验的开发者知道通过切割模块可以降低复杂度,但各个模块之间错综复杂的关系依然存在。
如果通过聚合的方式进行思考,情况则大不相同。把高度相关的概念封装到一个聚合中,并且将聚合中的对象尽量使用值对象建模,不仅可以减少表数量,在概念上也更加简单和清晰。现在假定还是100张表,每5张表映射到一个聚合中,那么具有20个聚合。我们在思考问题时,整个聚合成为一个独立思考的单元,聚合内部的附属对象已经成为二等公民,你并不需要随时想到它们。由于聚合根外部对象只能直接访问聚合根,所以复杂的关系被封装到聚合内部。我们现在只需要考虑聚合根之间的关系,整个系统设计会大幅简化,系统的耦合度得到控制。
另一方面,聚合对仓储产生影响。由于仓储代表的是聚合的集合,换句话说,每个聚合应该拥有一个仓储。如果每个表都映射为聚合,那么会导致大量的仓储,哪怕采用了依赖注入框架,整个系统的依赖复杂度还是非常高。
强制实施相关对象上的一致性规则
如果一组相关对象需要满足某些业务规则,并且这几个对象是离散的独立对象,那么实施一致性规则就非常困难。你可能需要在每个用到的地方进行各种判断,从而导致复杂度和冗余。
我几乎在每篇文章都给你反复强调充血模型的重要性,是想激起你的注意。对于上面的问题,实际上是需要一个统一的验证点。能够给你提供唯一的业务逻辑访问点的位置就在实体中,所以把这一组相关对象组合为一个聚合,并在聚合上强制实施验证规则可以很好的解决问题。
对并发更新提供保护
从聚合的定义可以看出,聚合不仅是一组对象的抽象概念,而且还要做一些实际工作,即作为一个整体更新数据。数据更新很容易就会碰到并发问题,聚合有义务提供相关支持来解决并发冲突,这是通过使用乐观离线锁来完成的。
并发是一个复杂的问题,仅了解一点乐观离线锁并不能顺利完成相关工作。有些业务场景需要使用悲观离线锁进行补充。另外,数据库也有自己的并发模型,同样有乐观和悲观模式,那么,聚合中使用的并发模型与数据库中的并发模型关系怎样?
对并发问题认识不清,轻则导致系统性能低下,重则导致数据错乱,所以我将在本文对开发中可能碰到的并发问题进行简单介绍。
聚合的选择
很多程序员都喜欢追求设计的“正确性”,比如他会问,这一堆对象中哪个才是正确的聚合。只要是设计问题,由于每个人理解不同,肯定答案不一样。更有经验的开发人员能够得到更好的设计,更接近于“标准答案”,但那是建立在充分理解的基础上。如果一个高手告诉你某个类应该是聚合,你却没有真正理解他的用意,这种情况可能导致你设计出一个艰涩的系统。所以正确性是因人而异的,你应该因地制宜,而不是人云亦云。
另外,高手告诉你的聚合也不见得是合适的,因为他不一定了解你的业务实际情况,聚合不仅受逻辑上的概念影响,并且还受到并发、性能等因素制约。
下面介绍选择聚合的一般性规律,可以帮助你进行一些决策。
第一步,寻找具有包含或组成关系的相关对象。
某些对象有附属的子项,比如订单Order和订单项OrderItem,它们具有包含关系,订单包含订单项的集合,或者可以认为一个订单是由N个订单项组成的。
找到的N组相关对象成为聚合的候选,能不能成为聚合需要经过后面的筛选。
第二步,考虑聚合内部的子对象集合,是否需要被聚合根外部的对象直接访问,如果需要,将其从聚合中移出,并建模为独立聚合。
虽然一个对象可能从概念上被另一个对象包含,但如果这种包含关系很弱,一般意味着子对象离开该聚合可能仍然有意义,外界对象希望能够直接和它打交道。
第三步,聚合内部导致并发冲突严重时,进行聚合拆分。
前两步是从概念上选择聚合,但聚合还受到其它因素影响,比如并发、性能等。
通过乐观离线锁可以保证,两次提交的聚合不会发生更新丢失。如果聚合只包含它本身,出现冲突的可能性就很小。但由于聚合中往往包含集合,甚至是多个集合,所以各个集合之间的修改可能导致并发冲突很严重。
比如一个聚合中包含两个实体集合,用户A正在编辑聚合的第一组实体集合,与此同时,用户B 开始编辑同一个聚合的第二组实体集合,第一个人提交成功,第二个人将更新失败。
如果用户经常需要对聚合内的不同集合进行单独编辑,这就说明聚合中的概念可能具有独立性,应该拆分出来。当聚合内部集合经常导致更新失败时,果断进行拆分是必须的。
设计一个大型聚合,除了可能经常导致并发冲突外,还可能导致低下的性能。比如酒店包含不同的房型,每个房型包含不同的价格政策,每种价格政策的价格又不同,价格可能每隔几天都会变化,如果把酒店作为一个大型聚合,把其它都作为集合包含进来,创建一个酒店聚合的开销可能很惊人。
当聚合中的子对象集合的层级超过2级,比如子对象又包含孙对象集合,需要考虑是否会导致并发和性能问题。另外一个聚合中包含子对象集合的数量也需要控制,比如一个聚合包含10个子对象集合,出现冲突的可能性就会很大。还有一个问题是,包含的子对象集合的元素个数也要考虑,比如一个商品,需要记录商品的价格变动历史,由于价格是商品的一个属性,所以可能会把价格变动历史也放到商品中。如果价格经常变动,比如每天2次,一年就会产生700条记录,可以看到,有些子对象集合刚开始数据量不大,但会持续增加,这种情况也需要进行聚合拆分。
如果一个聚合良好表达了一个整体概念,把附属信息都封装起来,并且没有导致并发冲突经常发生,还性能良好,可以认为设计相当成功了,当然,这很不容易。
识别聚合(边界)的方法
聚合的核心概念:
聚合可能包含一个或多个对象,有一个根,是数据修改和持久化的最小单元。
识别方法:
- 考虑对象是否可以独立存在
- 对象是否会直接和其他对象打交道
- 对象之间是否有不变性(Invariants),不变性指聚合内对象之间不管如何变化总是必须满足某个数据一致性规则
聚合设计与实现原则
- 将真正有不变性的对象聚合在一起
- 聚合应尽量小
- 聚合之间的关联用ID,不用引用
- 只保留必须的关联,单向关联
- 聚合内强一致性,聚合间考虑最终一致性
- 持久化聚合时,总是完全覆盖
并发问题及解决方案
上面介绍了聚合的基本概念,由于聚合更新与并发密切相关,下面将介绍应用程序开发中随时可能碰到的并发问题,并讨论相关解决方案。同时,将应用程序级别的并发模型与数据库事务级别的并发模型进行比较,这样可以对并发解决方案有更清晰的认识。
数据一致性问题
如果多个操作同时集中在同一条数据上,就可能造成并发,导致数据不一致。并发产生的数据不一致现象主要有以下几种:
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数据库为了解决读写并发冲突,首先引入了悲观并发模型,通过锁进制来解决读写冲突。
前面说过,脏读是必须要避免的问题。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 Framework也提供了IsRowVersion来配置乐观离线锁。
从上面的描述可以看出,乐观离线锁是应用程序级别的并发模型,与数据库的乐观并发模型没有什么关系,虽然Sql Server数据库的乐观并发模型也有行版本的概念。这也意味着你在应用程序级别使用的是乐观锁,而Sql Server数据库中却使用的是悲观锁。
使用乐观离线锁的前提是并发冲突机率很低,如果冲突机率很高,使用乐观离线锁虽然不会导致系统数据错乱,但会导致用户十分抓狂,因为每次保存成功都需要运气。
对于冲突机率很高的场景,需要引入悲观离线锁,下面继续介绍。
悲观离线锁
一个100人的客服团队,他们的工作是对某种申请单进行处理。客服处理一个申请单的时间大致5分钟,每成功处理一个申请单可提成1元,每当用户提交一个申请单,所有客服都可以看见。
一个编号为1的申请单过来了,为了争取拿到那一元钱提成,100名客服争先恐后的打开业务处理界面并开始授理。一名18岁的小妹眼明手快,只花了3分零2秒就提交了,“耶,1元到手”。另一名小妹花了3分零8秒,提交的时候,系统弹出一个友情提示“由于你的动作较慢,1元提成已经被人捷足先登了”。之后,接二连三的失败,大家只能感叹自己运气不好,另外有点走神,希望下一次可以拿到提成。
故事说完了,该系统采用乐观离线锁设计,虽然整个操作没有导致数据出错,但整个客服团队的办事效率低得吓人,近乎串行操作。
解决上面的问题,有两个常见办法。
一种办法是通过一套自动调度策略开发一个申请单自动分配服务,申请单一来,未处理前就已经确定好由谁处理了,这样就不会造成激烈的竞争,使用乐观离线锁也许就能满足需求。
另一种办法是使用悲观离线锁,开发一个锁管理器,锁管理器需要在数据库中建表,记录锁定时间,锁定人,业务编号等信息,在申请单列表界面的每行都放一个“锁定”按钮,当第一个人点击“锁定”按钮时,向锁管理器添加锁记录,一旦被锁定,其它人不能编辑操作界面或进行提交,界面控件应该处于冻结状态,更严格的甚至不能打开编辑界面。
使用这种方案有一些问题,在点击“锁定”按钮时可能存在并发问题,这可以通过为锁管理器的业务编号建立唯一索引,保证不会在同一个业务编号上插入两条锁定记录,当然,这要求你的业务编号可能是Guid,不然唯一性需要添加更多属性来识别。
既然允许锁定,就需要有解锁功能,解锁可以通过简单的删除锁定数据来完成。当编辑完成时,还需要对该业务编号自动解锁。也可能需要根据角色权限进行解锁,当某个客服锁定数据后就下班回家了,这导致其它人无法处理,所以更高级别的小组长可能允许对他的下级锁定的数据进行解锁。
如果需要强大的锁管理器,你可以仿照Sql Server悲观锁进行设计,加入锁模式、锁粒度、持续时间等要素。
可以看到,悲观离线锁,在实现和操作上并不简单,它只应该成为乐观离线锁的补充。
粗粒度锁与隐含锁
你可以把乐观离线锁放到每个实体中,但这样太复杂,把乐观离线锁放到聚合根上,则整个聚合都可以获得并发控制能力,这称为粗粒度锁。
另外,可以在聚合根和映射的层超类型上将乐观离线锁封装起来,称为隐含锁。
总结
聚合的概念
- 聚合包装一组高度相关的对象,作为一个数据修改的单元。
- 聚合根是聚合最外层的实体对象,它划分了一个边界,聚合根外部的对象,不能直接访问聚合根内部对象。
- 聚合体现了封装的思想。每个聚合有一个根和一个边界,边界定义了一个聚合内部有哪些实体或值对象,根是聚合内的某个实体;
- 聚合聚合根开始导航,绝对不能绕过聚内部的对象之间可以相互引用,但是聚合外部如果要访问聚合内部的对象时,必须通过合根直接访问聚合内的对象,也就是说聚合根是外部可以保持对它的引用的唯一元素;
- 聚合内除根以外的其他实体的唯一标识都是本地标识,也就是只要在聚合内部保持唯一即可,因为它们总是从属于这个聚合的;
- 聚合根负责与外部其他对象打交道并维护自己内部的业务规则;
- 基于聚合的以上概念,我们可以推论出从数据库查询时的单元也是以聚合为一个单元,也就是说我们不能直接查询聚合内部的某个非根的对象;
- 聚合内部的对象可以保持对其他聚合根的引用;
- 删除一个聚合根时必须同时删除该聚合内的所有相关对象,因为他们都同属于一个聚合,是一个完整的概念;
聚合的作用
- 简化系统设计。不要采用每个表对应一个实体,每个实体都是聚合的设计方式。
- 强制实施相关对象上的一致性规则。
- 对并发更新提供保护。
聚合的选择
- 寻找具有包含或组成关系的相关对象。
- 聚合内部的子对象需要被聚合根外部的对象直接访问时,进行聚合拆分。
- 聚合内部导致并发冲突严重时,进行聚合拆分。
识别聚合:
从业务的角度深入分析哪些对象它们的关系是内聚的,是一个整体;所谓关系是内聚的,是指这些对象之间会保持一个固定规则,固定规则是指在数据变化时必须保持不变的一致性规则。
识别聚合根:
- 判断是否有独立存在的意义
- 判断是否可以被从聚合外部直接访问
- 判断实体的ID是否会独立的出现在外面
聚合的例子
并发问题及解决方案
- Sql Server默认为悲观并发模型,会读写阻塞,不要使用With(NoLock)锁提示解决该问题,考虑通过添加缓存机制,读写分离,修改为乐观并发模型来消除读写阻塞。
- 通过为聚合根添加乐观离线锁解决丢失更新和重复更新的问题。
- 仅在乐观离线锁导致大量更新失败,且没有更好的解决办法时,才引入悲观离线锁。
- 应用程序级别的并发模型与数据库事务的并发模型没有太多关系,不要混为一谈。