首先说明一下什么是保存冲突问题,这里举一个例子。假设有一个订单001,操作员A首先打开了这个订单,在这之后操作员B也打开了这个订单。A对订单001做了一些修改,然后提交订单,B也对订单001做了一些修改,提交订单。这个时候,操作员B覆盖了操作员A对订单001的修改。要解决这个问题,有两大类方法,分别是悲观锁与乐观锁。
首先来说说悲观锁方法。悲观锁方法的解决方案就是,当A打开了订单后,对订单001加入了一个锁。在操作员A提交对001的操作前,其它的操作者只能对001查看,而不能对001进行更新,从界面设计角度来说,当其它人打开订单001时,界面上的提交按钮是不可用状态。关于悲观锁,网上有很多文章的描述都不准确,那些文章认为悲观锁是对操作的数据加入数据库级锁来锁定数据,实际上这样会给数据库带来很大的开销,对于Sqlserver2000数据库来说,如果使用了该方法,所有访问到订单001的查询与报表都会被阻塞,严重的影响用户体验。对于Oracle数据库这种基于版本来管理并发的数据库来说,也会由于加入了过多的锁而极大的影响读取性能。正确的方式是在订单表中增加一个锁定者ID,当操作员A打开订单001时,将订单001的锁定者ID设置为操作员A,然后当操作员B打开订单时,如果发现锁定者ID不为空且不等于操作员B,就将提交按钮设置为不可用。其实在设置锁定者ID的时候也是要加一定的数据库锁的,否则也会出现问题,这里就不展开说了。
另外一种方法是乐观锁。这种方法的解决方案是,在订单表中设置一个Version字段,当操作员A读取订单001时,记录Version的值,在保存时,如果数据库中的Version与之前读到的Version不相等,则报错,否则保存数据并重新生成版本号。这样的结果是,当操作员A保存数据时,如果数据在之前已经被操作员B保存,系统会报错。虽然操作员A不得不放弃对订单001的修改,重新读取数据,但这样好歹不会覆盖其它操作员对订单001的修改。乐观锁有多种实现方式,一种是对数据表的更新操作增加触发器,重新生成版本号,这样做的好处是数据无论是在系统中被其它人更新还是被直接从数据库中更新,都会被侦测到,缺点是如果系统支持多种数据库,需要针对不同的数据库分别编写触发器。另外一种方法是不设置Version字段,在读取数据时记录订单001的全部状态,在更新时比较数据库中的数据是否与读取时的状态一致,如果有任何一个字段的状态不一致就提示错误,在这个方案中,订单的全部状态起到了Version字段的作用。使用这种方法的好处是可以不依赖与具体的数据库编写而且可以侦测出数据直接从数据库中更新的情况,缺点是如果数据表的字段比较多,或有些字段的长度比较大,性能会比较差。最后一种方法是使用数据访问框架(如Nhibernate)来管理Version字段。这种方法的好处是不依赖与具体的数据库,而且性能比较高,缺点是不能侦测出数据不通过系统直接在数据库中被修改的情况,不过这种情况非常罕见,基本可以忽略。这里顺便提一下,Nhibernate在处理Version时有一个问题,就是当Nhibernate向数据库提交数据出现数据库错误时(如字段超长),内存中对象的Version字段还是会被修改,而数据库中的Version字段没有修改,这个时候再次保存数据时系统就会提示数据已经被其它用户修改!这个问题主要会对一些CS架构的系统产生影响,因为很多CS架构的系统,界面是直接绑定业务实体的,所以当数据保存失败,再次提交时,还是使用相同的实体提交,这个时候就会遇到那个问题。不过总的来说这个问题出现的几率都是比较小的,而且这个问题也可以通过改进自己的数据访问框架来解决。对于乐观锁,我个人还是比较推崇最后一种方法。
最后聊一聊这两种锁的使用场景。乐观锁适用于保存冲突出现几率比较小的场景,咱们做的绝大部分系统都可以归为这一类。而对于悲观锁来说,首先它要在读取数据的同时更新UserId字段,而且为了保证并发还要在数据库中加入一些行级锁定,所以它的性能会稍微差一些。而且如果锁定数据的用户非正常退出了,还需要有人手工的解除锁定,会影响用户体验,所以除非保存冲突的几率比较高,否则不要使用该方法。