硕大的汤姆

硕大的汤姆

The official website of Minhua Chen

16 Jan 2020

事务

尽管大多数程序员都认为事务是如此简单和自然,但事实上事务不是一个天然的东西,而是人为创造出来的,目的是为了简化应用层编程。

对应用层来说,底层可能出现的错误实在太多了。网络可能中断,数据库软件可能崩溃,应用程序自身可能突然崩溃,机房可能断电,数据更新可能被覆盖导致丢失,或者读到一些部分更新的数据。而事务就是一种将一系列操作捆绑成一个原子操作(一起成功或一起失败),并对外提供隔离性和持久性,从而将复杂的并发读写问题从应用层抽离到数据库层面的技术手段。

换句话说,即使没有事务,应用层有时也能工作。但是没有原子性保证后,操作的中间状态会多的让人崩溃,错误处理也会异常复杂。而如果没有隔离性,则会出现各种并发导致的数据覆盖导致更新丢失,或者读到中间状态数据的问题。

事务提供了什么

ACID,即原子性,一致性,隔离性,持久性。

  • 原子性:在出错时中止事务,并将部分完成的写入全部丢弃。也许可中止性比原子性更为准确。
  • 一致性主要只对数据有特定的预期状态,例如对账单系统来说要保证账户金额平衡。事实上,一致性更多是靠应用层来保证的,应用程序需要依靠数据库提供的 AID 来达到一定的 C。(Joe Hellerstein 曾经说过,C 只是为了让 ACID 这个缩略词读起来更顺口,lol)
  • 隔离性意味着并发执行的多个事务相互隔离,不能相互交叉,虽然实际上他们可能同时运行,但是数据库要保证数据提交时,其结果与串行运行一样。
  • 持久性意味着一旦写入成功,即使数据库崩溃,数据也不能丢。

除了 ACID 之外,存储系统有时候还会提供一些高级功能,比如当事务失败之后,究竟应该如何表现?一个简单的方案是直接丢弃所有请求。而有些无主节点的分布式存储系统则会采取“尽力而为”的策略,尝试多做一些工作。还有一些系统会采用在应用层重试事务的策略,这需要格外小心重复写的问题。在分布式系统中,错误处理是非常尴尬的事情,原因在于你对错误的了解是不完备的,除非对方系统能明确告知错误类型,否则你很可能无法得知真正的错误类型。

另外还要补充一点,有些时候逻辑上的事务可能会跨存储,甚至会有不可逆的副作用(比如发短信,当你发现事务无法提交成功而必须回滚,可是消息却已经发出去了)。对于这种跨系统的事务,我们可以采用补偿机制,两阶段提交机制等等方式来实现分布式事务。

弱隔离级别

前面我们提到,隔离性要保证并发执行的多个事务相互隔离,不能交叉。首先我们应当澄清,当两个事务需要读写的数据完全没有交集的时候,即使他们是同时执行的,也不会导致并发问题(尽管有时候我们认为负载问题也是并发问题,但这里我们认为他们完全是两码事)。

一种实现隔离的方式是串行执行,有时候,这可能会是个好主意(比如对于 redis 这种基于内存的 kv 数据库),但对于大多数关系型数据库应用场景来说,串行带来的性能损失是不可接受的。更重要的是,这种完全拒绝并行的方案非常危险,因为只要有一个事务被卡住了,其他事务将完全无法工作。

因此,数据库通常会提供串行化之外的其他相对较弱的隔离级别。弱隔离级别会带来一些并发问题,甚至已经造成了大量损失,但是在并发性的诱惑下,这些弱隔离级别得到了广泛应用。

读-提交

读提交是最基本的事务隔离级别,它提供了两个保证:只能读到已经提交的数据(防止脏读);只能覆盖已经提交的数据(防止脏写)。

防止脏读的一种策略是,对于每个待更新的值,数据库都会维持新值和旧值两个版本。事实上支持快照隔离级别的存储引擎会直接采用 MVCC 来实现读提交。

而对于脏写的问题,数据库通常采用行级锁来实现,当事务想要修改某行时,它必须首先获得行锁,并持有到事务提交。如果有另一个事务也想改这行,则必须等待。这种锁定机制是读提交模式下数据库自动完成的。

读倾斜

读提交会带来一些数据不一致的问题,我们来看下面这个例子。

假设 Alice 在银行有 1000 美元存款,分两个账号,各 500 美元。然后她从账号 1 转了 100 美元到账号 2。但是在转账的过程中,她有可能在查自己账户的时候,会发现自己只有 900 美元了。

