在稍微复杂点的应用中,特别是企业应用。并发控制几乎是必须要遇上的问题。比如说,我们要将客户的帐户余额读出来,更改,保存回数据库。在做这个实现的时候,第一反应我们会采用以下的方案:

1
2
3
4
SELECT balance FROM accounts WHERE id = 1;
--选出纪录后,在程序中进行更提,如减少100。假定原来的是300,减去100后,余200;
--然后写回数据库
UPDATE accounts SET balance = 200 where id = 1;

乍一看没什么问题,即使有单元测试,这样的代码测试一百遍也是通过的。但实际在布置到生产环境中,往往会出现不同的用户同时对 balance 进行读改写的操作。这样的情况下就会发现余额跟我们预期的值会有不一样的情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 会话1
SELECT balance FROM accounts WHERE id = 1;
--返回 balance = 300

UPDATE accounts SET balance = 200 where id = 1;
--更新 balance = 300 - 100 = 200


// 会话2
SELECT balance FROM accounts WHERE id = 1;
--balance = 300

UPDATE accounts SET balance = 200 where id = 1;
--balance = 200

在单元测试中,或者是单进程单线程的程序中, 这样的问题是不会出现的。但在生产环境中往往是多进程或者多线程的环境,偶然机率会出现同不会话同时对余额进行修改。这样的程序设计逻辑就会出现问题,得出和我们预期不一样的结果。并且出现这种问题的时候,排查调试几乎是非常困难的。所以在编写程序的过程中就要预先对并发控制和事务隔离有预防概念,在问题出现之前就先行对其进行控制。

你可能会想,用事务来解决不就OK了吗?确实,在SQL的定义中,事务是指由多条SQL语句组成,其行为就像一条SQL语句一样,要么成功,要么全部回滚。这样就可以避免悲剧发生,但如果只是简单的用 BEGIN 和 COMMIT 包装上面的语句,其结果还是一样的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 会话1
BEGIN
SELECT balance FROM accounts WHERE id = 1;
--返回 balance = 300

UPDATE accounts SET balance = 200 where id = 1;
--更新 balance = 300 - 100 = 200
COMMIT

// 会话2
BEGIN

SELECT balance FROM accounts WHERE id = 1;
--balance = 300

UPDATE accounts SET balance = 200 where id = 1;
--更新 balance = 300 - 100 = 200
COMMIT

在这个事务中,锁只发生在UPDATE语句中,而两个事务之间并没有冲突,结果是两条SQL都会被正确执行。单纯的事务包装并不能解决冲突的问题,这牵涉到一个事务隔离等级的问题。这个可以通过对所有需要操作的表进行 Lock Table 操作,但是锁表又会牵涉到更为复杂的死锁问题。

并发几乎是每个数据库都要面对的问题,POSTGRE向来以强大健壮而著名,少了不会提供解决的方式:

  1. 使用SQL代替读改写的操作
  2. 利用行锁 SELECT … FOR UPDATE
  3. 利用SERIALIZABLE串行化事务隔离
  4. 使用Optimistic Concurrent Control 乐观锁

使用SQL代替读改存操作

这个是最简单原始的解决方式,我们知道 UPDATE 会产生一个行锁,每条 UPDATE 语句操作都是具有原子性的。那我们可以利用 SQL 语句代替掉原来的读改写操作就可以了。

1
UPDATE accounts SET balance = balance - 100

这样一来,无论多少个会话,每一条会话的 SQL 操作都是串行的。我们不必担心并行操作带来的结果错误。这样的解决方式对应这种简单的需求是没问题的,但如果业务逻辑比较复杂,在减之前需要其它的数据来源,或者需要对数据进和检测再决定是否进行 UPDATE 的情况显然就不适用了。比如说我们一般要对余额的多少进行检测,再决定是否能再减下去,而在检测过程中,数据就可能产生变动。

利用行锁

除了表锁,PostgreSQL 提供了更为轻量的行锁操作。在事务操作过程之中,我们在对需要操作的数据进行 SELECT 的时候可以在后面追加一个 FOR UPDATE 的后缀来产生一个行锁。行锁的优势在于只对相关的行进行锁定。避免表锁带来的具大开销,同时也避免影响到其它会话对其它行的访问。

