Django项目开发实战
上QQ阅读APP看书,第一时间看更新

3.4 数据库并发控制

在数据库系统中,多用户同时访问或更改数据时,可能会引发冲突。为了保持数据完整性,并协调同步事务,并发控制机制是非常重要的。本节将介绍数据库并发控制方法,并学习使用Django来应用这些方法。

3.4.1 冲突

假设进程A和进程B从Product表中读取了同一行,在改变了数据后,同时把新版本写回数据库,这时哪个改动会生效呢?进程A生效?进程B生效?还是两者同时生效呢?

要了解如何在系统中实现并发控制,首先必须要了解冲突,我们可以避免冲突,或者检测冲突然后解决它。在现代软件的开发项目中,并发控制和事务不仅仅在数据领域存在,而是所有的架构层都存在相关的问题。因此,在数据库中解决冲突的办法也能够为其他领域解决类似的问题提供参考。

当两个活动(可能是两个事务)尝试更改记录系统中的相同实体时,这两个活动可能会发生冲突。在3种情况下,两个活动会互相干扰。

  • 脏读。活动A从记录系统中读取实体,然后更新记录系统,但是不提交更改(如更改尚未完成)。这时活动B读取实体,获得了未提交版本的副本。活动A回滚了更改,将实体恢复到原始状态。此时B读到的实体版本因为从未提交,因此不被认为实际存在,这种情况称为“脏读”。
  • 不可重复读。活动A从记录系统中读取一个实体并创建它的副本,此时B从记录系统中删除了这个实体,那么现在A有一个没有真实存在的实体的副本。
  • 幻影读。A从记录系统中检索实体集合,然后根据某种搜索条件(如“所有名字里面带有凉鞋的商品”)来记录它们的副本。然后B创建新的实体,新的实体正好满足搜索条件(如将“红色凉鞋”插入数据库),并保存到记录系统。如果A重新应用搜索条件,则将会获得不同的结果集。

如果允许缓存中的过时数据存在,则并发的用户/线程越多,发生冲突的可能性越大。

现在我们来看一个更具体的例子。假设我们要为电商网站添加一个类似银行账户的功能,首先要创建简单的模型,实现存款和取款功能,代码如下:

现在假设有两个用户对同一个账号进行操作:

(1)A获取账户余额100元。

(2)B获取账户余额100元。

(3)B提现30元,将账户余额更新为70元。

(4)A存入50元,将账户余额更新为150元。

我们期待的正确结果是120元,但是现在账户余额是150元。出现这种现象的原因是在B提现后,A存储在内存中的数据已经过时。

为了防止这种情况发生,需要确保正在处理的资源在工作时不会发生改变。

3.4.2 悲观锁

悲观锁是指实体在应用中存储(通常是以对象的形式)的整个生命周期内,在数据库中被锁定。悲观锁用于锁定限制或者阻止其他用户使用数据库中的这个实体。

写锁表示锁的持有者打算更新实体,在此期间禁止任何人读取、更新或者删除实体。读锁表示锁的持有者不希望实体在锁定期间被改变,它允许其他人读取实体,但是不能更新或删除该实体。锁的范围可能是整个数据库、表、多行或单行。这些锁分别称为数据库锁、表锁、页锁和行锁。

悲观锁的优点是易于实现,并且保证对数据库的更改是一致和安全的;主要的缺点是此方法不可扩展。当系统有许多用户时,或者当事务涉及更多数量的实体时,或者当事务长时间存在时,不得不等待锁释放的情况会大大增加,因此会限制系统实际可以同时支持的用户数量。

悲观锁要求在完成任务之前,应该完全锁定资源。当用户在处理一个对象时,没有其他人可以获取对该对象的锁定,那么就能确定该对象没有被更改。在Django中,可以使用select_for_update方法来实现悲观锁,示例代码如下:

说明:

  • 使用select_for_update( )方法告诉数据库锁住对象直到事务完成。
  • 使用atomic( )方法开启事务。
  • 所有的业务逻辑块都在事务内执行。

还是使用之前的例子来看看悲观锁是如何工作的:

(1)A要求提现30元。A获得锁,此时账户余额是100元。

(2)B要求充值50元。B试图获取锁,失败;等待锁释放。

(3)A提现30元。账户余额为70元,锁被释放。

(4)B获取锁,账户余额为70元。充值完成后,余额120元。

(5)B释放锁。

在上面的代码中,B等待A释放锁。可以在调用select_for_update时传入nowait=True,让B不再等待,而是抛出DateBaseError异常。

3.4.3 乐观锁

在多用户系统中,冲突不频繁的现象是很常见的。在这样的情况下,乐观锁会成为可行的并发控制策略。解决思路如下:程序员在知道发生冲突概率很低的情况下,不选择试图阻止它们,而是选择检测冲突,并且在冲突发生的时候解决它。

应用程序将对象读入内存的过程中,对数据添加读锁并在读完后释放。在该时间点,可以对该行进行标记以便检测冲突。然后应用程序操作对象,在要更新数据的时候,先获得对数据的写锁定,并读取数据源,以便确定是否有冲突。在确定没有冲突的情况下,程序更新数据并释放锁。如果检测到冲突,如数据在最初被读入内存后被另一个进程更新,那么冲突需要被解决。

确定是否发生冲突有两种基本策略。

  • 使用唯一标识符标记源数据。源数据在每次更新时都会被唯一标识。在更新的时候检查标识符,如果其和最初的值不同,那么说明数据源被改了。
  • 保留源数据的副本。在更新操作时检索源数据,并与最初检索的值进行比较。如果值不一样,那么说明发生了冲突。

唯一标识符有几种不同的类型。

  • 日期时间戳(这个值由数据库服务器来分配,因为不能期望所有计算机的时钟都同步)。
  • 增量计数器。
  • 用户ID(每个人都有唯一ID,并且只登录一台机器,并且应用程序确定在内存中只存在一个对象的副本时,这种方法才有效)。
  • 由全局唯一代理键生成器生成的值。

还是以上面的例子为例,首先在模型上添加字段来跟踪对象所做的更改,代码如下:

    version = models.IntegerField(default=0)

然后更新对象,代码如下:

说明:

(1)直接操作对象。

(2)约定每次操作对象,版本号自增。

(3)只有在版本号没有改变的情况下才执行update( )方法。如果对象没有更新,那么改变它;如果对象已经改变,那么filter( )将不会返回得到任何结果。

(4)Django会返回被更新的行的数量。如果updated的值是0,说明更新失败了。

在我们的场景中,乐观锁的工作过程如下:

(1)A获取账户,余额是100元,版本是0。

(2)B获取账户,余额是100元,版本是0。

(3)B要求提现30元,成功。余额是70元,版本是1。

(4)A要求充值50元。版本为0的记录已不存在,充值失败。

3.4.4 解决冲突

在解决冲突的时候有5种基本策略:

  • 放弃。
  • 展示问题让用户决定。
  • 合并改动。
  • 记录冲突让后来的人决定。
  • 无视冲突,直接覆盖。

知道冲突的粒度也很重要。假设两个人操作同一个Product实体的副本,一个人更新了名字,另一个人更新了创建时间。两个人更新的是同一个实体的不同粒度的数据,这样的操作造成的数据冲突很容易恢复到正确状态。

简单起见,许多项目团队会选择单一的锁定策略并将其应用到所有表。当应用程序中的所有表或至少大多数表具有相同的访问特性时,这个方法是很有效的。然而,对于更复杂的应用程序,可能需要基于各个表的访问特性实现几个锁定策略。按照不同的场景,可以选择不同的策略,如表3.1所示。

表3.1 不同策略的应用