trx1: select balance from accounts where id = 1; 返回 500 美元。

- 下面事务 2 开始转账
  trx2: begin;
  trx2: update accounts set balance = balance + 100 where id = 1;
  trx2: update accounts set balance = balance - 100 where id = 2;
  trx2: commit;
- 事务 2 转账结束,并提交
  trx1: select balance from accounts where id = 2; 返回 400 美元。

这种现象就是读倾斜(read skew),也叫不可重复读。在有些场景下,不可重复读不是什么大问题,但是有时候,可能会带来灾难。

快照隔离级别

快照隔离级别可以解决读倾斜的问题,其总体想法是,每个事务在开始的时候给整个数据库拍个快照,然后在事务中读取数据的时候,都从快照中读。事务一开始看到的是最近提交的数据,之后即使数据可能被另一个事务的提交改变,但是保证每个事务都只能看到特定时间的旧数据。以上面 Alice 的例子,她在提交 trx1 之前,两次读账号 1 和账号 2,读到的都是 500 美元,而不会读到 trx2 提交的数据。

快照隔离级别对于长时间运行的只读查询比较有用(备份,批处理分析)。

How InnoDB MVCC works

首先 innodb 中每个事务都有唯一的 trx_id,是事务开始的时候申请的,严格递增的整数。

在快照隔离级别下(也就是可重复读隔离级别),事务启动时会创建一个 readview,之后有人改了数据,它再读也是看到事务启动时候的数据。每次事务更新数据,就会产生新的版本,每个版本都会有自己的 row_trx_id,也就是进行这个更新操作的事务的 trx_id。利用这种机制,每行数据都拥有了多个版本。此外,每个事务在开始的时候,会记录那个时刻还没有提交的其他事务的 ID,一个 idlist。

而当事务读取数据的时候,存储引擎还是先拿到最新的版本,如果这个版本就是属于这个事务的则直接返回,否则,就要就要进行判断。如果读取的时候,那个数据版本对应的事务在当前事务开始前就已经提交了,则可以读到,否则,就要拿上一个版本,依次类推。(拿前面版本的方式,其实是通过 undo log 进行计算)。

但是只读快照依然会存在问题:在更新数据的时候,如果我们还是只读快照的数据,就会导致写覆盖问题(脏写),因为其他事务写的数据在当前事务还看不到。 所以我们需要解决的问题是,如果有个事务想更新一行但是被 block 了(拿不到写锁),那等他终于拿到这个锁的时候,看见的是啥?对此,InnoDB 采取一种称为当前读的规则:更新数据都是先读后写的,而且读的都是当前值(而非历史版本)。除了 update 外,select 语句加锁( lock in share mode 或 for update)也是当前读。

注意,在 MySQL 中,一致性快照并不是 start transaction 的时候创建的,而是在之后第一个操作 innodb 的语句的时候。如果你想在 start transaction 的时候立即创建快照,就需要 start transaction with consistent snapshot。

CREATE TABLE `t` (
`id` int(11) NOT NULL,
`k` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);

tx1: start transaction with consistent snapshot
tx2: start transaction with consistent snapshot
tx3: update t set k = k + 1 where id = 1; (auto commit)
tx2: update t set k = k+1 where id = 1;
tx2: select k from t where id = 1;
tx1: select k from t where id = 1; commit;
tx2: commit;

在上面的例子中,tx2 查 id=1 的 k 值为 3,而 tx1 查到的是 1。

防止更新丢失

事实上,读提交和快照级别隔离主要是解决了脏读的问题(读数据的请求在事务中可以看到什么),脏写只是并发写的一种特例。真正的脏写问题还没有被解决。

假设两个事务都在进行 read-modify-write 的过程,由于隔离性,第二个写操作并不包括第一个写操作修改后的内容,这就会导致第一个写操作被覆盖了,也就丢失了那次更新。

解决更新丢失常见的方式是利用数据库“原子写”的特性,原子操作通常采用对读取对象加独占锁的方式来实现(这种技术也称为游标稳定性)。这种方式讲一个完整的“read-modify-write”包装成一个原子操作来防止更新丢失。

UPDATE content SET v = v + 1 WHERE ...;

还有一种原子操作的实现方式是现在写在同一个线程完成,这样也就不会有并发写的问题了,redis 就是采用了这种方案,这省下了同步的开销。