使用了行锁的时候,当有其它的会话尝试对同一行进行 SELECT … FOR UPDATE 或者 UPDATE 操作的时候,将会被暂停执行。直到前面的会话完成它的事务,即 COMMIT 或者 ROLLBACK 之后才会继续执行。按我们的示例,当会话1 COMMIT 它的事务后,会话2 的 SELECT … FOR UPDATE 才会被执行,并且返回的值是200,即会话1完成UPDATE之后的新值。而接下来的操作后的结果也会跟我们预期的一样。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 会话1
BEGIN
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
--返回 balance = 300

UPDATE accounts SET balance = 200 where id = 1;
--更新 balance = 300 - 100 = 200
COMMIT

// 会话2
BEGIN

SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
--balance = 200
-- (这里会暂停直到会话1 COMMIT)

UPDATE accounts SET balance = 200 where id = 1;
--更新 balance = 100
COMMIT

使用行锁需要特别注意必须是在事务的包含之内进行操作,行锁的生命周期只有在事务之内才存在,一旦事务 COMMIT 或者 ROLL BACK ,行锁会自动释放。

使用表锁或者行锁都要特别小心,如果两个不同的事务都需要锁定特定的资源,而彼此之间锁定的顺序不一样,就很容易造成死锁。比如会话1先锁定 id = 1 ,再锁定 id = 2,而会话2先锁定 id = 2, 后锁定 id = 1,当这两个事务并发操作时,彼此锁定了一部分,这样就造成了死锁!

利用SERIALIZABLE串行化事务隔离

串行化事务的前提是冲突控制可以在一个事务周期内完成,并且 Postgre 的版本要等于或大于 9.1 (之前的版本不支持)。使用串行化事务隔离,我们不需要每次 SELECT 的时候显式加上 FOR UPDATE,在事务开始的时候,我们指定事务隔离等级为串行化:

BEGIN ISOLATION LEVEL SERIALIZABLE

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 会话1
BEGIN ISOLATION LEVEL SERIALIZABLE
SELECT balance FROM accounts WHERE id = 1;
--返回 balance = 300

UPDATE accounts SET balance = 200 where id = 1;
--更新 balance = 300 - 100 = 200
COMMIT


// 会话2
BEGIN ISOLATION LEVEL SERIALIZABLE

SELECT balance FROM accounts WHERE id = 1;
--balance = 300
(若发生在会话1UPDATE之后会抛出异常

UPDATE accounts SET balance = 200 where id = 1;
--(这里会抛出异常)
COMMIT

串行化事务采用一个 预测锁定 技术代替行锁,如果多个会话事务之中,有某个事务对相关数据进行了改动,而这个发动会影响到其它事务操作的结果。那么 Postgre 只会运行先开始的操作,而对其它事务进行异常并强制回滚。换句话说,即使会话2中的 SELECT语句,如果是发生在会话1的UPDATE语句之后,那么它也会被抛出异常。

串行化事务采用抛出异常的方式可能会导致我们编写程序时要多做一些失败检测和维护操作,但它也避免了行锁带来的潜在死锁问题以及磁盘性能问题。

使用乐观锁

乐观锁是通过在表中增加 TIMESTAMPE 或 ROW VERSION 的列,在 SELECT 的时候同时读取该列,在 UPDATE 的时候增加对列值的判断,并同时更新该列。

1
2
3
ALTER TABEL accounts ADD COLUMN version integer NOT NULL DEFAULT 1;
SELECT balance, version FROM accounts WHERE id = 1; -- balance = 300, version = 1
UPDATE accounts SET balance = 200, version = 2 WHERE id = 1 AND version = 1;

通过 UPDATE 的原子性,在并发发生时,后到的更改将不会被应用。而且,乐观锁不受事务生命周期影响,甚至跨应用都是没有问题的。

乐观锁最大的问题的 SQL 语句太过精细,实际上Programming中手工编码几乎是不实际的。但这个对于 ORM 框架来讲就不是什么问题,像 Hibernate, EntityFramework 都有集成乐观锁机制。

结论

上面总结了4种可行的解决方案,每种方案都有它的优势和短处。实际应用上要根据我们的项目特点具体去选择。