我们知道事务并发(如果不处理)会引发一些问题,一个典型的例子就是银行存取款的问题:比如一个账户,用户A和B同时对它操作。假设账户里有1000元,A要往里存100元,而B要取100元,假设这两个事务同时进行,会出现什么情况那?
首先A的事务查询账户里有1000元,B事务在A事务没有提交前(或者说执行期间)查询数据库也是1000元。这时A的事务提交将数据改为1100元(1000+100),而B事务后提交将数据改为900元(1000-100),最终结果就是900元。我们知道,正确的结果应该是1000元。当然如果B事务先提交,最终的结果也是不对的。
事务并发到底可能会引起那些后果呢?有如下几种情况:
①第一类丢失更新:撤消一个事务时,把其它事务已提交的更新的数据覆盖了。
②脏读:一个事务读到另一个事务未提交的更新数据。
③幻读:一个事务执行两次查询,但第二次查询比第一次查询多出了一些数据行。
④不可重复读:一个事务两次读同一行数据,可是这两次读到的数据不一样。
⑤第二类丢失更新:这是不可重复读中的特例,一个事务覆盖另一个事务已提交的更新数据。
对于第一种情况,只要数据库都能避免,事务之间是不可能交叉影响的,事务具有隔离性,所以不用考虑。
对于第二种情况,一般情况下数据库会有一个默认的隔离级别,通过锁来实现,也不会出现,比如oracle就不支持脏读,不用考虑。
对于第三种情况,很少认为是问题,比如你要进行一些查询,很少查了一遍,接着又查了一遍,即便是有这种情况,多出的数据肯定是有新的业务行为发生了,所以一般也不处理这种问题。
一般处理并发带来的问题就是指的第④⑤中情况,简单说就是对于同一条数据,多个事务你改我也改,或者说一个改着一个读等等。
为了解决多个事务并发会引发的问题。大多数数据库系统提供了四种事务隔离级别供用户选择。
Serializable:串行化。隔离级别最高
Repeatable Read:可重复读。
Read Committed:读已提交数据。
Read Uncommitted:读未提交数据。隔离级别最差。
数据库系统采用不同的锁类型来实现以上四种隔离级别,具体的实现过程对用户是透明的,用户应该关心的是如何选择合适的隔离级别。对于多数应用程序,可以优先考虑把数据库系统的隔离级别设为Read Committed,它能够避免脏读,而且具有较好的并发性能。(oracle的默认级别就是Read Committed)
当数据库系统采用Red Committed隔离级别时,会导致不可重复读和第二类丢失更新的并发问题,在可能出现这种问题的场合。我们上面的例子就属于第二类更新丢失,无法通过Red Committed解决。
可以在应用程序中采用悲观锁或乐观锁来避免这类问题。
乐观锁:乐观锁的原理极为简单,Hibernate中通过版本号检查来实现后更新为主,这也是Hibernate推荐的方式。做法是在数据表中多加一个字段Version,事务如果对数据进行变更操作,就将准备的数据里的version+1;然后去跟数据库里的版本比较,如果发现自己的Version不比数据库里的大(说明这个过程中别的事务将它+1了,也可能是在update后的where条件加上version=旧值的方式)就无法更新成功。乐观锁一般需要程序自己实现,可以预见的未来,或许数据库会加入乐观锁特性。
悲观锁:假定当前事务操纵数据资源时,假定肯定还会有其他事务同时访问该数据资源,为了避免当前事务的操作受到干扰,先锁定资源,一直到事务执行完毕释放。悲观锁往往是通过使用数据库的锁机制实现的,比如oracle中就是通过使用select for update的形式实现的。也可以使用分布式锁,比如使用redis或者zookeeper实现的分布式锁。
不管悲观锁还是乐观锁,其目的只有一个,把并行变为串行(既然不能并行)。悲观锁一直锁着资源,而如果此事务由于某些原因一直没有执行完(比如异常),其他的事务只能一直等待,设计不当还容易死锁;而乐观锁有点线程抢占cpu的思想(cas的思想,如果失败一般都做几次重试)。
两种锁的使用应该在考虑业务并发后作出选择,并不是悲观锁就一定比乐观锁性能差,一般来说竞争激烈时悲观锁比较好,因为这个时候乐观锁失败的概率比较大,需要不断重试,性能反而没有悲观锁好。如果几乎没有竞争,乐观锁肯定优于悲观锁,因为乐观锁就是个普通的update。
最后,不是所有的update并发都需要处理,我们的程序里有太多的update,没加乐观锁和悲观锁也没有问题,大部分属于这些情况:
- 数据只可能被一个用户修改,比如我们的帖子,只可能被自己修改。
- 操作本身是原子的,上述转账的例子之所以会有问题,原因在于事务包含一次读和写,整个操作不是原子的。以投票为例,你要给一个字段+1,SQL写成update tab set num = num+1,就没有问题,但是如果你先select,在程序里+1,再update tab set num = ${num}就有问题。update本身在数据库会开启一个事务,而程序里的一个事务里恰好只有一个原子性的update语句,是不存在并发问题的。如果程序的数据处理粒度比较大,乐观锁实现起来会比较复杂。
- 其他情况应该还有很多,只要经过思考不需要处理的,不加锁肯定比加锁有更好的性能。