如果数据库不支持原子写,我们就要想办法显式加锁,这种方案将同步逻辑移到应用层,增加了设计与实现的复杂度,但也带来了更好的灵活性。个人经验认为,在应用层加锁常常是一件非常麻烦的事,该加锁的地方忘了加锁,会导致不该出现的并发问题,引入冲突甚至导致巨大损失。在不该加锁的地方加了锁,会导致服务并发能力降低。在该释放锁的时候没有释放,或者应用提前 crash,会导致其他线程阻塞。在独占进程还没有释放锁的时候系统判断锁超时自动释放了锁,会导致锁失效。。。

还有一种思路,是先让冲突的进程并行执行,但是事务管理器如果检测到有更新丢失风险的时候,就强制第二个事务中止。PostgreSQL 的 RR 隔离级别就支持这种更新丢失检测功能。

还有一种非常常用的方法,我们喜欢叫它“乐观锁”,即在写入前先进行读,然后确保这次的写是在读的基础上的。可以考虑使用一个版本号,或者更新时间等等数据来实现。

UPDATE t SET content = 'cnt', VERSION = VERSION + 1 WHERE key = "key" and VERSION = VERSION;

上面这些方法(原子写,加锁,更新丢失检测,乐观锁)都是先保证当并发写来的时候,保证同时只有一个写成。但是如果数据库是多主的,两个主都接收了请求怎么办?首先,你不应该允许这样的事情发生,比如你可以让写负载均衡,这样每个写都会到指定的节点。假如还是发生了并发写路由到多个主上,一种思路是合并写,另一种思路是“最后写入获胜”(LWW)。

写倾斜(write skew)与幻读

写倾斜不是脏写,也不是更新丢失,通常是两笔事务更新两个对象,这时候两笔事务都无法在各自事务间隔内感知到对方写的存在,从而导致一些想关联的逻辑校验失效。事实上,虽然是两个事务在写两个不同的实体,但他们之间可能是存在某种竞争状态的。

实体化冲突是解决写倾斜的一种非常常用的技巧。我举个我遇到过的问题吧。我们需要实现一个功能,用户需要先完成一系列 A 类型的操作,假设为{A1,A2,A3,A4,A5},集合内元素数不固定,在完成全部 A 类型操作后,才会通知用户进行 B 类型操作。一个简单的思路就是在每个 A 类型操作 执行完成的时候检查 A 类型操作是否已经全部完成。但是如果最后两个 A 操作是并发的,显然他两都不会认为自己是最后一个。这就是写倾斜问题。

这个问题比较简单的解决方案就是实体化冲突,比如上面的问题中,我们可以将 A 类型全部操作归入一个任务实体,然后在任务完成前,每次进行 A 类型操作都需要在任务表上加锁。

分布式事务与两阶段提交

有些场景下,需要在多个节点之间实现事务原子提交的算法,来确保所有节点要么全部提交,要么全部中止。两阶段提交(2PC)用于解决这类问题,通过引入协调者角色来完成事务管理。

  • 当应用程序启动一个分布式事务时,先向协调者请求全局唯一的事务 ID。
  • 应用程序在每个参与节点上执行单节点事务,并将全局唯一事务 ID 附加到事务上。
  • 应用程序准备提交时,协调者向所有参与者发送准备请求,并附带事务 ID。
  • 参与者必须确保事务无论如何都能提交成功,在此基础上向协调者答复可以提交事务。
  • 协调者如果没有收到所有答复为“是”,则会通知其他参与者放弃事务。
  • 如果收到所有参与者都能提交事务,则先将决定存入磁盘的事务日志中,防止系统崩溃。这个时间称为提交点。
  • 协调者落盘成功后,就通知所有参与者提交事务,如果有参与者提交失败,协调者也必须一直重试,直到所有参与者都成功。

两阶段提交存在一个问题,就是如果在参与者回复“是”,表示能参与事务之后,协调者出现故障。这时候参与者不能单方面放弃事务,必须等待,此时参与者处于一个不确定状态。所以 2PC 也被称为阻塞式原子提交协议,因为 2PC 可能在等待协调者恢复时卡住。

尽管有一些非阻塞原子提交技术,但是他们往往依赖一个故障检测器,并需要一个可靠的判断节点崩溃的方法。但是在延迟无限的网络环境下,这种机制通常并不可靠。因此,尽管两阶段提交存在上述问题,但依然被广泛应用。

有些人抱怨,常用的两阶段提交在性能和可用性方面代价太高。而我们认为事务滥用和过度使用所引入的性能瓶颈应该主要由应用层来解决,而不是简单的抛弃事务。