数据库的事务应该保证隔离性,这就是说,两个用户(连接)在操作数据库的时候,它们之间的操作应该互相不受影响的。比如用户 A 在修改了 foo 这个变量,但是还没有提交,那么 B 不应该看到这个修改。
但是隔离性的时间不是一件简单的时间,隔离性保证的越高,要么实现的技术手段越复杂,要么性能很低。因为很显然,避免多个事务之间互相影响,就必然要通过加锁来同步操作。如果想要更高的性能,就必须要加更加细粒度的锁,或者使用无锁(更加复杂)的技术来实现。
隔离性一般有这几个问题:
- 读到了未提交的数据。这个比较好理解,也比较好解决。一般加锁就行。
- 在同一个事务中,两次读到的数据不一样。比如用户 A 开始了一个事务,查询了 foo,这时候用户 B 修改了 foo 并且提交了,然后 A 在事务中又查询了 foo,发现和上一次查询不一样。这也违反了“隔离性”,但是读到的数据却的确是已经提交的数据。这就是“不可重复读”问题。现在用 MVCC 的方式解决,大体意思就是一个事务开始的时候记一个版本(时间戳)v1,其他人所有的操作都会带上新的版本(时间戳),那么在这个事务中,所看到的所有的数据都是先于 v1 的,所有在 v1 之后提交的数据都看不到。MVCC 有一些 tricky 的地方,比如要删除数据的时候,不能直接删除,因为删除了的话,对于所有的事务(即使是在删除操作 commit 之前开始的事务)来说都看不到了。为了解决这个问题,需要在要删除的对象上标记一个新的版本,记为删除。这样之前的事务看到这个数据的时候,发现删除操作是在自己的事务版本之后的,就仍然可以读到这个对象。这样,就可以解决了同一个事务中读到的数据不一样的问题。(但这样其实会带来新的问题,比如数据标记新的版本了,那么索引要不要标记呢?索引怎么做隔离?比如 PostgresSQL 的
COUNT
其实不是一个 O(1) 的操作,而是要遍历每一行数据,检查数据对当前的事务是否是可见的。 - 幻读。上面说这么多,主要是给这个问题做铺垫,我觉得这个问题最有意思了。本文下面会主要讲讲幻读。
假设这么一种情况:一家医院规定必须至少有 1 名医生在值班,某天值班医生 A 和 B 感到不舒服想请假,按照系统的要求,不能让 A 和 B 同事请假成功的。如果我们使用的数据库没有解决了幻读的问题,那么即使解决了“重复读”问题,也是不正确的。考虑下面这种情况:
这样两人都会同时请假成功。
这个问题并不是“不可重复读”问题,因为两个事务看到的正是事务开头的数据。这个竞争条件产生的根本原因是,这两个事务互相依赖了别的对象,但是别的对象又被更改了。
在这种情况中,我们可以锁住所有的对象,强制他们只能一个接一个的执行。
1 2 3 4 5 6 7 8 9 10 11 |
BEGIN TRANSACTION; SELECT * FROM doctors WHERE on_call = TRUE AND shift_id = 1234 FOR UPDATE; UPDATE doctors SET on_call = FALSE WHERE name = 'Alice' AND shift_id = 1234; COMMIT; |
再举一个幻读的例子:抢占会议室。
抢占程序是这样的,先检查会议室 R 在 某一天 12:00 有没有被抢占,如果没有的话,就可以抢占。那么假如两个事务同时发起的话,就同时看到这个会议室并没有被抢占,就会错误地被抢占两次。被抢占两次的 SQL 过程如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
BEGIN TRANSACTION; -- 检查所有现存的与12:00~13:00重叠的预定 SELECT COUNT(*) FROM bookings WHERE room_id = 123 AND end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00'; -- 如果之前的查询返回0 INSERT INTO bookings(room_id, start_time, end_time, user_id) VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666); COMMIT; |
这个问题我们也可以通过加锁来解决:将不存在的锁转换成已经存在的。比如,只允许抢占 7 天内的会议室,并且以小时为单位进行抢占。然后就可以将 7 天内的每一个小时作为一个锁,要抢占这个会议室就必须先获得锁。
另外有一些问题不容易实例化不存在的锁。比如注册系统,不允许同一个用户名字被注册两次。但是这种情况可以用数据库的 unique 约束来解决。
不太好解决的问题,防止双重开支。支付系统要保证用户支付的钱不能大于自己的余额,不然公司要倒闭了。那么付款的事务中,我们先检查一下余额是否足够,然后在发起支付,这样可以吗?如果用户同时用电脑付款,也用手机付款,会不会在同一个事务中提交两次呢?
这些问题都有一种共同的模式:
- 执行一个 select 查询;
- 根据 1 的查询结果,判断当前的操作是否能够继续;
- 如果能,就继续,但是这个操作会改变 1 的结果;
因为事务进入的时候 2 都是检查通过的,但是都执行了 3,会影响彼此的 1 的结果,导致出现竞争条件。这就是幻读。
除了“强行加锁”,即上文中提到的方法以外。最靠谱的是使用串行化的隔离级别,即对于程序员来说,数据库好像在串行地一个一个执行事务,这样永远不会有竞争条件。但是因为性能问题,串行化很少使用。
注:本文内容大多数都阅读 DDIA 第七章的笔记。