数据的一致性是数据准确的重要指标,那如何实现数据的一致性呢?本文从事务特性和事务级别的角度和大家一起学习如何实现数据的读写一致性。
1. 数据的一致性:通常指关联数据之间的逻辑关系是否正确和完整。
举个例子:某系统实现读写分离,读数据库是写数据库的备份库,小李在系统中之前录入的学历信息是高中,经过小李努力学习,成功获得了本科学位。小李及时把信息变成成了本科,可是由于今天系统备份时间较长,小李变更信息时,数据已经开始备份。公司的 HR 通过系统查询小李信息时,发现还是本科,小李的申请被驳回。这就是数据不一致问题。
2. 数据库的一致性:是指数据库从一个一致性状态变到另一个一致性状态。这是事务的一致性的定义。
举个例子:仓库中商品 A 有 100 件,门店中商品 A 有 10 件。上午 10 点,仓库发送商品 A50 件到门店,最后仓库中有商品 A50 件,门店有商品 A60 件,这样商品的总是是不变的。不能门店收到货后,仓库的商品 A 还是 100 件,这样就出现数据库不一致问题。仓库和门店商品 A 的总数是 110 才是正确的,这就是数据库的一致性。
数据库事务 (transaction) 是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之间执行的全部数据库操作组成。
事务的性质:
数据库在并发环境下会出现脏读、重复读和幻读问题.
事务 A 读取了事务 B 未提交的数据,如果事务 B 回滚了,事务 A 读取的数据就是脏的。
举例:订单 A 需要商品 A20 件,订单 B 需要商品 A10 件。仓库中有商品 A 库存是 20 件。订单 B 先查询,发现库存够,进行扣减。在扣减的过程中,订单 A 进行查询,发现库存只有 10 个不够订单数量,抛出异常。这时候订单 B 提交失败了。库存数量又变成 20 了。这时候,仓库人员去查库存,发现数量是 20,可是订单 A 却说库存不足,这就让人很奇怪。
复读指的是在一个事务内,最开始读到的数据和事务结束前的任意时刻读到的同一批数据出现不一致的情况。
举例:库房管理员查询商品 A 的数量,读取结果是 20 件。这是订单 A 出库,扣减了商品 10 件。这时管理员再去查商品 A 时,发现商品 A 的数量时 10 件和第一此查询的结果不同了。
事务 A 在执行读取操作,需要两次统计数据的总量,前一次查询数据总量后,此时事务 B 执行了新增数据的操作并提交后,这个时候事务 A 读取的数据总量和之前统计的不一样,就像产生了幻觉一样,平白无故的多了几条数据,成为幻读。
举例:操作员查询可生产单量 10 个,调用接口下发 10 个订单,事务 A 增加 10 个订单。操作员获取 10 个订单落库,查询 发现变成 30 个订单。
Read Uncommitted(未提交读)
一个事务可以读取到其他事务未提交的数据,会出现脏读,所以叫做 RU,它没有解决任何的问题。
Read Committed(已提交读)
一个事务只能读取到其他事务已提交的数据,不能读取到其他事务未提交的数据,它解决了脏读的问题,但是会出现不可重复读的问题。
Repeatable Read(可重复读)
它解决了不可重复读的问题,也就是在同一个事务里面多次读取同样的数据结果是一样的,但是在这个级别下,没有定义解决幻读的问题。
Serializable(串行化)
在这个隔离级别里面,所有的事务都是串行执行的,也就是对数据的操作需要排队,已经不存在事务的并发操作了,所以它解决了所有的问题。
有两个方案可以解决读一致性问题:基于锁的并发操作(LBCC)和基于多版本的并发操作(MVCC)
既然要保证前后两次读取数据一致,那么读取数据的时候,锁定我要操作的数据,不允许其他的事务修改就行了。这种方案叫做基于锁的并发控制 Lock Based Concurrency Control(LBCC)。
LBCC 是通过悲观锁来实现并发控制的。
如果事务 A 对数据进行加锁,在锁释放前,其他事务就不能对数据进行读写操作。这样并发调用,改成了顺序调用。对目前的大多数系统来说,性能完全不能满足要求。
要让一个事务前后两次读取的数据保持一致,那么我们可以在修改数据的时候给它建立一个备份或者叫快照,后面再来读取这个快照就行了。不管事务执行多长时间,事务内部看到的数据是不受其它事务影响的,根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。这种方案我们叫做多版本的并发控制 Multi Version Concurrency Control (MVCC)。
MVCC 是基于乐观锁的。
在 InnoDB 中,MVCC 是通过 Undo log 中的版本链和 Read-View 一致性视图来实现的。
undo log 是 innodb 引擎的一种日志,在事务的修改记录之前,会把该记录的原值先保存起来再做修改,以便修改过程中出错能够恢复原值或者其他的事务读取。undo log 是一种用于撤销回退的日志,在事务没提交之前,MySQL 会先记录更新前的数据到 undo log 日志文件里面,当事务回滚时或者数据库崩溃时,可以利用 undo log 来进行回退。
对数据变更的操作不同,undo log 记录的内容也不同:
undo log 版本链
每条数据有两个隐藏字段,trx_id 和 roll_pointer,trx_id 表示最近一次事务的 id,roll_pointer 表示指向你更新这个事务之前生成的 undo log。
事务 ID:MySQL 维护一个全局变量,当需要为某个事务分配事务 ID 时,将该变量的值作为事务 id 分配给事务,然后将变量自增 1。
举例:
所以当多个事务串行执行的时候,每个事务修改了一行数据,都会更新隐藏字段 trx_id 和 roll_pointer,同时多个事务的 undo log 会通过 roll_pointer 指针串联起来,形成 undo log 版本链。
InnoDB 为每个事务维护了一个数组,这个数组用来保存这个事务启动的瞬间,当前活跃的事务 ID。这个数组里有两个水位值: 低水位 (事务 ID 最小值) 和 高水位 (事务 ID 最大值 + 1); 这两个水位值就构成了当前事务的一致性视图(Read-View)。以上的操作大家可以去cnaaa服务器上部署环境进行操作实验下。
ReadView 中主要包含 4 个比较重要的内容:
有了这些信息,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
快照读又叫一致性读,读取的是历史版本的数据。不加锁的简单的 SELECT 都属于快照读,即不加锁的非阻塞读,只能查找创建时间小于等于当前事务 ID 的数据或者删除时间大于当前事务 ID 的行(或未删除)。
当前读查找的是记录的最新数据。加锁的 SELECT、对数据进行增删改都会进行当前读。
如图所示:
事务 A id =1 初始化了数据
事务 B id=2 进行了查询操作(MVCC 只读取创建时间小于当前事务 ID 的数据或者删除时间大于当前事务 ID 的行)
事务 B 的结果是 (商品 A:10, 商品 B:5)
事务 C id =3 插入了商品 C
事务 B id=2 进行了查询操作(MVCC 只读取创建时间小于当前事务 ID 的数据或者删除时间大于当前事务 ID 的行)
事务 B 的结果是 (商品 A:10, 商品 B:5)
事务 D id =4 删除商品 B
事务 B id=2 进行了查询操作(MVCC 只读取创建时间小于当前事务 ID 的数据或者删除时间大于当前事务 ID 的行)
事务 B 的结果是 (商品 A:10, 商品 B:5)
事务 E id =4 修改商品 A 的数量
事务 B id=2 进行了查询操作(MVCC 只读取创建时间小于当前事务 ID 的数据或者删除时间大于当前事务 ID 的行)
事务 B 的结果是 (商品 A:10, 商品 B:5)
所以当事务 E 提交后,当前读获取的数据和事务 B 读取的快照数据明显不同。
MVCC 可以很好的解决读一致问题,只能看到这个时间点之前事务提交更新的结果,而不能看到这个时间点之后事务提交的更新结果。而且降低了死锁的概率和解决读写之间堵塞问题。
LBCC 和 MVCC 都可以解决读一致问题,具体使用哪种方式,要结合业务场景选择最合适的方式,MVCC 和锁也可以结合使用,没有最好只有更好。