Potala(3)——Transaction

   在设计一个系统时,事务和并发控制是非常重要的考虑点。而比较多见的事务主要是数据库事务,所以这里重点探讨数据库事务及其并发控制。本文将从事务的基本属性开始,接着讨论数据库级的并发控制,再到应用程序级的并发控制,最后还将研究一些具体的问题。


事务的属性:

   亦即我们常说的ACID标准。

   A:Atomicity(原子性),即保证一系列操作要么全部成功要么全部失败的能力,这个大家应该都很清楚,没什么好说的; 

   C:Consistency(一致性),事务始终工作在一组数据上;

   I:Isolation(隔离性),这将是本文讨论的重点;

   D:Durability(持久性),意味着事务一旦完成,所带来的变化将是持久的。


数据库级的并发控制:

   可能习惯了ADO.net的朋友很难理解为什么在j2ee中,所有的数据库操作都需要封装在事务中。事实上,哪怕是一个最简单的查询操作,也是包含在数据库系统所提供的数据库事务中的。就算是我们常说的非事务数据访问,其实也不是脱离了数据库事务,只是开启了数据库事务的自动提交模式而已。所以,理解数据库系统提供的事务隔离级别对整个系统设计的意义就很明显了。一般来说,数据库系统提供以下四个基本级别(这是由ANSI SQL标准定义的,这些级别都是可以通过数据库管理系统来配置):

   Read Uncommitted:允许脏读取,即允许读事务读取写事务未提交的操作,写事务不阻塞读事务(但阻塞其他写事务);

   Read Committed:不允许脏读取,即写事务阻塞所有其他事务,读事务不阻塞其他任何事务;

   Repeatable Read:所谓Repeatable Read,是指在同一个读事务中两次读取同一条记录,得到的结果应该是一样的。要实现这个隔离级别显然需要读事务阻塞写事务(但不阻塞读事务)。

   Serializable:最严格的隔离级别,所有事务都只能一个接一个地进行。

  在设计系统时需要使用哪个隔离级别需要根据不同的需求仔细斟酌。一般来说,Read Uncommitted级别有点偏低,一个事务的回滚或失败将影响到另一个事务,所以不太常用;Serializable级别偏高,多并发时性能比较差,使用得也不多。我们用得比较多的是Read Committed级别,在这个级别下,配合使用应用程序级的并发控制可以同时获得比较好的隔离性和性能。


应用程序级的并发控制:

在应用程序级别,并发控制主要包括乐观锁和悲观锁策略。

考虑两个不同的会话(Session)同时访问数据库。Session1在一个事务中读取了一行数据,接着Session2在另一个事务中也读取了这行数据。然后Session1和Session2都对数据进行了修改。最后,它们都想把数据更新到数据库中。这时,问题出现了:如果Session1先于Session2更新数据库,则Session2对数据所做的修改就会覆盖Session1中所做的修改。大部分情况下,这都不是我们希望看到的。

乐观锁策略就是用来解决这个问题的。应用乐观锁策略后,对每行数据的更新都将使这行数据的版本号增加,而每个Session在更新数据库之前将必须先检查这行数据的版本是否和当前版本一致,如果不一致将放弃更新。这就保证了后来的更新操作不会被覆盖前面所做的更新。但这也导致了一个问题:后来的更新将丢失。事实上乐观锁的理念就是认为这种冲突是不常见的,所以丢失后来的更新也是可以接受的,这也是所谓的“乐观”的涵义。

但乐观锁依然没有办法保证Repeatable Read隔离级别,这时我们就需要悲观锁了。悲观锁将在一个Session的范围内锁住某条数据,并防止其他事务对它进行更新(这个和数据库事务中读事务对写事务的阻塞策略有点相像)。

当然,悲观锁也只能在一个会话中锁住某条数据。如果需要跨会话锁数据,则需要使用离线锁了。但这种锁代价比较高,所以一般使用得也不多。


一些具体的问题:

如上所述,在系统设计时考虑事务的并发是一件很让人头痛的事情。 但事实上很多复杂性都是因为我们在设计Domain Model时聚合边界设计得不够清晰造成的。比如这样一个例子。假设有一个Item对象,用户可以对它进行评论,评论保持在Comment表中,通过一个外键指向Item表。如果把Comment包含到Item的聚合边界中来,将可能导致严重的问题:两个不同的用户读取了同一条Item,然后用户1提交了一条新评论后,由于这个Item的版本已改变,用户2将无法再对这个Item进行评论。当然,这只是最简单的一个例子,并没有多少代表意义,更深入的介绍可以参考Eric Evans的《Domain-Driven Design》第6.1节。

第二个问题是使用写事务时必须非常谨慎。在Read Committed级别下,由于写事务会阻塞其他所有事务,所以过于频繁的写事务将严重影响系统性能。可能大家都不觉得自己会犯这样的低级错误,但事实上,在有些场景下这个问题却极其容易被忽视。比如用户查看某个Item时,我们可能会去做对这个Item的查看次数加1的操作。当系统并发数大时,这将导致非常频繁地更新操作。特别是如果我们使用事务来传播Session的话(这将在后面的笔记中具体介绍),将会导致频繁的长时间写事务,极大地影响了系统性能。所以比较好的策略是把查看次数先缓存起来,到达一定次数时再去一次性更新数据库。


posted @ 2008-12-17 20:48  Marco Zeng  阅读(307)  评论(0编辑  收藏  举报