简介
数据库的并发安全
本文是innodb的读书笔记,更宏观的看待并发问题请参考腾讯云李海翔:数据库的并发控制技术深度探索基本要点:
- 数据库一共会发生11种异常现象,脏读、不可重复读、幻读只是其中三种。
-
主流的并发控制技术
- 两阶段锁
- 基于时间戳
- 基于有效性检查
- MVCC,常与其它技术一起使用
- SCO
所谓并发控制技术就是抑制并发,或者发现数据异常并处理。 使各种共享资源在被并发访问变得有序所设计的一种规则
《软件架构设计》软件并发问题其实就是读写、写写冲突问题,读写冲突又可以细分为快照读与写冲突、当前读与写冲突
并发冲突 | 处理办法 | 示例 |
---|---|---|
读读 | 无冲突 | |
快照读与写 | copyOnWrite/MVCC | select xx from xx |
当前读与写 | 加锁,但锁有强弱(互斥、读写),粒度有大小(表、行、范围),锁住的对象有不同(索引、数据行) 可以根据容忍的读错误类型加不同的锁 |
select xx for udpate select xx in share mode |
写写 | 加锁 |
锁的实现
《MySQL实战45讲》MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类
- 全局锁,对整个数据库实例加锁,命令是
flush tables with read lock
,释放全局锁命令unlock tables
,典型使用场景是:做全库逻辑备份 - 表级锁
- 表锁,表锁的语法是
lock tables … read/write
,可以用unlock tables
主动释放锁,也可以在客户端断开的时候自动释放。 - 元数据锁,不需要显式使用,在访问一个表的时候会被自动加上。在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。
- 表锁,表锁的语法是
- 行锁就是针对数据表中行记录的锁。在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。因此,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。
给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。
书中提到,在数据库中,锁有lock和latch,一般业务开发熟悉的锁对应的是latch,简单区别如下:
对象 | 保护 | 持续时间 | 存在于 | |
---|---|---|---|---|
lock | 事务 | 表、页、行 | 整个事务过程 | lock manager的哈希表中 |
latch | 线程 | 内存数据结构 | 很短 | 被保护的数据结构中 |
比如在java中,一个object内存结构就相应有锁的标记位,意味着任何一个object都有可能被竞争访问,如果object已经被锁住(标记位是某个值),则线程会被挂起。
其实,锁的标记信息存储在被保护的数据结构上还是独立集中管理,都是一样的。
- 在操作系统中,一个文件在磁盘上的存在形式是一个个磁盘块,在内存中的存在形式除了磁盘块载入内存的缓冲块外,还有一个文件表,表中的文件结构体有锁的标志位。文件是否被某个线程独占,并不属于文件的内容信息,存入磁盘中是不恰当的。如果锁的信息存入磁盘块对应的缓冲块,则破坏了缓冲块与磁盘块的直接对应关系。
- 每个数据结构保有锁的标记信息有一个好处,即语言层面简化锁的使用,比如java的synchronized关键字, 比
lock unlock
方便多了。
上层应用开发会加各种锁,有些锁是隐式的,数据库会主动加(比如update),有些锁是显式的,比如select xx for update。 因为开发的使用不当,数据库会发生死锁,就像jvm 也会死锁一样。作为数据库,必须有机制检测出死锁(判断一个有向图是否存在环),并解决死锁问题,比如强制让其中某个事务回滚,释放锁。
事务
《软件架构设计》通俗的讲,事务就是一个“代码块”,这个代码块要么不执行,要么全部执行。事务要操作数据(数据库里面的表),事务与事务之间会存在并发冲突,就好比在多线程编程中,多个线程操作同一份儿数据,存在线程间的并发冲突是一个道理。
理解事务 - MySQL 事务处理机制基本要点(太经典,要低水平的复制粘贴了):
重新理解一致性:在事务T开始时,此时数据库有一种状态,这个状态是所有的MySQL对象处于一致的状态,例如数据库完整性约束正确,日志状态一致等,当事务T提交后,这时数据库又有了一个新的状态,不同的数据,不同的索引,不同的日志等,但此时,约束,数据,索引,日志(binlog/redo/undo log)等MySQL各种对象还是要保持一致性(正确性)。 这就是 从一个一致性的状态,变到另一个一致性的状态。也就是事务执行后,并没有破坏数据库的完整性约束。有分布式一致性,其实一致性问题分布式和单机都有。
事务的原子性和持久性——redo/undo log
一次事务实际执行的伪代码
start transaction
写undo log1: 备份该行数据(update)
update 表1某行记录
写redo log1
写undo log2:备份该行数据(insert)
delete 表1某行记录
写redo log2
写undo log3:该行的主键id(delete)
insert 表2某行记录
写redo log3
commit
InnoDB将Undo Log看作数据,因此记录Undo Log的操作也会记录到redo log中,包含Undo Log操作的Redo Log,看起来是这样的:
记录1: <trx1, Undo log insert <undo_insert …>>
记录2: <trx1, insert …>
记录3: <trx2, Undo log insert <undo_update …>>
记录4: <trx2, update …>
记录5: <trx3, Undo log insert <undo_delete …>>
记录6: <trx3, delete …>
宕机恢复后(redo log undo log 貌似都是从宕机恢复的视角来说的)
- InnoDB 如果判断到一个数据页可能在崩溃恢复的时候丢失了更新,就会将它读到内存,然后让 redo log 更新内存内容。并不关心事务性,提交的事务和未提交的事务都被重放了,从而让数据库”原封不动“的回到宕机前的状态。
- 重放完成后,再把未完成的事务找出来,逐一利用undo log进行逻辑上的“回滚”。 undo log 记录了sql 的反操作,所谓回滚即 执行反操作sql
可以看出,redo log 不保证事务原子性, 只是保证了持久性, 不管提交未提交的事务都会进入redo log。
redo log和undo log所做的一切都是为了提高 数据本身的IO效率,已提交事务和未提交事务的数据 可以随意立即/延迟写入磁盘。代价是,事务提交时,redo log必须写入到磁盘,数据随机写转换为日志数据顺序写。PS,随机写优化为顺序写,也是一种重要的架构优化方法。
事务的隔离性与一致性——MVCC与锁
mysql 作为一个数据库,其实就是sql的 解释执行器,这一点和jvm 作为字节码的解释执行器是一样一样的。但跟java语言层面的并发安全又有所不同,java语言层面就两个安全级别:安全,不安全。目的是为了保证一致性,但绝对的一致性要损失性能,因此允许某些异常便产生一致性强弱的区别。主要体现在 如果数据正在更新(感知到有事务正在处理,并发冲突),可以先返回老版本数据,数据更新则始终基于最新数据(也就是要等其它事务结束)。PS:就像主从同步一样,读可以读从库(很多业务可以接受一点不一致),写则必须去主库写。 追求绝对的并发安全会导致性能的下降。为了提高性能,可以降低并发控制”强度“,从读写的角度提出了一个隔离性的概念来描述并发安全的程度
并发竞争的几种处理
- 靠锁把并发搞成顺序的
- 发现有人在操作数据,就先去干点别的,比如自旋、sleep 一会儿
- 发现有人在操作数据,找个老版本数据先用着,比如mvcc
- 相办法不共享数据
《MySQL实战45讲》一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?
begin/start transaction
命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot
这个命令。- 在 MySQL 里,有两个“视图”的概念
- 一个是 view。它是一个用查询语句定义的虚拟表,创建视图的语法是
create view …
,而它的查询方法与表一样。 - 另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。
- 一个是 view。它是一个用查询语句定义的虚拟表,创建视图的语法是
- 在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。如果一个库有 100G,那么我启动一个事务,MySQL 就要拷贝 100G 的数据出来,这个过程得多慢啊。实际上,我们并不需要拷贝出这 100G 的数据。
- InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id(trx_id 即操作 row 的事务id)。一行记录的 多个`版本并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的
可重复读——可以读到什么数据
按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。
数据版本的可见性规则,就是基于数据的 row trx_id 和这个高低水位/事务视图(每个事务的高低水位都不同)对比结果得到的。对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:
- 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据对当前事务是可见的;
- 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是当前事务肯定不可见的;
- 如果落在黄色部分,那就包括两种情况:a 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,对当前事务不可见; b 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,对当前事务可见。
有了这个声明后,系统里面随后发生的更新,是不是就跟这个事务看到的内容无关了呢?因为之后的更新,生成的版本一定属于上面的 2 或者 3(a) 的情况,而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力(PS:有点copy on write的feel)。一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:
- 版本未提交,不可见;
- 版本已提交,但是是在视图创建后提交的,不可见;
- 版本已提交,而且是在视图创建前提交的,可见。
可重复读——更新逻辑
当事务要去更新数据的时候,就不能再在历史版本上更新了,否则其它事务的更新就丢失了。
update 更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read):必须要读最新版本,而且必须加锁。当前读的规则,就是要能读到所有已经提交的记录的最新值。
如果当前记录的行锁被其他事务占用的话,就需要进入锁等待。根据两阶段锁协议,其他事务提交才会释放锁,因此可以读到其他事务提交后的row。
除了 update 语句外,select 语句如果加锁,也是当前读
# 加读锁
mysql> select k from t where id=1 lock in share mode;
# 加写锁
mysql> select k from t where id=1 for update;
mvcc 实现
postgresql、mysql、tidb对 mvcc 有着不同的实现方案。
TiKV 事务模型概览,Google Spanner 开源实现MVCC层暴露给上层的接口行为定义:
MVCCGet(key, version), 返回某 key 小于等于 version 的最大版本的值
MVCCScan(startKey, endKey, limit, version), 返回 [startKey, endKey) 区间内的 key 小于等于 version 的最大版本的键和值,上限 limit 个
MVCCPut(key, value, version) 插入某个键值对,如果 version 已经存在,则覆盖它。上层事务系统有责任维护自增version来避免read-modify-write
MVCCDelete(key, version) 删除某个特定版本的键值对, 这个需要与上层的事务删除接口区分,只有 GC 模块可以调用这个接口
从CPU Cache出发彻底弄懂volatile/synchronized/cas机制cpu 硬件因为多级缓存的缘故,一般的cpu 指令操作的是local cache,对于同一个数据,因为local cache 存在天然的有了多 verison。
各CPU都会通过总线嗅探来监视其他CPU,一旦某个CPU对自己Cache中缓存的共享变量做了修改(能做修改的前提是共享变量所在的缓存行的状态不是无效的),那么就会导致其他缓存了该共享变量的CPU将该变量所在的Cache Line置为无效状态,在下次CPU访问无效状态的缓存行时会首先要求对共享变量做了修改的CPU将修改从Cache写回主存,然后自己再从主存中将最新的共享变量读到自己的缓存行中。
缓存一致性协议通过缓存锁定来保证CPU修改缓存行中的共享变量并通知其他CPU将对应缓存行置为无效这一操作的原子性,即当某个CPU修改位于自己缓存中的共享变量时会禁止其他也缓存了该共享变量的CPU访问自己缓存中的对应缓存行,并在缓存锁定结束前通知这些CPU将对应缓存行置为无效状态。在缓存锁定出现之前,是通过总线锁定来实现CPU之间的同步的,即CPU在回写主存时会锁定总线不让其他CPU访问主存,但是这种机制开销较大
读提交
读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:
- 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图(高低水位),之后事务里的其他查询都共用这个一致性视图;对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
- 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图(高低水位)。对于读提交,查询只承认在语句启动前就已经提交完成的数据;