2011年9月25日星期日

企业应用程序中的并发控制


  在企业应用程序中,如何考虑并发是一件极为重要的事情:企业应用一般都要求严格的数据完整性和一致性。然而,在实际的开发过程中,很多企业应用程序都不考虑并发,造成了脏数据随处可见,需要浪费大量人力物力来维护的境地。
  一般而言,企业应用中一共有两类场景需要考虑并发加锁:
    1. 多个事务并发时,比如同时有很多个事务修改同一个对象(数据库表),典型的表现为多个线程对应的多个事务同时修改一个对象。
    2. 一个用例要经过一系列的事务操作才能完成的,比如典型的WEB应用中修改订单的行为:用户发起一个请求,然后服务端程序响应,读取订单,返回给前端表现层,事务完成;用户修改订单后提交,此时系统会再启动一个事务,修改订单后返回。我们把这种场景成为离线模式。
  对应“在线”模式场景和离线模式场景,都有两个典型的解决方式:乐观锁和悲观锁。乐观锁是先尝试,失败了再说的处理方式,而悲观锁则是估计会失败,所以我先把对象都锁住。两种锁模式要视实际业务需求来选择使用。
一、在线乐观锁实现原理:
  在线乐观锁一般会有三种实现方式:基于版本号、基于时间戳、基于所有字段。基于版本号和基于时间戳类似,都是在更新数据的时候比较该值和数据库中是否相同,如果相同则表示该对象从读取到写回的这段时间内并没有别的线程(事务)修改过,此时可以认为修改是有效的。过程一般如下:开始-->读取数据-->执行业务逻辑-->保存数据-->结束。对应的SQL语句如下:
  update my_table set version=version+1,business='new_value' where version=V1 and other_condition
此时可以根据更新的数据行数来确认是否执行成功,如果更新的数据行数为0,则可以根据实际业务的需要选择抛出乐观锁失效或者简单的选择忽略。
  基于所有字段的乐观锁和基于版本号的基本类似,只是将条件改成了所有字段的旧值。语句如下:
  update my_table set name='new_name',address='new_address' where name='old_name' and address = 'old_address' and email='old_email' and phone='old_phone'
  一般基于所有字段的在线乐观锁只在不能修改现有表结构的情况下使用,否则不推荐使用。因为该方法存在一些缺陷,比如比较浮点数的时候不能正确比较等。好处是不需要修改scheme。
  Hibernate内置了对于在线乐观锁的支持,使用起来很简单,只需要在其映射文件(*.hbm.xml)中配置声明version字段即可。这样Hibernate在保存数据的时候会校验version和读取时一致,不一致则抛出StaleObjectStateException的异常。配置片段如下所示:

  "version" column="version" />

二、在线悲观锁实现原理:
  在线悲观锁一般都使用数据库的机制来实现,比如Oracle的 for update语句。使用for update语句时,如果没有其他事务锁定执行的记录行,则可以直接获取锁并返回;如果锁已经被其他事务所获取了,则当前事务就会等待,
直到获取到锁的事务释放该锁以后才能继续运行。
  Hibernate使用锁的方式也较为简单,比如执行load方法的时候指定锁模式为LockMode.UPGRADE即为悲观锁,此外还能在session和Query中指定悲观锁。
  示例代码如下:
    调用 Session.load() 的时候指定锁定模式(LockMode)
    调用 Session.lock()
    调用 Query.setLockMode()。

三、离线乐观锁实现原理:
  离线锁都跨越了多个事务,所以在线锁的场景并不适用离线锁的场景。设想如下修改订单的场景:
  1. A用户读取订单Order-->编辑订单-->提交更改-->结束
  2. B用户读取订单Order-->编辑订单-->提交更改-->结束
  其中的读取订单是一次HTTP请求,也对应一个事务;编辑订单是在浏览器中完成;提交更改是另外一次HTTP请求,对应另外一个事务。即修改订单这个用例横跨了两个事务。而两个不用的用户同时修改了一个订单,这会导致后提交
更改的用户把前面用户的修改结果覆盖,造成“写丢失”的情况,这在一些场景中是不允许发生的。
  类似前面的在线乐观锁模式,系统也使用类似的控制策略。所不同的是,我们把verion保存在session中(对于没有session的集群应用来说,可以采用另外的方法)。在每个用户读取到订单的时候,都将version的值保存在session中,在第二次提交更改的时候比较该version的值,如果发现不同,则抛出“乐观锁失效”的异常给用户提示。其实现的基本原理就是在一个能够横跨多次事务的地方保存最初的版本号,然后进行比较判断。
  还可以使用脱管对象来进行离线乐观锁。具体的做法是,把业务层返回的持久对象脱钩后放入session,然后等第二次事务(这里是提交更改)开始的时候,先进行持久对象的挂钩操作,此时如果持久对象的version已经被修改过了,则持久层框架会抛出“StaleObjectStateException”异常。这种做法的好处是简单易行,坏处就是把脱管对象放入HTTP Session将会占用大量的内存。
  对于没有session的应用程序(很多企业级应用程序为了简化集群而不使用http session),可以采取如下措施:在一个用例的多次事务中传递version。比如上述场景在第一次读取订单的时候就返回持久对象的version到客户端保存。在用户编辑完订单后,提交更改的时候,也同时提交version信息到服务端。服务端使用该version与从数据库中load的持久化对象的version进行比较。该措施的缺点是增加了系统的复杂性,也增加了网络的开销(尽管网络开销很小)。

四、离线悲观锁实现原理:
  离线悲观锁一般都采取应用程序控制的方式。典型的做法是在数据库中维持一张系统锁表,可以如下设计表结构:
  sys_lock(object_code,object_id,locker,create_date)
  其中object_code为对象编码,可以是表名或者持久化对象类名;object_id是锁定的记录主键ID;locker是悲观锁的持有人;创建时间可以用在自动释放锁上面(比如设定一个机制把锁定时间超过24小时的锁释放掉)。
  在要对对象进行更新以前,先获取悲观锁。获取到锁以后再更新数据,然后释放锁。
  对应上面的场景,这里变为:
      用户尝试获取对象锁,成功后-->编辑订单-->提交更改-->释放锁-->结束
  这里的获取对象锁和返回对象在同一个事务中,提交更改和释放锁在另外一个事务中。
  使用悲观离线锁时需要注意两点:
  1. 由于悲观离线锁是应用程序级别的锁,所有需要控制的用例都必须遵循 获取锁-->更改-->释放锁 的模式,如果有人不遵守,则会不受悲观锁的控制。
  2. 注意锁的释放。可以采取定时释放和后来者抢锁的策略。具体视需求而定。