在上一篇中,我们总结了事务处理中5类典型的问题。在这一篇,我想总结一下如何在保证并发效率的情况下去解决。如果让我们自己来想办法解决这些问题的话,毫无疑问,我们会用数据库锁来解决,然而自己去控制数据库锁是非常复杂的。其实,数据库提供了自动锁机制。只要用户指定会话的事务隔离级别,数据库就会分析事务中的SQL语句,然后自动为事务操作的数据资源添加上适合的锁。此外数据库还会维护这些锁,当一个资源上的锁数目太多时,自动进行锁升级以提高系统的运行性能,这一过程对用户来说完全是透明的。
1. 数据库锁相关概念
在数据库中有两种基本的锁类型:排它锁(Exclusive Locks,即X锁)和共享锁(Share Locks,即S锁)。当数据对象被加上排它锁时,其他的事务不能对它读取和修改。加了共享锁的数据对象可以被其他事务读取,但不能修改。数据库利用这两种基本的锁类型来对数据库的事务进行并发控制。
直接使用数据库锁往往会影响系统性能,如何在保证数据安全的同时,也保证性能呢?有两个概念需要了解一下:乐观锁和悲观锁。
a. 乐观锁:乐观锁大多是基于数据版本记录机制实现,他认为同一时间修改一条记录的概率是很低的。在基于数据库表的版本解决方案中,一般是通过为数据库表增加"version”字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。乐观锁机制避免了长事务中的数据库加锁开销,大大提升了大并发量下的系统整体性能表现。NHibernate在其数据访问引擎中内置了乐观锁实现。需要注意的是,由于乐观锁机制是在我们的系统中实现,来自外部系统的用户更新操作不受我们系统的控制,因此可能会造成脏数据被更新到数据库中。
b. 悲观锁:悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。如一个金融系统,当某个操作员读取用户的数据,并在读出的用户数据的基础上进行修改时(如更改用户账户余额),如果采用悲观锁机制,也就意味着整个操作过程中,数据库记录始终处于加锁状态。所以,采用悲观锁进行控制时一定要考虑清楚。
2. 事务隔离级别
上面那些概念,在我们开发应用时,一般不用我们去操心,因为前辈们都帮我们做好了。我只需要理解思路,用好就行。ANSI/ISO SQL 92标准定义了4个等级的事务隔离级别,在相同数据环境下,使用相同的输入,执行相同的工作,根据不同的隔离级别,可以导致不同的结果。不同事务隔离级别能够解决的数据并发问题的能力也是不同的。 下面有一张表:
隔离级别 | 脏读(DR) | 不可重复读(UR) | 幻读(PR) | 第一类丢失更新(LU) | 第二类丢失更新(SLU) |
READ UNCOMMITED | 允许 | 允许 | 允许 | 不允许 | 允许 |
READ COMMITTED | 不允许 | 允许 | 允许 | 不允许 | 允许 |
REPEATABLE READ | 不允许 | 不允许 | 允许 | 不允许 | 不允许 |
SERIALIZABLE | 不允许 | 不允许 | 不允许 | 不允许 | 不允许 |
事务的隔离级别和数据库并发性是对立的,两者此增彼长。使用SERIALIZABLE隔离级别的数据库并发性最低,这种隔离级别下,事务之间是严格串行。SQL 92定义READ UNCOMMITED主要是为了提供非阻塞读的能力,推荐使用REPEATABLE READ以保证数据的读一致性,不过用户可以根据应用的需要选择适合的隔离等级。选取数据库的隔离级别时,应该注意以下几个处理的原则:
a. 排除READ UNCOMMITED,因为在多个事务之间使用它将会是非常危险的。事务的回滚操作或失败将会影响到其他并发事务。第一个事务的回滚将会完全将其他事务的操作清除。
b. 绝大部分应用都无须使用SERIALIZABLE隔离(一般来说,读取幻影数据并不是一个问题),此隔离级别也难以测量。目前使用序列化隔离的应用中,一般都使用悲观锁,这样强行使所有事务都序列化执行,当然如果并发不是瓶颈时,且没有长事务时,使用SERIALIZABLE是最方便且最安全的。
c. 剩下的也就是在READ COMMITTED和REPEATABLE READ之间选择了。我们先考虑可重复读取。如果所有的数据访问都是在统一的原子数据库事务中,此隔离级别将消除一个事务在另外一个并发事务过程中覆盖数据的可能性(第二个事务更新丢失问题)。这是一个非常重要的问题。