第6章 数据库 6.1 范式与反范式 数据库范式要求: 第一范式: 每个字段都是原子的,不能再分解。 第二范式: 1.表必有主键,主键可以是单个属性或者几个属性的组合。 2.非主属性必须完全依赖,而不能部分依赖。 第三范式: 没有传递依赖:非主属性必须直接依赖主键,而不能间接依赖主键。 6.2 分库分表 6.2.1 为什么要分 1.分库的目的是要做"业务拆分",通过业务拆分,把一个大的复杂的系统拆成多个业务子系统,之间通过RPC或者消息中间件通信。 2.应对高并发。但要针对读多写少,还是读少写多的场景分别讨论。如果是读多写少,可以通过增加从库,缓存解决,不一定要分库分表。如果是 读少写多,或者说写入的QPS已经达到了数据库的瓶颈,这时就要考虑分库分表了。 3.另外一个角度是"数据隔离"。如果把核心数据和非核心数据业务放在一起,一旦因为非核心数据库宕机,核心业务也会收到影响。 6.2.2 分布式ID生成服务 分库之前,数据库的自增ID可以唯一标识一条记录。分表分库之后,需要一个全局的ID生成服务。 6.2.3 拆分维度的选择 对于在分库分表之后其他维度的查询,一般有以下几个方法: 1.建立一个映射表 建立辅助维度和主维度之间的映射关系(商户ID和用户ID之间的映射关系)。查询的时候根据商户ID查询映射表,得到用户ID;再根据用户 ID查询订单ID。但这里有个问题,映射表本身也需要分库分表,并且分库分表维度和订单的分库维度还不同。即使映射表不分库分表,写入一条 订单的时候也可能需要同时写两个库,属于分布式事务问题。对于这种问题,通常也只能做一个后台任务定时对比,保证订单表和映射表的数据 最终一致性。 2.业务双写 同一份数据,两套分库分表。一套按用户ID分,一套按商户ID切分。同样,存在写入多个库的分布式事务问题。 3.异步双写 还是两套表,只是业务单写。然后通过监听binlog,同步到另外一套表上。 4.两个维度统一到一个维度 把订单ID和用户ID统一成一个维度,比如把用户ID作为订单ID中的某几位,这样订单ID中就包含了用户ID的信息,然后按照用户ID分库, 当按订单ID查询的时候,截取出用户ID,再按用户ID查询;或者订单ID和用户ID中有某几位是相同的,用这几位作为分库维度。 6.2.4 Join查询问题 分库分表之后,join 查询就不能用了。解决方法: 1.把join拆成多个单表查询,不让数据库做join,而是在代码层面对结果进行拼装。 这种做法比较常见,因为数据库全是单表查询,大大降低了数据库发生慢查询的概率。 2.做宽表,重写轻读 很多时候会遇到这个情况:需要把join的结果分页,这需要利用mysql本身的分页功能。对于这种不得不用join的情况,可以另外做一个 join表,提前把结果join好。这就是"重写轻读",其实也就是"空间换时间"的思路。 3.利用搜索引擎 利用类似ES的搜索引擎,把数据库中的数据导入搜索引擎中进行查询,从而解决join问题。 6.2.5 分布式事务 做了分库之后,纯数据库的事务就做不了了。一般的解决办法是优化业务,避免垮跨库的事务,保证所有事务都落到单库中。如果实在无法避免,就 需要做分布式事务的解决方案了。 6.3 B+ 树 关系型数据库在查询方面有一些重要特性,是kv型的数据库或者缓存所不具备的,比如: 1.范围查询 2.前缀匹配模糊查询 3.排序和分页 这些特性都得归功于B+树这种数据结构。 6.3.1 B+ 树逻辑结构 数据结构关键特征: 1.在叶子节点一层,所有记录的主键按照从小到大的顺序排列,并且形成了一个双向链表,叶子节点的每一个key指向一个记录。 2.非叶子节点取的是叶子节点里面key最小的值。这意味着所有非叶子节点的key都是冗余的节点。同一层的非叶子节点也互相串联,形成了一个 双向链表。 基于这样一个数据结构,要实现上面的几个特性就很容易了: 1.范围查询:比如要查主键在[1,17]之间的记录。二次查询,先查找1所在的叶子节点的记录位置,再查找17所在的叶子节点记录的位子,然后 顺序的遍历。 2.前缀匹配模糊查询:假设主键是字符串类型,要查询 where Key like abc%,其实可以转换成一个范围查询 Key in [abc,abcz]。 当然,如果是后缀匹配模糊查询,或者诸如 where Key like %abc%这样的中间匹配,则没办法转换成范围查询,只能挨个遍历。 3.排序与分页。叶子节点是天然排序好的,支持排序和分页。 另外,基于 B+ 树的特性,会发现对于offset这种特性,其实是用不到索引的。比如每页显示10条,要展示第101页,通常会写成 select xxx where xxx limit 1000, 10,从offset=1000的位置开始取10条。 虽然只取了10条数据,但实际上数据库要把前面的1000条都遍历了才知道offset=1000的位置在哪里。对于这种情况,合理的办法是不要用 offset,而是把offset=1000的位置换算出某个 max_id,然后用where语句实现,如 select xxx where xxx and id > max_id limit 10, 这样就可以利用 B+ 树的特性,快速定位 max_id的位置,即使 offset=1000的位置。 6.3.2 B+ 树物理结构 对于磁盘来说,不可能一条条的读写,而都是以"块"为单位进行读写的。innodb默认定义的块大小是16kb,通过innodb_page_size参数指定。 这里说的"块",是一个逻辑单位,而不是指磁盘扇区的物理块。块是通过 innodb 读写磁盘的基本单位,innodb每一次磁盘IO,读取的都是16kb的 整数倍数据。无论是叶子节点,还是非叶子节点,都会装在page里。innodb为每个page赋予一个全局32位的编号,所以innodb的存储容量上限是 64TB(2^32 * 16kb)。 16kb是一个什么概念呢?如果用来装非叶子节点的话,一个page大概可以装1000个key(16kb,假设key是64位,8个字节,再加上其他字段), 意味着B+树有1000个分叉;如果用来装叶子节点,一个page大概可以装200条记录(记录和索引放在一起存储,假设一条记录大概100个字节)。基于 这种估算,一个三层的B+树可以存储多少数据量呢? 第一层:一个节点是一个page,里面存放了1000个key,对应1000个分叉; 第二层:1000个节点,1000个page,每个page里面装了1000个key; 第三层:1000*1000 个节点(page),每个page里面装200条记录,即是 1000*1000*200=2亿条,总容量是16kb*1000*1000=16GB。 把第一层和第二层的索引全部放入内存,即(1+1000)*16KB,也即约16KM的内存。三层B+树 就可以支撑2亿条记录,并且一次性基于主键的等值 查询,只需要一次IO(读取叶子节点)。由此可见B+树的强大。 page与page之间组成双向链表,每一个page头部都记录2个关键字段:前一个page的编号,和后一个page的编号。page里面存储一条条记录, 记录之间用单向链表串联。最终所有的记录形成双向链表的逻辑结构。对于记录来说,定位到了page,也就定位到了page里面的记录,因为page会 一次性读入内存,同一个page里面的记录可以在内存中顺序查找。 在innodb的实践里面,其中一个建议是按主键的自增顺序插入记录,就是为了避免 Page Split问题。比如一个page里一次装入了(1,3,5,9), 4条记录,并且假设这个page满了。接下来如果插入key=4的记录,就不得不新建新的page,同时把(1,3,5,9)分成两半,前一半(1,3,4)还依旧在 旧page中,后一半(5,9)拷贝到新的page里,并且要调整page前后的双向链表的指针关系,这显然会影响插入速度。但如果插入的是key=10的记录, 就不需要做page Split,只需要创建一个新的page,把key=10的记录放进去,然后让整个链表的最后一个page指向这个新的page即可。 另外一个点,如果只是插入而不删除记录(软删除),也会避免某个page的记录数减少而发生相邻的page合并的问题。 6.3.3 非主键索引 对于非主键索引,同上面的结构类似,每一个非主键索引对应一颗B+树。在innodb里面,非主键索引的叶子节点存储的不是记录的指针,而是主键 的值。所以,对于非主键索引的查询,会查询2棵B+树,先在非主键索引的B+树上定位主键,再用主键去主键索引的B+树上查找最终记录。 有一点需要说明:对于主键索引,一个key只会对应一个记录;但对于非主键索引,值可以重复。所以一个key可能对应多条记录。 6.4 事务与锁 6.4.1 事务的四个隔离级别 事务并发导致的问题: 1.脏读 事务A读取了一条记录的值,然后基于这个值做业务逻辑,在事务A提交之前,事务B回滚了该记录,导致事务A读到的这条记录一个脏数据。 2.不可重复度 在同一个事务里,两次读取同一行记录,但结果不一样。因为另外一个事务对此记录进行了update操作。 3.幻读 在同一个事务里,同样的select操作,执行两次,返回的记录条数不一样。因为另外一个事务在进行insert/delete操作。 4.丢失更新 两个事务同时修改同一条记录,事务A的修改被事务B覆盖了。举个例子,x=5,A和B同时把x读取出来,减1,再写回去,得到x=4,实际 x的正确值应该是x=3。 为了解决上述问题,数据库设置了不同的事务隔离级别: 1.RU(Read Uncommited,读未提交) 相当于什么都没做,上述4个问题一个都没解决。 2.RC(Read Commited,读已提交) 只解决了上述问题1(脏读)。 3.RR(Repeatable Read,可重复读) 解决了上述问题1,2,3(脏读,不可重复读,幻读),这也是InnoDB默认级别。 4.Serialization(串行化) 完全解决上述4个问题。 隔离级别,一级比一级严格。隔离级别4就是串行化,所有事务串行执行,虽然能解决上述的4个问题,但性能接受不了,一般不会采用;隔离 级别1没有任何作用,所以常用的是2和3. 既然默认级别是3(RR),如何解决 丢失更新问题呢?这就是涉及到悲观锁和乐观锁。 6.4.2 悲观锁和乐观锁 丢失更新在业务场景中非常常见,数据库没有帮工程师解决这个问题,只能靠自己解决。 场景如下: 两个事务并发的对同一个记录进行修改,一个充钱,一个扣钱,伪代码如下: 事务A: start transaction int b = select balance from T where user_id = 1 b = b + 50 update T set balance = b where user_id = 1 commit 事务B: start transaction int b = select balance from T where user_id = 1 b = b - 50 update T set balance = 6 where user_id = 1 commit 如果正确的执行了事务A和事务B(无论是谁先后),执行完成之后,user_id=1的用户余额都是30;但现在事务A和事务B并行执行,执行结果可能是 30(正确结果),也可能是80(事务A覆盖了事务B),也可能是-20(事务B把事务A的结果覆盖了),这2个都是错误的。 要解决这个问题,有下面几个方法: 方法1:利用单条语句的原子性 在上面的每个事务里,都是先把数据select出来,再update回去,没有办法保证2条语句的原子性。如果改成一条,就能保证原子性。 如: 事务A: start transaction update T set balance = balance + 50 where user_id = 1 commit 事务B: start transaction update T set balance = balance - 50 where user_id = 1 commit 这种方法简单可行,但有局限性。因为实际的业务往往需要把 balance 先读取出来,做各种逻辑计算之后再写回去。如果不读,直接 修改balance,没有办法知道修改之前的balance是多少。 方法2:悲观锁 悲观锁,就是认为数据发生并发冲突的概率很大,所以读之前就上锁。利用select xxx for update语句,伪代码如下: 事务A: start transaction //对user_id = 1的记录上锁 int b = select balance from T where user_id = 1 for update b = b + 50 update T set balance = b where user_id = 1 commit 事务B: start transaction 对user_id = 1的记录上锁 int b = select balance from T where user_id = 1 for update b = b - 50 update T set balance = b where user_id = 1 commit 悲观锁的潜在问题,假如事务A拿到锁之后,commit之前出了问题,会造成锁不能释放,数据库死锁。另外,一个事务拿到锁后,其他 访问该记录的事务都会被阻塞,这在高并发场景下会造成用户端的大量请求阻塞。为此,有了乐观锁。 方法3:乐观锁 对于乐观锁,认为数据发生并发冲突的概率比较小,所以读之前不上锁。等到写回去的时候再判断数据是否被其他事务修改了,即多线程 里面讲的CAS(compare and set)的思路。对应的伪代码如下: 事务A: while(!result) { start transaction int b, v1 = select balance, version from T where user_id = 1 b = b + 50 result = update T set balance = b,version = version + 1 where user_id = 1 and version = v1 commit } 事务B: while(!result) { start transaction int b, v1 = select balance, version from T where user_id = 1 b = b - 50 result = update T set balance = b,version = version + 1 where user_id = 1 and version = v1 commit } CAS 的核心思想是:数据读出来的时候有个版本v1,然后在内存中修改,当再写回去的时候,如果发现数据库中的版本不是v1(比v1大), 说明在修改的期间别的事务也在修改,则放弃更新,把数据重新读取出来,重新计算逻辑,再重新写回去,如此不断的重试。 在实现层面,就是利用update语句的原子性实现了CAS,当且仅当version=1的时候,才能把balance更新成功。在更新balance的 同时,version也必须加1.version的比较,version的加1,balance的更新,这3件事都是在一条update语句中实现的,这是这个事情 的关键。 当然在实际场景中,不会让客户端无限循环重试的,可以重试3次。 方法4:分布式锁 乐观锁的方案可以很好的应对上述场景,但有一个限制是 select 和 update 是同一张表的同一个记录,如果业务场景更加复杂,有 类似下面的事务: start_transaction: select xxx from t1 select xxx from t2 ... 根据t1和t2的查询结果进行逻辑计算,然后更新t3 update t3 commit 要实现update 表t3的同时,表t1和t2是锁住的状态,不能让其他事务修改。在这种场景下,乐观锁就不能解决了,需要分布式锁。 6.4.3 死锁检测 上层应用开发会添加各种锁,有些锁是隐式的,数据库会主动加;而有些锁是显式的,比如上文说到的悲观锁。因为开发的使用不当,数据库会发生 死锁。所以,作为数据库,必须有机制检测出死锁,并解决死锁问题。 死锁检测机制就是发现这种有向图中存在的环,存在环就发生了死锁。如何判断一个有向图是否有环属于图论中的基本问题。 检测到死锁后,数据库可以强制让某个事务回滚,释放掉锁,把环断开,死锁就解除了。 具体到mysql,开发者可以通过日志或者命令查看当前数据库是否发生了死锁想象。遇到这种问题,需要排查代码,分析死锁原因,定位到具体的 sql语句,然后解决。死锁发生的场景比多,与代码有关,也可能与隔离级别有关。 死锁发生的场景: 1.事务A操作了表T1,T2的两条记录,事务B也操作了表T1,T2中同样的两条记录,但顺序刚好反过来,可能发生死锁。 2.同一张表,在RR隔离级别下,insert操作会增加 Gap 锁,可能会导致两个事务发生死锁。这个比较隐晦,不容易看出来。 事务A: delete from T1 where id = 1 insert into T1 values(...) 事务B: update T1 set xxx where id = 5 insert into T1 values(...) 6.5 事务实现原理之1:Redo Log 事务有ACID4个核心属性: A:原子性 C:一致性 I:隔离性 D:持久性 这4个属性中,D是比较容易的,C主要是由上层的各种规则来约束,也相对简单。而A和I涉及并发的问题,崩溃恢复的问题。 说到事务的实现原理,会追溯到 ARIES 算法理论。 6.5.1 Write-Ahead 一个事务要修改多张表的多条记录,多条记录分布在不同的page里面,对应到磁盘的不同位置。如果每个事务都直接写磁盘,一次事务提交就要多次 磁盘的随机IO,性能也达不到要求。怎么办?不写磁盘,在内存中进行事务提交,然后再通过后台线程,异步的把内存中的数据写入磁盘。但有个问题, 机器宕机,内存中的数据还没来得及刷盘,数据就丢失了。 为此,就有了 write-ahead 的思路:先在内存中提交事务,然后写日志(所谓的 write-ahead log),然后后台任务把内存中的数据异步的 刷到磁盘。日志是顺序的在尾部 append ,从而也避免了一个事务发生多次磁盘随机IO的问题。明明是先在内存中提交事务,然后写的日志,为什么叫做 write-ahead 呢?这里的 ahead,其实是指相对于真正的数据刷到磁盘,因为是先写的日志,后把内存数据刷到磁盘,所以叫 Write-Ahead Log。 内存操作数据 + Write-Ahead Log 这种思想非常普遍,后面讲 LSM 树的时候,也是基于此。在多备份一致性中,复制状态机的模型也是基于此。 具体到 InnoDB 中,write-ahead log 是 Redo Log。在innodb中,不光事务修改的数据库表数据是异步刷磁盘的,连Redo Log 的写入本身 也是异步的。在事务提交后,redo log 先写入到内存中的 Redo Log Buffer中,然后异步的刷到磁盘上的Redo Log。 为此,innodb 有个关键的参数 innodb_flush_log_at_trx_commit 控制 Redo Log 的刷屏策略,该参数有三个值: 0:每秒刷一次磁盘,把Redo Log Buffer 中的数据刷到 redo log(默认为0); 1:每提交一个事务,就刷一次磁盘(这个最安全); 2:不刷盘。然后根据参数 innodb_flush_log_at_timeout 设置的值决定刷盘频率。 6.5.2 Redo Log的逻辑与物理结构 从逻辑上讲,日志就是一个无限延长的字节流,从数据库安装好启动开始,日志便源源不断的追加,永无结束。 但从物理上来说,日志不可能是一个永不结束的字节流,日志的物理结构和逻辑结构,有2个显著的差异点: 1.磁盘的读取和写入都不是按一个个字节来处理的,磁盘是"块"设备,为了保证磁盘的IO效率,都是整块的读取和写入。对于redo log来说, 就是Redo Log Block,每个redo log block 都是512字节,为什么是512?因为早期磁盘,一个扇区(最细粒度的磁盘存储单位)就是 存储512字节数据。 2.日志文件不可能无限制膨胀,过了一定时期,之前的历史日志就不重要了,通俗的讲叫做"归档",专业术语是 checkpoint。所以redo log 其实是一个固定大小的文件,循环使用,写到尾部后,回到头部覆写(实际redo log是一组文件,但这里就当成一个大文件)。之所以能覆写, 因为一旦page数据刷到磁盘上,日志数据就没有存在的必要了。 LSN(Log Sequence Number)是逻辑上日志按时间顺序从小到大的编号。在innodb中,LSN是一个64位的整数,取的是从数据库安装启动 开始,到当前所写入的总的日志字节数。实际上LSN没有从0开始,而是从8192开始,这个是innodb源代码里面的一个常量LOG_START_LSN。因为 事务有大有小,每个事务产生的日志数据量是不一样的,所以日志是变长记录,因此LSN是单调递增的,但肯定不是单调连续递增。 物理上面,一个固定的文件大小,每512个字节一个block,循环使用。显然,很容易通过LSN换算出所属的Block。反过来,给定Redo log, 也很容易换算出第一条日志在什么位置。假设在 Redo Log中,从头到尾所记录的LSN依次如下: (200,289,378,478,30,46,58,69,129) 很显然,第1条日志是 30,最后1条日志是 478,30以前的已经被覆盖了。 6.5.3 Physiological Logging Log Block 里面log的存储格式,这个问题很关键,是数据库事务实现的一个核心点: 记法1。类似于 binlog 的 statement 格式,记原始的sql语句,insert/delete/update。 记法2。类似于 binlog 的 RAW 格式,记录每张表的每条记录的修改前的值,修改后的值,类似(表,行,修改前的值,修改后的值)。 记法3。记录修改的每个page的字节数据。由于每个page有16kb,记录这16kb里哪些部分修改了,一个page如果被修改了多个地方后,就会有 多条物理记录。如 : (PageID, offset1,len1, 修改之前的值,修改之后的值); (PageID, offset2,len2, 修改之前的值,修改之后的值); 前面2种记法都是逻辑记法;第三种是物理记法。redo log采用了哪种?它采用了逻辑记法和物理记法的综合体,就是先以page为单位记录 日志,每个page里面再采用逻辑记法(记录page里面的哪一行被修改了)。这种记法有个专业术语,Physiological Logging。 要搞清楚为什么要采用 Physiological Logging,就得知道逻辑日志和物理日志的对应关系: 1.一条逻辑日志可能产生多个Page的物理日志。 比如往某个表插入一条记录,逻辑上是1条日志,但物理上可能会操作两个以上的Page?为什么,因为一个表可能有多个索引,每个 索引都是一颗B+树,插入一条记录,同时更新多个索引,自然可能修改多个page。 如果redo log采用了逻辑日志的记法,一条记录牵涉的多个page写到一半系统宕机了,恢复的时候很难知道到底哪个page写成功, 哪个失败。 2.即使1条逻辑日志只对应一个Page,也可能要修改这个Page的多个地方。 因为一个page里面的记录是用链表串联的,所以如果在中间插入一条记录,不仅要插入数据,还要修改记录前后的链表指针。对应到 page就是多个位置要修改,会产生多条物理日志。 所以纯粹的逻辑日志宕机后不好恢复;物理日志又大,一条逻辑日志就可能对应多条物理日志。Physiological Logging 综合了 两种记法的优点,先以page为单位记录日志,在每个page里面再采用逻辑记法。 6.5.4 I/O写入的原子性(Double Write) 要实现事务的原子性,先得考虑磁盘IO的原子性。一个Log Block是512个字节。假设调用操作系统的一次write,往磁盘上写入一个Log Block( 512字节),如果写到一半机器宕机后再重启,请问写入的是0字节,还是[0,512]之间任意一个数值? 这个问题答案不一,可能与操作系统底层和磁盘的机制有关,如果底层实现了512个字节的写入原子性,上层就不需要做什么事情;否则,在上层就需要 考虑这个问题。假设底层没有保障512个字节的原子性,可以通过在日志中加入 checksum 解决。通过 checksum 能判断一个 Log Block是否完整, 如果不完整,就可以丢弃这个 log block,对日志来说,就是截断操作。 除了日志写入有原子性问题,数据写入的原子性问题更大。一个page有16KB,往磁盘上刷盘,如果刷到一半系统宕机重启,这个page处于什么状态? 在这种情况下,page既不是一个脏的page,也不是一个干净的page,而是一个损坏的page。既然已经有redo log了,不能用redo log 恢复吗? 因为redo log 也恢复不了。因为redo log 是 Physiological Logging,里面只是一个对page的修改的逻辑记录,redo log记录了哪个 地方修改了,但不知道哪个地方损坏了。另外,即使这个page加了 checksum,也只能判断出page损坏了,只能丢弃,但无法恢复数据。有两个办法 解决: 1.让硬件支持16KB写入的原子性。要么写入0,要么16kb全部写入成功。 2.Double write。把16kb写入到一个临时的磁盘位置,写入成功后再拷贝到目标磁盘位置。 6.5.5 Redo Log Block结构 Log Block 还需要有 checksum 字段,另外还有一些头部字段。事务可大可小,可能一个block存不下生产的日志数据,也可能一个block能 存下多个事务的数据。所以在block里面,得有字段记录这种偏移量。 头部4个字段的含义如下: Block No:每个block的唯一编号,可以由LSN换算得到。 Data Len:该block中实际日志数据的大小,可能496字节没有存满。 First Rec Group:该block中第一条日志的起始位置,可能因为上一条日志很大,上一个block没有存下,日志的部分数据到了当前 的block。如果 first rec group = Data Len,则说明上一条日志太大,大到了横跨上一个block,当前block,下一个block,当前 block 没有新数据。 Checkpoint No:当前block进行 Check point 时对应的LSN。 6.5.6 事务、LSN与Log Block的关系 假设有一个事务,伪代码如下: start transaction: update 表1某行记录; delete 表1某行记录; insert 表2某行记录; commit; 应用层所说的事务都是"逻辑事务",具体到底层实现,是"物理事务",也叫做 Mini Transaction(Mtr)。在逻辑层面,事务是3条sql语句, 涉及2张表;在物理层面,可能是修改了2个page(也可能是4个page,5个page ...),每个page的修改对应一个Mtr。每个Mtr产生一部分日志, 生成一个LSN。 这个"逻辑事务"产生了两段日志和两个LSN。分别存储到redo log 的block里面,这2段日志可能是连续的,也可能不连续的(中间插入其他的 事务日志)。所以,在实际的磁盘上面,一个逻辑事务对应的日志不是连续的,但一个物理事务(Mtr)对应的日志一定是连续的(即使横跨多个block)。 同一个事务的多条LSN日志也会通过链表串联,其中,TxID 是innodb为每个事务分配的一个唯一的ID,是一个单调递增的整数。 6.5.7 事务Rollback与崩溃恢复(ARIES算法) 1.未提交事务的日志也在redo log中 通过上面的分析,可以看到不同的事务的日志在redo log中是交叉存在的,这也为这未提交的事务也在redo log中。因为日志是交叉存在的, 没有办法把提交的事务的日志和未提交的日志分开,或者说前者刷到磁盘redo log 上面,后者不刷。 所以这是 ARIES 算法的一个关键点,不管事务有没有提交,其日志都会被记录到redo log上。当崩溃恢复的时候,会把redo log全部重放 一遍,提交的事务和未提交的事务,都重放了,从而让数据库"原封不动"的回到宕机之前的状态,这就叫 Repeating History。 重放完成后,再把宕机之前未完成的事务找出来。这就有个问题,怎么把宕机之前未完成的事务全部找出来?这就是checkpoint。 把未完成的事务找出来后,逐一利用undo log回滚。 2.Rollback 转换为 Commit 回滚是把未提交的事务的redo log 删了吗?显然不是,这里用了一个巧妙的方法,把回滚转化为提交。 一个Rollback事务被转换为Commit事务: start transaction: update tb1 set a = newvalue; delete tb2 where xxx; insert tb3 values(xxx); rollback; 转换为: start transaction: update tb1 set a = newvalue; delete tb2 where xxx; insert tb3 values(xxx); delete tb3 xxx insert tb2 xxx update tb1 set a = oldvalue; commit; 同样,如果宕机时一个事务执行了一半,在重启,回滚的时候,也并不是删除之前的部分,而是以相反的操作把这个事务"补齐",然后commit。 这样一来,事务的回滚就简单多了,不需要修改之前的数据,也不需要修改redo log。相当于没有了回滚,全部是commit。对于redo log 来说,就是不断的append。这种逆向操作sql语句对应到redo log里面,叫做 Compensation Log Record(CLR),会和正常操作的sql的log 区分开。 3.ARIES算法 ARIES 算法分为3个阶段: 阶段1:分析阶段 分析阶段,要解决两个核心问题: 1.确定哪些数据页是脏页,为阶段2的Redo做准备。发生宕机时,虽然T0,T1,T2已经提交了,但只是redo log在磁盘上,其对应 的数据是否已经刷到磁盘上不得而知。如何找出从checkpoint 到 Crash 之前,所有未刷盘的Page呢? 2.确定哪些事务未提交,为阶段3做准备。未提交的事务的日志也写入了Redo Log。也就是 T3,T4,T5的部分日志也在redo log 中。如何判断出t3,t4,t5未提交,对其做回滚呢? 这里就要聊到 ARIES 的 Checkpoint 机制。checkpoint 是每隔一段时间对内存中的数据拍一个"快照",或者说把内存中的 数据一次性的刷到磁盘上去。但实际上这样做不到,因为把内存中的所有脏页往磁盘上刷的时候,数据库还在不断的接受客户端的请求, 这些脏页一直在更新。除非把系统阻塞住,不再接受前端的请求,这时redo log也不再增长,然后一次性把所有脏页刷到磁盘中,叫做 Sharp Checkpoint。 Sharp checkpoint 的应用场景很狭窄,因为系统不可能听下来,所以更多的是 Fuzzy Checkpoint。具体怎么做呢? 在内存中,维护了两个关键表,活跃事务表和脏页表。 活跃事务表是当前所有未提交事务的集合,每个事务维护了一个关键变量lastLSN,是该事务产生的日志最后一条日志的LSN。 脏页表是当前所有的未刷到磁盘上的Page的集合(包括了已提交的事务和未提交的事务),recoveryLSN 是导致该Page为脏页的 最早的LSN。比如一个Page本来是clean的(内存和磁盘上数据一致),然后事务1修改了它,对应的LSN是LSN1;之后事务2,事务3又 修改了它,对应的LSN分别是 LSN2,LSN3,这里的recoveryLSN取的就是 LSN1。 所谓的 Fuzzy checkpoint,就是对这2张表做了一个checkpoint,而不是对数据本身做checkpoint。这非常巧妙,因为page 本身很多,数据量大,但这两个表记录的全是ID,数据量很小,很容易备份。 所以,每一次 Fuzzy checkpoint,就把两个表的数据生成一个快照,形成一条checkpoint日志,记入 Redo log。 基于这2个关键表,可以求取两个问题: a) 求取 Crash 的时候,未提交事务的集合 如图6-14,在最近一次checkpoint 2时候,未提交事务集合是{t2,t3},此时还没有t4,t5。从此处开始,遍历redo log 到末尾。 在遍历的过程中,首先遇到了t2的结束标识,把t2从集合中移除,剩下{t3}。 之后遇到了t4的开始标识,把t4加入到集合中,为{t3,t4}; 之后遇到了t5的开始标识,把t5加入到集合中,为{t3,t4,t5}; 最终直到末尾,没有遇到{t3,t4,t5}的结束标识,所以未提交事务的集合是{t3,t4,t5} b) 求取 Crash 的时候,所有未刷盘的脏页集合 假设在checkpoint 2 的时候,脏页的集合是{P1,P2}。从checkpoint开始,一直遍历到redo log末尾,一旦遇到 redo log操作的是新的page,就把它加入到脏页集合,最终结果可能是{p1,p2,p3,p4}。 这里有个关键点:从checkpoint2到Crash,这个集合只增不减。可能p1,p2在checkpoint之后已经不是脏页了,但 把它认为是脏页也没关系,因为redo log是幂等的。 阶段2:进行Redo 假设最后求出来的脏页集合是{p1,p2,p3,p4,p5}。在这个集合中,可能都是真的脏页,也可能是已经刷盘了。取集合中所有脏页 的recoveryLSN 的最小值,得到firstLSN。从firstLSN遍历redo log 到末尾,把每条redo log对应的page全部重新刷一次磁盘 关键如何做到幂等?磁盘上的每个page有一个关键字段 --- pageLSN。这个LSN记录的是这个page刷盘时最后一次修改它的日志 对应的LSN。如果重放日志的时候,日志的LSN <= pageLSN,则不修改日志对应的page,略过此条日志。 这与tcp在接收端对数据包的判重有异曲同工之妙。在tcp中,是对发送的数据包从小到大编号,这里是对所有的日志从小到大编号( LSN),接收的一方发现收到的日志编号比之前的还要小,就说明不用重做了。 有了这种判重机制,我们就实现了redo log重放时的幂等。从而可以从firstLSN开始,将所有的日志重放一遍,这里包含了已经 提交的事务和未提交的事务的日志,也包含了对应的脏页或者干净的页。 redo 完成后,就保证了所有的脏页都成功的写入到了磁盘,干净的脏页也可能重新写入一遍,并且未提交的事务t3,t4,t5对应的 page数据也写入了磁盘。接下来,就是对t3,t4,t5 回滚。 阶段3:进行Undo 在阶段1,我们已经找出了未提交事务集合{t3,t4,t5}。从最后一条日志逆向遍历,因为每条日志都有一个prevLSN字段,所以沿着 t3,t4,t5各自的日志链一直回溯,最终直到t3的第一条日志。 所谓的undo,是指每遇到一条属于t3,t4,t5的log,就生成一条逆向的sql语句来执行,其执行对应的redo log 是 Compensation Log Record(CLR),会在redo log尾部继续追加。所以对redo log来说,其实不存在所谓的"回滚",全部是正向的 commit,日志只会追加,不会执行"物理截断"之类的操作。 这里需要注意的是:redo 的起点位置和undo的起点位置并没有天然的先后关系,实际上可以反过来。因为redo对应的是所有脏页的 最小LSN,undo 对应的是所有未提交事务的LSN,两者的概念是完全不一样的。 在进行undo操作的时候,还可能遇到一个问题,回滚到一半,宕机,重启,再回滚,要进行"回滚的回滚"。不管怎么样,不会出现 "回滚嵌套"的问题。 对 Redo Log 先做一个总结: 1.一个事务对应多条redo log,事务的redo log不是连续存储的; 2.redo log不保证事务的原子性,而是保证持久性。无论是提交的,还是未提交事务的日志,都会进入redo log。从而使得redo log 回放完毕,数据库就恢复到之前的状态,称为 Repeating History; 3.同时,把未提交的事务挑出来回滚。回滚通过 checkpoint 记录的"活跃事务表" + 每个事务日志中的开始/结束标记 + undo log 来实现; 4.redo log 具有幂等性,通过每个page里面的pageLSN 实现; 5.无论是提交的,还是未提交的事务,其对应的page数据都可能被刷到磁盘中。未提交的事务对应的page数据,在宕机重启后会回滚; 6.事务不存在"物理回滚",所有的回滚操作都被转换成了commit。 6.6 事务实现原理之2:Undo Log 6.6.1 Undo Log是否一定需要 说到undo log,很多人只想到"事务回滚"。"事务回滚"有4种场景: 1.人为回滚 2.宕机回滚 3.人为回滚+宕机回滚 4.宕机回滚+宕机回滚 回滚就是取消已经执行的操作。无论是物理取消,还是逻辑取消,只要达到目的就行。假设page数据都在内存里面,每个事务执行,都只在内存中 修改数据,必须等到事务commit之后写完redo log,再把page数据刷盘。在这种策略下,不需要undo log 也能实现数据回滚。因为在这种数据刷盘 的策略下,正好利用了"内存闪断消失"的特性,磁盘上存储的全部是已经提交的数据,宕机重启,内存中还未完成的事务自然被一笔勾销,在这种策略下, 未提交的事务不会进入redo log,未提交的事务,也不会刷盘,全部在内存中。 Page刷盘的4种策略: No Steal 和 Steal: 是指未提交的事务是否可以写入磁盘。no steal 是指未提交的事务不能写入磁盘,只能在内存中操作,等到事务提交完,再把数据一次性 写入;steal是指未提交的事务也能写入,如果事务需要回滚,再更改磁盘上的数据。 No Force 和 Force: 是指提交的事务是否必须写入磁盘?no force 是指已经提交的事务可以保留在内存中,暂时不写入磁盘;force是指已经提交的事务必须 强制写入磁盘。 策略1:force 和 no steal 已经提交的事务必须强制写入磁盘,未提交的事务,只能保留在内存里,等事务提交后再写入磁盘。这种策略不需要redo log 和 undo log,仅靠数据本身就能实现原子性和持久性。但很显然不行,未提交的事务不能写入磁盘,这还可以接受;已提交的事务必须强制写入 磁盘,这需要多次IO,性能会受影响,所以才有了redo log> 策略2:no force 和 no steal 已经提交的事务不立即写入磁盘,未提交的事务只能保留在内存中。在这个策略下,只需要redo log即可,因为有"内存断电消失"这个 天然的特性。 策略3:force / steal 已提交的事务立即写入磁盘,未提交的事务也立即写入磁盘。这种只需要undo log回滚宕机时未提交的事务,不需要redo log。但和 策略1一样,多次IO的性能会受到影响。 策略4:no force / steal 这种策略是我们最想要的,也是innodb实现的策略。就是已经提交的事务可以不立即写入磁盘;未提交的事务可以立即写入磁盘,也可以 延迟写入磁盘。再通俗一点,无论事务是否提交,既可以立即写入磁盘,也可以不写,写入磁盘时机任意,想什么时候写就什么时候写。 策略1和策略3因为性能问题不能接受,所以必须有redo log。而策略4和策略2都可以接受,但策略4比策略2号的地方在于提高了Io效率。 因为事务没有提交,就开始写入磁盘,等到提交事务的时候,要写入磁盘的数据量会小,不然要把所有数据都累积到事务提交时再一次性写入磁盘。 也正是因为现代的数据库都是用策略4,是最灵活的一种数据刷盘策略。在这种策略下为了实现事务的原子性和持久性,才有了如此复杂的 redo log和undo log机制,才有了 ARIES 算法。 除了在宕机恢复时对未提交的事务进行回滚,undo log还有两个核心作用: 1.实现ACID中的I(隔离性); 2.高并发。 6.6.2 Undo Log(MVCC) 在多线程编程中,读写的并发问题有三种策略: 1.互斥锁 一个数据对象上只有一把锁,任何时候只要有一个线程拿到该锁,其他线程就会阻塞,这意味着: a) 写和写互斥 b) 写和读互斥 c) 读和读互斥 2.读写锁 一个数据对象上有一把锁,但有两个视图,读和写可以做到: a) 写和写互斥 b) 写和读互斥 c) 读和读可以并发 3.CopyOnWrite 写的时候,把该数据对象拷贝一份,等写完以后,再把数据对象的指针(引用)一次性赋值回去,读的时候读取原始数据,这意味着: a) 读和读可以并发 b) 读和写可以并发 c) 写和写理论上也可以并发 对比上面,从上到下,并发程度越高。而innodb用的就是CopyOnWrite 思想,是在undo log里面实现的。每个事务修改记录之前,都会先把 该记录拷贝一份出来,拷贝出来的这个备份存在undo log里。因为事务有唯一的编号ID,ID从小到大递增,每一次修改,就是一个版本,因此undo log 维护了数据从旧到新的每个版本,各个版本之间的记录通过链表串联。 也正是因为每条记录有多个版本,才容易实现事务ACID属性中的I(隔离性)。事务要并发,多个事务读写同一条记录,为了实现第二个,第三个隔离 级别,就不能让事务读到正在修改的数据,而只能读历史版本。 也正因为有了mvcc这种特性,通过select语句都是不加锁的,读取的全部是数据的历史版本,从而支撑高并发的查询。这种读,专业数据叫 "快照读",与之对应的是"当前读"。快照读通常就是 select 语句,当前读包括加锁的select语句和insert/upate/delete语句。 快照读: select xxx from xxx 当前读: select xxx from xxx for update select xxx from xxx lock in share mode insert/update/delete 语句 6.6.3 Undo Log不是Log undo log 这个词有很大的迷惑性,它其实不是log,而是数据。为什么这么说? 1.undo log 并不像redo log一样按照LSN的编号,从小到大依次执行append操作。undo log其实没有顺序,多个事务是并行的向undo log 中随机写入。 2.一个事务一旦commit之后,数据就"固化"了,固化之后不可能再回滚。这意味着undo log只在事务commit过程中有用,一旦事务 commit了,就可以删除undo log了。具体来说: a) 对于insert记录,没有历史版本,因此insert的undo log只记录了该记录的主键id,当事务提交后,该undo log就可以删除了。 b) 对于update/delete记录,因为mvcc的存在,其历史版本数据可能还被当前未提交的其他事务所引用,一旦未提交的事务提交了,其对应 的undo log也就可以删除了。 所以,更应该把undo log叫做记录的"备份数据",即在事务未提交之前的时间里"备份数据"。事务提交后,没有其他事务引用历史版本,就可以 删除了。 "备份数据"是怎么操作的?Page中的每条记录,除了自身的主键id和数据外,还有2个隐藏字段:一个是修改该记录的事务id,一个是 rollback_ptr,用来串联所有的历史版本。假设该记录被 tx_id 为68,80,90,100的4个事务修改了4次,该数据就有4个版本,通过 rollback_ptr 从新到旧串联起来。 然后,三个历史版本分别被其他不同的事务读取。为什么会出现不同的事务读取到的不同的版本呢?每个事务读取的都是这个事务执行时最新的 历史版本。这些历史版本什么时候可以删除呢?在t1,t2提交字后,历史版本3就可以删除了。以此类推。 回滚段:就是修改记录之前先把记录拷贝一份,然后拷贝出来的这些历史版本形成一个链表,仅此而已。 6.6.4 Undo Log与Redo Log的关联 undo log本身也要写入磁盘,但一个事务修改多条记录,产生多条undo log,不可能同步写入磁盘,所以遇到了write-ahead时的问题。如何 解决undo log 需要多次写入磁盘的效率问题呢? redo log 记录的是对数据的修改,凡是对数据的修改,都必须记入redo log,可以把undo log也当做数据,在内存中记录undo log,异步 的刷盘,宕机重启,用redo log恢复undo log。 拿一个事务举例: start transaction: update 表1某行记录 delete 表1某行记录 insert 表2某行记录 commit; 把undo log和redo 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; 在这里,所有的undo log和redo log 的写入都可以只在内存中进行,只要保证commit之后redo log落盘即可,undo log可以一直保留在 内存中,之后异步刷盘。 6.6.4 各种锁 mvcc 解决了快照读和写之间的并发问题,但对于写和写之间,当前读和写之间的并发,mvcc就无能为力了,这时就需要用到锁。 InnoDB的7种锁: 1.共享锁(S锁)与排它锁(X锁) 2.意向锁(Intention Locks) 3.记录锁(Record Locks) 4.间隙锁(Gap Locks) 5.临键锁(Next-Key Locks) 6.插入意向锁(Insert Intention Locks) 7.自增锁(Aotu-inc Locks) 这种分类比较迷惑,因为这7种锁不在一个维度上。比如记录锁可能是共享锁,也可以是排它锁;间隙锁也可能是共享锁或者排它锁; 按锁的粒度分:锁表,锁行,锁一个Gap(一个范围); 按锁的模式分:共享,排他,意向等。 1.表(S锁,X锁),行(S锁,X锁) 共享锁(S)和排它锁(X)是读写锁的另外一个叫法,共享锁即"读锁",读和读之间可以并发;排它锁就是写锁,读和写之间不能并发,写和写之间也不能并发。 InnoDB通常加锁的粒度是行,所以有对应的 行共享锁,行排它锁,但有些场景会在 表这个粒度加锁,比如ddl语句。 2.意向锁(IS锁,IX锁) 有了共享锁和排它锁,为什么又会有"意向锁"呢?假设事务A给表中的某一行记录加了一行排它锁,现在事务B要给整张表加排它锁,事务B怎么处理呢?显然事务B加锁不会 成功,因为表中的某一行正被A修改。但事务B要做出这个判断,它需要遍历表中的每一行,看是否加锁了,只要有一行加锁,就意味着整个表加锁。 很显然这种效率太低,而意向锁就是为了解决这个锁的判断效率问题产生的。意向锁是专门加在表上的,在行上没有意向锁。一个事务A要给某张表加一个意向锁S,是 "暗示"接下来要给表中的某一行加S锁;一个事务A要给某张表加一个意向X锁,是"暗示"接下来要给表的某一行加X锁。反过来说,一个事务要给某张表的一行加S锁,必须 先获得整张表的IS锁;要给某张表加一行X锁,必须先获得整张表的X锁。 有了这种暗示,事务B要给整张表加排它锁,就不用遍历所有记录了。只要看一下这张表有没有被其他事务加IX锁或者IS锁,就能做出判断。也正是因为这种暗示,是一种 很"弱"的互斥条件,所以所有的IX锁,IS锁之间都不互斥,IX锁,IS锁只是为了和表共享锁,表排它锁进行互斥。 意向锁实际上是表(共享锁,排他锁)和行(共享锁,排它锁)之间的桥梁,通过意向锁串起来两个不同粒度(表,行)的锁之间如何做互斥判断。 3.AI(Aotu-inc Locks) 自增锁是一种表级的锁,专门针对 auto_increment 的列。 假设表t1中有某一列是自增的,连续insert 2条,再select出来,自增一列的取值应该也是连续的;但如果不加 AI锁,可能别的事务会在这两条insert中间插入 一条记录,那么该事务第二次insert的记录的自增列取值可能不是7,而是8. 4.间隙锁(Gap Lock),临键锁(Next-Key Lock)和插入意向锁(Insert Intension Lock) 除锁表,锁行两种粒度外,还有第三种:范围锁,或者叫Gap锁。锁Gap是和锁行密切相关的,Gap肯定建立在某一行的基准上,所以往往又把锁Gap当做锁行的不同 算法来看待: 1.间隙锁(Gap Lock) 只是一个范围,不包括记录本上,也是一个开区间,目的是避免另外一个事务在这个区间插入新记录。 2.临键锁(Net-Key Lock) Gap Lock 和 Record Lock 的综合不仅锁记录,也锁记录之前的范围。 3.插入意向锁(Insert Intension Lock) 也是一种Gap锁,专门针对insert操作。多个事务在同一索引,同一个范围区间内可以并发插入,即插入意向锁之前并不互相阻碍。 Gap锁的各种算法实际很复杂,说明两点: 1.是否加 Gap 锁和事务隔离级别往往密切相关。所以要锁gap,一个主要的目的是避免幻读,如果事务隔离级别是RC,则允许幻读,不需要锁范围。 2.锁gap往往针对非唯一索引,如果是主键索引,或者非主键索引(但是唯一索引),每次修改可以明确的定位到哪一条或者哪几条记录,也不需要锁gap。 总结,事务的几个特性的实现原理: 1.通过 undo log + redo log 实现事务的A(原子性)和D(持久性); 2.通过 "MVCC+锁" 实现了事务的 I(隔离性)和并发性。 6.7 Binlog与主从复制 6.7.1 Binlog与Redo Log的主要差异 mysql 中 redo log记录事务执行的日志,binlog也记录日志。redo log和undo log 是innodb引擎里面的工具,但binlog是mysql层面的工具。 不同于redo log 和undo log用来实现事务,binlog 的主要作用是用作主从复制。在互联网应用中,binlog 有了第二个用途:一个应用进程可以把自己伪装 成slave,监听master的binlog,然后把数据库的变更以消息的形式抛出来,业务系统可以消费消息,执行对应的逻辑操作,比如更新缓存。如阿里的canal,Databus。 同redo log 一样,binlog 也存在刷盘问题,由参数 sync_binlog 控制,该参数有3个取值: 0:事务提交后不主动刷盘,依靠操作系统自身的刷盘机制可能会丢失数据; 1:每提交一个事务,刷一次盘; 2:每提交n个事务,刷一次盘。 显然,0和n都不安全,为了不丢数据,一般建议双1,即sync_binlog和innodb_flush_log_at_trx_commit 都设置为1. binlog 要比redo log简单的多,在不发生宕机的情况下,未提交的事务,回滚的事务,其日志都不会进入binlog。同时,事务的日志在binlog中是连续排列的, 等到事务提交的"一刹那",把该事务的所有日志都刷盘。连续排列会造成一个问题:binlog全局只有一份,每个事务都要串行的写入,这意味着每个事务在写binlog之前 要拿一个全局的锁,才能保证每个事务的binlog是连续写入的,这在效率上问题很大。因此,在mysql 5.6 Group Commit 出现之前,各种第三方在优化这类问题。 Group Commit 的思想也很简单,就是pipeline(http 1.1 是同样的思路;kafka的主从复制也是)。虽然binlog只能串行的写入,但不需要提交一次刷盘一次, 而是把事务的提交和刷盘放到不同的线程,刷盘时可以对多个提交的事务同时刷盘,虽然还是串行的,但是批量化了。 6.7.2 内部XA ?C Binlog与Redo Log一致性问题 一个事务的提交既要写 binlog,也要写redo log,如何保证两份日志的原子性?一个写成功,一个写失败,如何处理? binlog 自身写入原子性问题:binlog刷到一半,出现宕机。这个问题和之前讲redo log的写入原子性是一样的,通过类似于checksum的办法或者binlog中有结束 标记,来判断出这部分的,不完整的binlog,把最后这一段截掉。对于客户端来说,此时宕机,事务肯定是没有成功提交的,所以截掉也没问题。 接下来讲如何实现binlog和redo log的数据一致性,即内部XA,或者叫内部的分布式事务问题。外部分布式事务是两个系统或者两个数据库之间的。内部分布式事务是 binlog和redo log之间的事务,使用的是经典的2阶段提交方案(2PC,2 Phase Commit)。 阶段1: innodb 的 Prepare,是在把事务提交之前,对应的redo log 和undo log全部写入了。binlog也已经写入到内存,只等刷盘。 阶段2: 客户端收到的Commit指令,先刷盘binlog,然后让innodb执行 Commit。 2PC的显著特点是,在阶段1就把90%的工作做完了,就等阶段2的收尾,所以在阶段2收到客户端的Commit指令后,只要不宕机,事务就能成功提交。但如果宕机,如何 恢复呢? 首先,整个过程以binlog的刷盘来判定一个事务是否被成功提交,即以binlog为准,让redo log向binlog "靠齐"。具体分为以下几个场景: 场景1:在阶段1宕机,此时binlog完全在内存中,宕机消失。redo log记录了未提交的日志。不需要依赖binlog,redo log自己可以回滚未提交的日志。 场景2:阶段2宕机,binlog写了一半,innodb commit 还未执行。对binlog做截断,对redo log做回滚,处理方法和场景1一样。 场景3:binlog写入成功,innodb未提交。此时遍历binlog,binlog中存在,innodb中不存在的事务,发起commit操作。 6.7.3 三种主从复制方式 mysql的三种主从复制方式: 1.同步复制 待等待的slave接收到binlog,并且接收完毕,master才认为事务提交成功,再对客户端返回最安全,但性能没法忍受,一般不会用; 2.异步复制 只要master事务提交成功,就对客户端返回成功,后台线程异步的把binlog同步给slave,然后slave回收binlog,虽然最快,但可能丢失数据; 3.半同步复制 master事务提交,同时把binlog同步给slave,只要部分slave接收到了binlog(slave的数量可以设置),就认为事务提交成功,对客户端返回。 对于异步复制,可能是会丢失数据的。master宕机,切换到了slave,此时slave上没有最新的数据。所以很多时候都是半同步复制。 是不是半同步数据就不会丢失数据呢?不是的。半同步复制可能会退化为异步复制,因为master不可能无限期等待slave,当超过某个时间,slave还没回复ack时, master就会切换为异步复制模式。 另外,还有一个参数 rpl_semi_sync_master_wait_slave_count,可以设置在半同步复制模式下,需要等待几个slave的ack,才认为事务提交成功。默认是 1,即多个slave中只要有其中一个返回了,master就会向客户端返回事务提交成功。 无论是异步复制,还是半同步复制(可能退化为异步复制),都可能在主从切换的时候丢失数据。业务的做法一般是牺牲一致性换取高性能,即在master宕机后切换到 slave,忍受少量的数据丢失,后续再人工修复。 但如果主从复制延时太大,切换到slave,丢失数据太多,也难以接受。为了降低主从复制的延迟,并行复制,特别是在跨机房的情况下。 6.7.3 并行复制 原生的mysql主从复制的原理,分为2个阶段: 阶段1: 把master的binlog 搬到slave上面,形成 RelayLog。在这个搬运的过程中,master和slave两边各有一个线程,master上是 dump thead, slave 上面的叫 IO thread. 阶段2: slave把RelayLog回放到数据库,通过一个叫 SQL Theaad 的线程执行。 可见,整个复制过程无论是log的传输,还是回放过程,都是单线程的。而并行复制,就是把回放环节并行化了。 所谓的并行复制,准确的说是 并行回放,因为传输环节还是单线程的。之所以传输环节没有用多线程,主要是因为没有必要。一个原因是在回放环节,而不在传输环节; 另外一个原因是binlog本身是全局有序的,如果用多线程传输,还要重新排序和重组,可能得不偿失。 而并行回放的难点在于事务的并行提交。binlog本身是全局只有一份,同一个mysql的实例,不同的库,不同的表事务binlog都串行的排列。所谓的并行回放,就是 一次性从relaylog中拿出多个事务,并行的执行。这就涉及什么样的事务能并行,什么样的事务不能并行。大的来说,有两个策略: 第一类:按数据维度并行。 从粗到细,三个粒度:不同库的事务可以并行,不同表的事务可以并行,不同行的事务可以并行。当然实际没有这么简单,因为一个事务可能修改多个库的多个表的 多条记录。如,事务1修改了记录1,2,事务2修改了记录2,3,则两个事务就无法并行了。 第二类:按事务的提交顺序并行。 mysql中有commit_id的概念,表示哪些事务是同时提交的。什么意思呢?如果在一个事务还没结束之前,另外一个事务也开始进入提交阶段,这说明两个事务是 在并行的,它们操作的肯定是不同的数据库记录。所以,在回放的时候具有同样的commit_id的事务可以并行。 当然,两个事务的commit_id不一样,不代表不能并行。commit_id 不一样,可能仅仅是因为一个事务在另外一个事务结束之后才开始的,它们在时间上有先后 顺序,但操作的数据完全不同,用第一类并发策略仍然可以并行。