Dunwu Blog

大道至简,知易行难

《MySQL 实战 45 讲》笔记

基础架构:一条 SQL 查询语句是如何执行的?

  1. 连接器:连接器负责跟客户端建立连接、获取权限、维持和管理连接。
  2. 查询缓存:命中缓存,则直接返回结果。弊大于利,因为失效非常频繁——任何更新都会清空查询缓存。
  3. 分析器
    • 词法分析:解析 SQL 关键字
    • 语法分析:生成一颗对应的语法解析树
  4. 优化器
    • 根据语法树生成多种执行计划
    • 索引选择:根据策略选择最优方式
  5. 执行器
    • 校验读写权限
    • 根据执行计划,调用存储引擎的 API 来执行查询
  6. 存储引擎:存储数据,提供读写接口

日志系统:一条 SQL 更新语句是如何执行的?

更新流程和查询的流程大致相同,不同之处在于:更新流程还涉及两个重要的日志模块:

  • redo log(重做日志)
  • binlog(归档日志)

redo log

如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 采用了 WAL 技术(全程是 Write-Ahead Logging),它的关键点就是先写日志,再写磁盘。

具体来说,当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log 里,并更新内存,这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。

InnoDB 的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么总共就可以记录 4GB 的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。

write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。

write pos 和 checkpoint 之间的是还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。

有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe

binlog

redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。

redo log 和 binlog 的差异:

  1. redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  2. redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
  3. redo log 是循环写的,空间固定会用完;binlog 是追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

再来看一下:update 语句时的内部流程

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

两阶段提交

为什么日志需要“两阶段提交”

由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,或者采用反过来的顺序。

  1. 先写 redo log 后写 binlog。假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 c 的值是 1。
    • 但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。
    • 然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。
  2. 先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。

可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。

事务隔离:为什么你改了我还看不见?

深入浅出索引

索引数据结构

哈希索引

适用:只能用于等值查询

哈希索引的限制

  • 无法用于排序:因为哈希索引数据不是按照索引值顺序存储的。
  • 不支持部分索引匹配查找:因为哈希索引时使用索引列的全部内容来进行哈希计算的。
  • 不能用索引中的值来避免读取行:因为哈希索引只包含哈希值和行指针,不存储字段。
  • 只支持等值比较查询(包括 =、IN()、<=>);不支持任何范围查询
  • 哈希索引非常快,除非有很多哈希冲突:
    • 出现哈希冲突时,必须遍历链表中所有行指针,逐行比较匹配
    • 如果哈希冲突多的话,维护索引的代价会很高

哈希索引的应用

Mysql 中,只有 Memory 存储引擎显示支持哈希索引。

有序数组索引

有序数组索引在等值查询和范围查询场景中的性能都非常优秀。

如果仅仅看查询效率,有序数组就是最好的数据结构了。但是,更新数据的时候,往中间插入一个记录就必须得挪动后面所有的记录,成本太高。所以,有序数组索引只适用于静态存储引擎

B+ 树索引

在 InnoDB 中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。又因为 InnoDB 使用了 B+ 树索引模型,所以数据都是存储在 B+ 树中的。

每一个索引在 InnoDB 里面对应一棵 B+ 树。

根据叶子节点的内容,索引类型分为主键索引(聚簇索引)和非主键索引(非聚簇索引)。

  • 聚簇索引:叶子节点存储行数据。
    • 可以把相关数据保存在一起
    • 数据访问更快
    • 使用覆盖索引扫描的查询可以直接使用叶节点中的主键值
  • 非聚簇索引:叶子节点存储主键。访问需要两次索引查找。
    • 第一次获得对应主键值
    • 第二次去聚簇索引中查找对应行,即回表

B+ 树为了维护索引有序性,在插入新值的时候需要做动态调整。

  • 插入位置如果不是末尾,需要挪动后面的数据,空出位置。
  • 更糟的情况是,如果待插入位置所在的数据页已经满了,根据 B+ 树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。
    • 这种情况下,性能自然会受影响。
    • 除了性能外,页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约 50%。
  • 如果相邻的两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。

由于每个非主键索引的叶子节点上都是主键的值。显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。

什么场景适合用业务字段直接做主键的呢?

  • 只有一个索引;
  • 该索引必须是唯一索引。

为什么不用二叉树?

要考虑尽量减少磁盘扫描。

索引策略

  • 索引基本原则
    • 索引不是越多越好,不要为所有列都创建索引
    • 要尽量避免冗余和重复索引
    • 要考虑删除未使用的索引
    • 尽量的扩展索引,不要新建索引
    • 频繁作为 WHERE 过滤条件的列应该考虑添加索引
  • 独立索引
    • 索引列不能是表达式的一部分,也不能是函数的参数
    • 对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。
  • 前缀索引和索引选择性
    • 索引的选择性是指:不重复的索引值和数据表记录总数的比值。
    • 选择性越高,查询效率越高使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。
    • order by 无法使用前缀索引,无法把前缀索引用作覆盖索引
  • 最左前缀匹配原则
    • 将选择性高的列或基数大的列优先排在多列索引最前列
    • 匹配联合索引最左前缀的时候,如果遇到了范围查询,比如(<)(>)和 between 等,就会停止匹配。
  • 覆盖索引:索引上的信息足够满足查询请求,不需要回表查询数据。
  • 使用索引扫描来排序:ORDER BY 的字段作为索引,这样命中索引的查询结果,不需要额外排序
  • = 和 in 可以乱序:不需要考虑 =、IN 等的顺序,Mysql 会自动优化这些条件的顺序,以匹配尽可能多的索引列。

全局锁和表锁 :给表加个字段怎么有这么多阻碍?

根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类

全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。

MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

表锁的语法是 lock tables … read/write。与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

另一类表级的锁是 MDL(metadata lock)。MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
  • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。

行锁功过:怎么减少行锁对性能的影响?

MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的,这也是 MyISAM 被 InnoDB 替代的重要原因之一。

如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

死锁和死锁检测

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁

当出现死锁以后,有两种策略:

  • 进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
    • 在 InnoDB 中,innodb_lock_wait_timeout 的默认值是 50s,意味着如果此策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。
    • 但是,我们又不可能直接把这个时间设置成一个很小的值,比如 1s。这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。
  • 发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
    • 主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的。每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。
    • 极端情况下,如果所有事务都要更新同一行:每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。

减少死锁的主要方向,就是控制访问相同资源的并发事务量。

事务到底是隔离的还是不隔离的

“快照”在 MVCC 里是怎么工作的?

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。

图中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。

因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。

当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  3. 如果落在黄色部分,那就包括两种情况
    a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
    b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

更新逻辑

更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

事务的可重复读的能力是怎么实现的?

可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

普通索引和唯一索引,应该怎么选择?

普通索引和唯一索引的查询性能相差微乎其微

当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InooDB 会将这些更新操作缓存在 change buffer 中,这样就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行 change buffer 中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。

虽然名字叫作 change buffer,实际上它是可以持久化的数据。也就是说,change buffer 在内存中有拷贝,也会被写入到磁盘上。将 change buffer 中的操作应用到原数据页,得到最新结果的过程称为 merge。除了访问这个数据页会触发 merge 外,系统有后台线程会定期 merge。在数据库正常关闭(shutdown)的过程中,也会执行 merge 操作。

显然,如果能够将更新操作先记录在 change buffer,减少读磁盘,语句的执行速度会得到明显的提升。而且,数据读入内存是需要占用 buffer pool 的,所以这种方式还能够避免占用内存,提高内存利用率。

change buffer 的使用场景

change buffer 只限于用在普通索引的场景下,而不适用于唯一索引。

因为 merge 的时候是真正进行数据更新的时刻,而 change buffer 的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做 merge 之前,change buffer 记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。

  • 对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。
  • 如果一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在 change buffer,但之后由于马上要访问这个数据页,会立即触发 merge 过程。这样随机访问 IO 的次数不会减少,反而增加了 change buffer 的维护代价。

索引选择和实践

如果所有的更新后面,都马上伴随着对这个记录的查询,那么你应该关闭 change buffer。而在其他情况下,change buffer 都能提升更新性能。

在实际使用中,你会发现,普通索引和 change buffer 的配合使用,对于数据量大的表的更新优化还是很明显的。

特别地,在使用机械硬盘时,change buffer 这个机制的收效是非常显著的。所以,当你有一个类似“历史数据”的库,并且出于成本考虑用的是机械硬盘时,那你应该特别关注这些表里的索引,尽量使用普通索引,然后把 change buffer 尽量开大,以确保这个“历史数据”表的数据写入速度。

change buffer 和 redo log

图 - 带 change buffer 的更新过程

图 - 带 change buffer 的读过程

redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。

由于唯一索引用不上 change buffer 的优化机制,因此如果业务可以接受,从性能角度出发我建议你优先考虑非唯一索引。

MySQL 为什么有时候会选错索引

选择索引是优化器的工作。

优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越少,消耗的 CPU 资源越少。但是,扫描行数并不是唯一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行综合判断。

MySQL 在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。

这个统计信息就是索引的“区分度”。显然,一个索引上不同的值越多,这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。也就是说,这个基数越大,索引的区分度越好。

如果发现 explain 的结果预估的 rows 值跟实际情况差距比较大,可以采用 analyze table t 命令来重新统计索引信息

对于其他优化器误判的情况,你可以在应用端用 force index 来强行指定索引,也可以通过修改语句来引导优化器,还可以通过增加或者删除索引来绕过这个问题。

怎么给字符串字段加索引?

使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。

在建立索引时关注的是区分度,区分度越高越好。因为区分度越高,意味着重复的键值越少。因此,我们可以通过统计索引上有多少个不同的值来判断要使用多长的前缀。

可以通过下面的方式来测试不同前缀长度的区分度:

1
2
3
4
5
6
7
select count(distinct email) as L from SUser;
select
count(distinct left(email,4))as L4,
count(distinct left(email,5))as L5,
count(distinct left(email,6))as L6,
count(distinct left(email,7))as L7,
from SUser;

使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例,比如 5%。然后,在返回的 L4~L7 中,找出不小于 L * 95% 的值,假设这里 L6、L7 都满足,你就可以选择前缀长度为 6。

需要注意:使用前缀索引就用不上覆盖索引对查询性能的优化了,必须回表才能拿到该索引字段的完整信息。

如果前缀的区分度不够好的情况时如何处理?

第一种方式是使用倒序存储

1
select field_list from t where id_card = reverse('input_id_card_string');

第二种方式是使用 hash 字段

可以在表上再创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引。

1
alter table t add id_card_crc int unsigned, add index(id_card_crc);

然后每次插入新记录的时候,都同时用 crc32() 这个函数得到校验码填到这个新字段。由于校验码可能存在冲突,也就是说两个不同的身份证号通过 crc32() 函数得到的结果可能是相同的,所以你的查询语句 where 部分要判断 id_card 的值是否精确相同。

1
select field_list from t where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string'

这两种方式的对比:

  • 它们的相同点是,都不支持范围查询。倒序存储的字段上创建的索引是按照倒序字符串的方式排序的,已经没有办法利用索引方式查出身份证号码在 [ID_X, ID_Y] 的所有市民了。同样地,hash 字段的方式也只能支持等值查询。
  • 它们的区别
    • 从占用的额外空间来看,倒序存储方式在主键索引上,不会消耗额外的存储空间,而 hash 字段方法需要增加一个字段。当然,倒序存储方式使用 4 个字节的前缀长度应该是不够的,如果再长一点,这个消耗跟额外这个 hash 字段也差不多抵消了。
    • 在 CPU 消耗方面,倒序方式每次写和读的时候,都需要额外调用一次 reverse 函数,而 hash 字段的方式需要额外调用一次 crc32() 函数。如果只从这两个函数的计算复杂度来看的话,reverse 函数额外消耗的 CPU 资源会更小些。
    • 从查询效率上看,使用 hash 字段方式的查询性能相对更稳定一些。因为 crc32 算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近 1。而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。

小结:

  1. 直接创建完整索引,这样可能比较占用空间;
  2. 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;
  3. 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;
  4. 创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。

为什么我的 MySQL 会“抖”一下?

利用 WAL 技术,数据库将随机写转换成了顺序写,大大提升了数据库的性能。

但是,由此也带来了内存脏页的问题。脏页会被后台线程自动 flush,也会由于数据页淘汰而触发 flush,而刷脏页的过程由于会占用资源,可能会让你的更新和查询语句的响应时间长一些。

为什么表数据删掉一半,表文件大小不变?

表数据既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数 innodb_file_per_table 控制的:

  1. 这个参数设置为 OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;
  2. 这个参数设置为 ON 表示的是,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。

我建议你不论使用 MySQL 的哪个版本,都将这个值设置为 ON。因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过 drop table 命令,系统就会直接删除这个文件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。

要删掉 R4 这个记录,InnoDB 引擎只会把 R4 这个记录标记为删除。如果之后要再插入一个 ID 在 300 和 600 之间的记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。

如果删掉了一个数据页上的所有记录,则整个数据页就可以被复用了。

如果把整个表的数据删除,则所有的数据页都会被标记为可复用。但是磁盘上,文件不会变小。

delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。也就是说,通过 delete 命令是不能回收表空间的。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。

如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂。页分裂完成后,就可能产生空洞。另外,更新索引上的值,可以理解为删除一个旧的值,再插入一个新值。不难理解,这也是会造成空洞的。

也就是说,经过大量增删改的表,都是可能是存在空洞的。

重建表

那么,如何收缩表空间,去除空洞呢?

可以使用 alter table A engine=InnoDB 命令来重建表。MySQL 会自动完成转存数据、交换表名、删除旧表的操作。

显然,花时间最多的步骤是往临时表插入数据的过程,如果在这个过程中,有新的数据要写入到表 A 的话,就会造成数据丢失。因此,在整个 DDL 过程中,表 A 中不能有更新。也就是说,这个 DDL 不是 Online 的。

MySQL 5.6 版本开始引入的 Online DDL,对这个操作流程做了优化。

  1. 建立一个临时文件,扫描表 A 主键的所有数据页;
  2. 用数据页中表 A 的记录生成 B+ 树,存储到临时文件中;
  3. 生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中,对应的是图中 state2 的状态;
  4. 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件,对应的就是图中 state3 的状态;
  5. 用临时文件替换表 A 的数据文件。

对于一个大表来说,Online DDL 最耗时的过程就是拷贝数据到临时表的过程,这个步骤的执行期间可以接受增删改操作。所以,相对于整个 DDL 过程来说,锁的时间非常短。对业务来说,就可以认为是 Online 的。

需要补充说明的是,上述的这些重建方法都会扫描原表数据和构建临时文件。对于很大的表来说,这个操作是很消耗 IO 和 CPU 资源的。因此,如果是线上服务,你要很小心地控制操作时间。如果想要比较安全的操作的话,我推荐你使用 GitHub 开源的 gh-ost 来做。

optimize table、analyze table 和 alter table 这三种方式重建表的区别:

  • 从 MySQL 5.6 版本开始,alter table t engine = InnoDB(也就是 recreate)默认的就是上面图 4 的流程了;
  • analyze table t 其实不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程中加了 MDL 读锁;
  • optimize table t 等于 recreate+analyze。

count(*)这么慢,我该怎么办?

不同的 MySQL 引擎中,count(*) 有不同的实现方式。

  • MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高;
  • 而 InnoDB 引擎就麻烦了,它执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。

为什么 InnoDB 不跟 MyISAM 一样,也把数字存起来呢

因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB 表“应该返回多少行”也是不确定的。

InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。所以,普通索引树比主键索引树小很多。对于 count(*) 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL 优化器会找到最小的那棵树来遍历。

  • MyISAM 表虽然 count(*) 很快,但是不支持事务;
  • show table status 命令虽然返回很快,但是不准确;
  • InnoDB 表直接 count(*) 会遍历全表,虽然结果准确,但会导致性能问题。

保存计数

可以使用 Redis 保存计数,但存在丢失更新一集数据不一致问题。

可以使用数据库其他表保存计数,但要用事务进行控制,增/删数据时,同步改变计数。

不同的 count 用法

对于 count(主键 id) 来说,InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来,返回给 server 层。server 层拿到 id 后,判断是不可能为空的,就按行累加。

对于 count(1) 来说,InnoDB 引擎遍历整张表,但不取值。server 层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。

对于 count(字段) 来说

  • 如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;
  • 如果这个“字段”定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。

但是 count(*) 是例外,并不会把全部字段取出来,而是专门做了优化,不取值。count(*) 肯定不是 null,按行累加。

所以结论是:按照效率排序的话,count(字段)<count(主键 id)<count(1)≈count(*),所以我建议你,尽量使用 count(*)。

order by 是怎么工作的?

用 explain 命令查看执行计划时,Extra 这个字段中的“Using filesort”表示的就是需要排序。

全字段排序

1
select city,name,age from t where city='杭州' order by name limit 1000;

这个语句执行流程如下所示 :

  1. 初始化 sort_buffer,确定放入 name、city、age 这三个字段;
  2. 从索引 city 找到第一个满足 city=’杭州’条件的主键 id,也就是图中的 ID_X;
  3. 到主键 id 索引取出整行,取 name、city、age 三个字段的值,存入 sort_buffer 中;
  4. 从索引 city 取下一个记录的主键 id;
  5. 重复步骤 3、4 直到 city 的值不满足查询条件为止,对应的主键 id 也就是图中的 ID_Y;
  6. 对 sort_buffer 中的数据按照字段 name 做快速排序;
  7. 按照排序结果取前 1000 行返回给客户端。

按 name 排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数 sort_buffer_size。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。

外部排序一般使用归并排序算法。可以这么简单理解,MySQL 将需要排序的数据分成 N 份,每一份单独排序后存在这些临时文件中。然后把这 N 个有序文件再合并成一个有序的大文件。

rowid 排序

如果表的字段太多,导致单行太大,那么全字段排序的效率就不够好。

这种情况下,Mysql 可以采用 rowid 排序,相比于全字段排序,它的主要差异在于:

取行数据时,不取出整行,而只是取出 id 和用于排序的字段。当排序结束后,再根据 id 取出要查询的字段返回给客户端。

全字段排序 VS rowid 排序

如果内存足够大,Mysql 会优先选择全字段排序,把需要的字段都放到 sort_buffer 中,这样排序后就会直接从内存里面返回查询结果了,不用再回到原表去取数据。

如果内存太小,会影响排序效率,才会采用 rowid 排序算法,这样排序过程中一次可以排序更多行,但是需要再回到原表去取数据。

并不是所有的 order by 语句,都需要排序操作的。MySQL 之所以需要生成临时表,并且在临时表上做排序操作,其原因是原来的数据都是无序的。如果能保证排序字段命中索引,那么就无需再排序了。

覆盖索引是指,索引上的信息足够满足查询请求,不需要再回到主键索引上去取数据。

为什么这些 SQL 语句逻辑相同,性能却差异巨大?

函数操作会破坏索引有序性

对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。

示例:

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `tradelog` (
`id` int(11) NOT NULL,
`tradeid` varchar(32) DEFAULT NULL,
`operator` int(11) DEFAULT NULL,
`t_modified` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `tradeid` (`tradeid`),
KEY `t_modified` (`t_modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

select count(*) from tradelog where month(t_modified)=7;

由于在 t_modified 字段加了 month() 函数操作,导致了全索引扫描。为了能够用上索引的快速定位能力,我们就要把 SQL 语句改成基于字段本身的范围查询。

1
2
3
4
select count(*) from tradelog where
-> (t_modified >= '2016-7-1' and t_modified<'2016-8-1') or
-> (t_modified >= '2017-7-1' and t_modified<'2017-8-1') or
-> (t_modified >= '2018-7-1' and t_modified<'2018-8-1');

隐式转换

下面两个 SQL 的执行流程相同:

1
2
select * from tradelog where tradeid=110717;
select * from tradelog where CAST(tradid AS signed int) = 110717;

交易编号 tradeid 这个字段上,本来就有索引,但是 explain 的结果却显示,这条语句需要走全表扫描。这是由于这条语句隐式增加了转换函数,而对索引字段做函数操作,优化器会放弃走树搜索功能。

隐式字符编码转换

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
CREATE TABLE `trade_detail` (
`id` int(11) NOT NULL,
`tradeid` varchar(32) DEFAULT NULL,
`trade_step` int(11) DEFAULT NULL, /* 操作步骤 */
`step_info` varchar(32) DEFAULT NULL, /* 步骤信息 */
PRIMARY KEY (`id`),
KEY `tradeid` (`tradeid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

insert into tradelog values(1, 'aaaaaaaa', 1000, now());
insert into tradelog values(2, 'aaaaaaab', 1000, now());
insert into tradelog values(3, 'aaaaaaac', 1000, now());

insert into trade_detail values(1, 'aaaaaaaa', 1, 'add');
insert into trade_detail values(2, 'aaaaaaaa', 2, 'update');
insert into trade_detail values(3, 'aaaaaaaa', 3, 'commit');
insert into trade_detail values(4, 'aaaaaaab', 1, 'add');
insert into trade_detail values(5, 'aaaaaaab', 2, 'update');
insert into trade_detail values(6, 'aaaaaaab', 3, 'update again');
insert into trade_detail values(7, 'aaaaaaab', 4, 'commit');
insert into trade_detail values(8, 'aaaaaaac', 1, 'add');
insert into trade_detail values(9, 'aaaaaaac', 2, 'update');
insert into trade_detail values(10, 'aaaaaaac', 3, 'update again');
insert into trade_detail values(11, 'aaaaaaac', 4, 'commit');

SELECT d.*
FROM tradelog l, trade_detail d
WHERE d.tradeid = l.tradeid AND l.id = 2;
# 等价于
select * from trade_detail where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value;

# 不需要做字符编码转换
EXPLAIN
SELECT l.operator
FROM tradelog l, trade_detail d
WHERE d.tradeid = l.tradeid AND d.id = 2;

字符集 utf8mb4 是 utf8 的超集,所以当这两个类型的字符串在做比较的时候,MySQL 内部的操作是,先把 utf8 字符串转成 utf8mb4 字符集,再做比较。

为什么我只查一行的语句,也执行这么慢?

查询长时间不返回

查询结果长时间不返回。

一般碰到这种情况的话,大概率是表被锁住了。接下来分析原因的时候,一般都是首先执行一下 show processlist 命令,看看当前语句处于什么状态。

使用 show processlist 命令查看 Waiting for table metadata lock 的示意图

出现这个状态表示的是,现在有一个线程正在表 t 上请求或者持有 MDL 写锁,把 select 语句堵住了。

幻读是什么,幻读有什么问题?

产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。

幻读是什么

幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。

说明:

  • 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
  • 幻读专指“新插入的行”。

幻读有什么问题

  • 语义上的破坏
  • 数据一致性的问题
  • 即使把所有记录都加上锁,还是阻止不了新插入的记录

如何解决幻读

  • 间隙锁(gap lock
  • select * from t where d=5 for update,不止给数据库已有的 6 个记录加上了行锁,还同时加了 7 个间隙锁。这样就确保了无法再插入新纪录
  • next-key lock 是前开后闭区间
  • 间隙锁和行锁合称为 next-key lock
  • 跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作
  • 间隙锁是在可重复读隔离级别下才会生效的,所以把隔离级别设置为读提交的话,就没有间隙锁了。但同时,要解决可能出现的数据和日志不一致的问题,需要把binlog格式设置为row

间隙锁和next-key lock的引入,帮我们解决了幻读问题,但同时也带来了一些困扰

  • 间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响并发度的

为什么我只改一行的语句,锁这么多?

加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。

  1. 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
  2. 原则 2:查找过程中访问到的对象才会加锁。
  3. 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
  4. 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
  5. 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

MySQL 有哪些“饮鸩止渴”提高性能的方法?

短连接风暴

短连接模式就是连接到数据库后,执行很少的 SQL 语句就断开,下次需要的时候再重连。

  • MySQL 建立连接的成本很高。
    • 除了正常的网络连接三次握手外,还需要做登录权限判断和获得这个连接的数据读写权限。
  • 短连接模型存在一个风险:一旦数据库处理速度很慢,连接数就会暴涨。
  • max_connections 控制一个 MySQL 实例同时存在的连接数的上限
    • 超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。

解决方法 1:先处理掉那些占着连接但是不工作的线程

  • show processlist 查看 sleep 的线程,然后干掉空闲的连接。注意:可能会误杀事务。
  • 应该优先断开事务外空闲的连接。
    • 通过查 information_schema 库的 innodb_trx 表判断是否处于事务中。
  • 再考虑断开事务内空闲太久的连接。

解决方法 2:减少连接过程的消耗

如果想短时间创建大量数据库连接,有一种做法是跳过权限验证。

跳过权限验证的方法是:重启数据库,并使用 –skip-grant-tables 参数启动。

注意:此方法风险极高,不建议使用。

慢查询性能问题

一般有三种可能:

  1. 索引没有设计好;
  2. SQL 语句没写好;
  3. MySQL 选错了索引。
    • 可以通过 force index 强制使用某个索引

QPS 突增问题

有时候由于业务突然出现高峰,或者应用程序 bug,导致某个语句的 QPS 突然暴涨,也可能导致 MySQL 压力过大,影响服务。

应对方法:

  1. 一种是由全新业务的 bug 导致的。假设你的 DB 运维是比较规范的,也就是说白名单是一个个加的。这种情况下,如果你能够确定业务方会下掉这个功能,只是时间上没那么快,那么就可以从数据库端直接把白名单去掉。
  2. 如果这个新功能使用的是单独的数据库用户,可以用管理员账号把这个用户删掉,然后断开现有连接。这样,这个新功能的连接不成功,由它引发的 QPS 就会变成 0。
  3. 如果这个新增的功能跟主体功能是部署在一起的,那么我们只能通过处理语句来限制。这时,我们可以使用上面提到的查询重写功能,把压力最大的 SQL 语句直接重写成”select 1”返回。
    • 这个方法是用于止血的,但风险很高,不建议使用。

个人观点:以上方法都是基于 DBA 视角的处理方式。实际环境中,应该做好数据库 QPS、CPU 监控,如果发现请求量激增,快要达到瓶颈,可以先紧急弹性扩容,保障业务不损失。然后排查原因,是否是新业务设计不当导致、是否是大数据在也业务高峰期进行数据分析导致,等等。

Mysql 是怎么保证数据不丢的

只要 redo log 和 binlog 保证持久化到磁盘,就能确保 MySQL 异常重启后,数据可以恢复。

binlog 的写入机制

binglog 写入逻辑:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。

一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。

系统给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。

write 和 fsync 的时机,是由参数 sync_binlog 控制的:

  1. sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
  2. sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
  3. sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。

redo log 的写入机制

redo log 要先写到 redo log buffer

innodb_flush_log_at_trx_commit 参数用于控制 redo log buffer 写入 page cache 和写入磁盘的时机

  1. 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
  2. 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
  3. 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。

InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。

事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。

还有两种场景会让一个没有提交的事务的 redo log 写入到磁盘中。

  1. 一种是,redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。
  2. 另一种是,并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。

如果 MySQL 出现了 IO 性能瓶颈,可以通过哪些方法来提升性能

WAL 机制主要得益于两个方面:

  1. redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
  2. 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。

有三种方法提升 Mysql IO 性能:

  • 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。

    • binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
    • binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。
  • 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。

  • 将 innodb_flush_log_at_trx_commit 设置为 2。这样做的风险是,主机掉电的时候会丢数据。

Mysql 是怎么保证主备一致的

MySQL 主备的基本原理

MySQL 主备切换流程

客户端的读写都直接访问主库,备库只是将主库的更新都同步过来,到本地执行。

建议将备库设为 readonly 模式:

  • 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作;
  • 防止切换逻辑有 bug,比如切换过程中出现双写,造成主备不一致;
  • 可以用 readonly 状态,来判断节点的角色。

readonly 设置对超级 (super) 权限用户是无效的,而用于同步更新的线程,就拥有超级权限。

备库跟主库之间维持了一个长连接。主库内部有一个线程,专门用于服务备库的这个长连接。一个事务日志同步的完整过程是这样的:

  1. 在备库上通过 change master 命令,设置主库的 IP、端口、用户名、密码,以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量。
  2. 在备库上执行 start slave 命令,这时候备库会启动两个线程,就是图中的 io_thread 和 sql_thread。其中 io_thread 负责与主库建立连接。
  3. 主库校验完用户名、密码后,开始按照备库传过来的位置,从本地读取 binlog,发给备库。
  4. 备库拿到 binlog 后,写到本地文件,称为中转日志(relay log)。
  5. sql_thread 读取中转日志,解析出日志里的命令,并执行。

binlog 三种格式对比

binlog 有两种格式,一种是 statement,一种是 row

当 binlog_format=statement 时,binlog 里面记录的就是 SQL 语句的原文。

当 binlog_format=row 时,binlog 里没有了 SQL 语句的原文,而是替换成了两个 event:Table_map 和 Delete_rows。

  1. Table_map event,用于说明接下来要操作的表是 test 库的表 t;
  2. Delete_rows event,用于定义删除的行为。

为什么会有 mixed 这种 binlog 格式的存在场景?

  • 有些 statement 格式的 binlog 可能会导致主备不一致,所以要使用 row 格式。

  • row 格式很占空间

  • mixed 格式的 binlog,是指 MySQL 自己会判断这条 SQL 语句是否可能引起主备不一致,如果有可能,就用 row 格式,否则就用 statement 格式。

循环复制问题

如果两个节点互为主备,就可能出现循环复制问题。

如何解决循环复制问题:

  1. 规定两个库的 server id 必须不同,如果相同,则它们之间不能设定为主备关系;
  2. 一个备库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog;
  3. 每个库在收到从自己的主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。

Mysql 是怎么保证高可用的

主备延迟

所谓主备延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值。

show slave status 命令可用于显示备库延迟(seconds_behind_master),其计算方式如下:

  1. 每个事务的 binlog 里面都有一个时间字段,用于记录主库上写入的时间;
  2. 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到 seconds_behind_master。

主备延迟最直接的表现是,备库消费中转日志(relay log)的速度,比主库生产 binlog 的速度要慢。

主备延迟的来源

  • 备库的机器性能比主库的机器性能差。
    • 一般应采用对称部署。
  • 备库的压力大。
    • 因为一般会采用读写分离架构,备库承担读请求,反而导致备库压力过大。
    • 解决方法:
      • 一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力。
      • 通过 binlog 输出到外部系统,比如 Hadoop 这类系统,让外部系统提供统计类查询的能力。
  • 大事务
    • 不要一次性地用 delete 语句删除太多数据
    • 大表 DDL:计划内的 DDL,建议使用 gh-ost 方案
    • 备库的并行复制

可靠性优先策略

  1. 判断备库现在的 seconds_behind_master,如果小于某个值(比如 5 秒)继续下一步,否则持续重试这一步;
  2. 把主库改成只读状态,即把 readonly 设置为 true;
  3. 判断备库的 seconds_behind_master 的值,直到这个值变成 0 为止;
  4. 把备库改成可读写状态,也就是把 readonly 设置为 false;
  5. 把业务请求切到备库。

这个切换流程,一般是由专门的 HA 系统来完成的,我们暂时称之为可靠性优先流程。

可用性优先策略

如果强行把步骤 4、5 调整到最开始执行,也就是说不等主备数据同步,直接把连接切到备库,并且让备库可以读写,那么系统几乎就没有不可用时间了。

我们把这个切换流程,暂时称作可用性优先流程。这个切换流程的代价,就是可能出现数据不一致的情况。

备库为什么会延迟好几个小时

按表分发策略

每个 worker 线程对应一个 hash 表,用于保存当前正在这个 worker 的“执行队列”里的事务所涉及的表。hash 表的 key 是“库名. 表名”,value 是一个数字,表示队列中有多少个事务修改这个表。

在有事务分配给 worker 时,事务里面涉及的表会被加到对应的 hash 表中。worker 执行完成后,这个表会被从 hash 表中去掉。

事务在分发时,有三种情况:

  1. 如果跟所有 worker 都不冲突,coordinator 线程就会把这个事务分配给最空闲的 woker;
  2. 如果跟多于一个 worker 冲突,coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的 worker 只剩下 1 个;
  3. 如果只跟一个 worker 冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的 worker。

这种方案在多个表负载均匀的场景里应用效果很好。但是,如果碰到热点表,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个 worker 中,就变成单线程复制了。

按行分发策略

要解决热点表的并行复制问题,就需要一个按行并行复制的方案。按行复制的核心思路是:如果两个事务没有更新相同的行,它们在备库上可以并行执行。显然,这个模式要求 binlog 格式必须是 row。

按行复制和按表复制的数据结构差不多,也是为每个 worker,分配一个 hash 表。只是要实现按行分发,这时候的 key,就必须是“库名 + 表名 + 唯一键的值”。

这个“唯一键”只有主键 id 是不够的,还需要考虑唯一键。即 key 应该是“库名 + 表名 + 索引 a 的名字 +a 的值”。因此,coordinator 在解析这个语句的 binlog 的时候,这个事务的 hash 表就有三个项:

  1. key=hash_func(db1+t1+“PRIMARY”+2), value=2; 这里 value=2 是因为修改前后的行 id 值不变,出现了两次。
  2. key=hash_func(db1+t1+“a”+2), value=1,表示会影响到这个表 a=2 的行。
  3. key=hash_func(db1+t1+“a”+1), value=1,表示会影响到这个表 a=1 的行。

可见,相比于按表并行分发策略,按行并行策略在决定线程分发的时候,需要消耗更多的计算资源。

MySQL 5.6 版本的并行复制策略

官方 MySQL5.6 版本,支持了并行复制,只是支持的粒度是按库并行。

MariaDB 的并行复制策略

MariaDB 的并行复制策略利用的就是这个特性:

  1. 能够在同一组里提交的事务,一定不会修改同一行;
  2. 主库上可以并行执行的事务,备库上也一定是可以并行执行的。

在实现上,MariaDB 是这么做的:

  1. 在一组里面一起提交的事务,有一个相同的 commit_id,下一组就是 commit_id+1;
  2. commit_id 直接写到 binlog 里面;
  3. 传到备库应用的时候,相同 commit_id 的事务分发到多个 worker 执行;
  4. 这一组全部执行完成后,coordinator 再去取下一批。

MySQL 5.7 的并行复制策略

由参数 slave-parallel-type 来控制并行复制策略:

  1. 配置为 DATABASE,表示使用 MySQL 5.6 版本的按库并行策略;
  2. 配置为 LOGICAL_CLOCK,表示的就是类似 MariaDB 的策略。不过,MySQL 5.7 这个策略,针对并行度做了优化。这个优化的思路也很有趣儿。

主库出问题了,从库怎么办?

A 和 A’互为主备, 从库 B、C、D 指向的是主库 A

基于位点的主备切换

1
2
3
4
5
6
7
CHANGE MASTER TO
MASTER_HOST=$host_name
MASTER_PORT=$port
MASTER_USER=$user_name
MASTER_PASSWORD=$password
MASTER_LOG_FILE=$master_log_name
MASTER_LOG_POS=$master_log_pos
  • MASTER_HOST、MASTER_PORT、MASTER_USER 和 MASTER_PASSWORD 四个参数,分别代表了主库 A’的 IP、端口、用户名和密码。
  • 最后两个参数 MASTER_LOG_FILE 和 MASTER_LOG_POS 表示,要从主库的 master_log_name 文件的 master_log_pos 这个位置的日志继续同步。

主备切换时,由于找不到精确的同步位点,所以只能采用直接跳过指定错误这种方法来创建从库和新主库的主备关系。

  • 通过 sql_slave_skip_counter 跳过事务
  • 通过 slave_skip_errors 忽略错误

GTID

GTID 的全称是 Global Transaction Identifier,也就是全局事务 ID,是一个事务在提交的时候生成的,是这个事务的唯一标识。GTID 由两部分组成:GTID=server_uuid:gno

  • server_uuid:是一个实例第一次启动时自动生成的,是一个全局唯一的值;
  • gno:是一个整数,初始值是 1,每次提交事务的时候分配给这个事务,并加 1。

启动 Mysql 时,加上参数 gtid_mode=on 和 enforce_gtid_consistency=on 就可以启动 GTID 模式。在 GTID 模式下,每个事务都会跟一个 GTID 一一对应。

在 GTID 模式下,备库 B 要设置为新主库 A’的从库的语法如下:

1
2
3
4
5
6
CHANGE MASTER TO
MASTER_HOST=$host_name
MASTER_PORT=$port
MASTER_USER=$user_name
MASTER_PASSWORD=$password
master_auto_position=1

找位点的工作,由 Mysql 内部完成。

读写分离有哪些坑

读写分离的主要目标就是分摊主库的压力。

还有一种架构是,在 MySQL 和客户端之间有一个中间代理层 proxy,客户端只连接 proxy, 由 proxy 根据请求类型和上下文决定请求的分发路由。

客户端直连 vs. 带 proxy 的读写分离

  • 客户端直连方案:因为少了一层 proxy 转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便。但是这种方案,由于要了解后端部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。
    • 你可能会觉得这样客户端也太麻烦了,信息大量冗余,架构很丑。其实也未必,一般采用这样的架构,一定会伴随一个负责管理后端的组件,比如 Zookeeper,尽量让业务端只专注于业务逻辑开发。
  • 带 proxy 的架构:对客户端比较友好。客户端不需要关注后端细节,连接维护、后端信息维护等工作,都是由 proxy 完成的。但这样的话,对后端维护团队的要求会更高。而且,proxy 也需要有高可用架构。因此,带 proxy 架构的整体就相对比较复杂。

解决主从延迟的方案:

  • 强制走主库方案;
  • sleep 方案;
  • 判断主备无延迟方案;
  • 配合 semi-sync 方案;
  • 等主库位点方案;
  • 等 GTID 方案。

强制走主库方案

强制走主库方案其实就是,将查询请求做分类。

  1. 对于必须要拿到最新结果的请求,强制将其发到主库上。比如,在一个交易平台上,卖家发布商品以后,马上要返回主页面,看商品是否发布成功。那么,这个请求需要拿到最新的结果,就必须走主库。
  2. 对于可以读到旧数据的请求,才将其发到从库上。在这个交易平台上,买家来逛商铺页面,就算晚几秒看到最新发布的商品,也是可以接受的。那么,这类请求就可以走从库。

Sleep 方案

主库更新后,读从库之前先 sleep 一下。具体的方案就是,类似于执行一条 select sleep(1) 命令。

判断主备无延迟方案

show slave status 结果里的 seconds_behind_master 参数的值,可以用来衡量主备延迟时间的长短。

所以第一种确保主备无延迟的方法是,每次从库执行查询请求前,先判断 seconds_behind_master 是否已经等于 0。如果还不等于 0 ,那就必须等到这个参数变为 0 才能执行查询请求。

第二种方法,对比位点确保主备无延迟:

  • Master_Log_File 和 Read_Master_Log_Pos,表示的是读到的主库的最新位点;
  • Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是备库执行的最新位点。

如果 Master_Log_File 和 Relay_Master_Log_File、Read_Master_Log_Pos 和 Exec_Master_Log_Pos 这两组值完全相同,就表示接收到的日志已经同步完成。

第三种方法,对比 GTID 集合确保主备无延迟:

  • Auto_Position=1 ,表示这对主备关系使用了 GTID 协议。
  • Retrieved_Gtid_Set,是备库收到的所有日志的 GTID 集合;
  • Executed_Gtid_Set,是备库所有已经执行完成的 GTID 集合。

如果这两个集合相同,也表示备库接收到的日志都已经同步完成。

可见,对比位点和对比 GTID 这两种方法,都要比判断 seconds_behind_master 是否为 0 更准确。

配合 semi-sync

semi-sync replication 即半同步复制。

semi-sync 做了这样的设计:

  1. 事务提交的时候,主库把 binlog 发给从库;
  2. 从库收到 binlog 以后,发回给主库一个 ack,表示收到了;
  3. 主库收到这个 ack 以后,才能给客户端返回“事务完成”的确认。

如果启用了 semi-sync,就表示所有给客户端发送过确认的事务,都确保了备库已经收到了这个日志。

semi-sync 配合判断主备无延迟的方案,存在两个问题:

  1. 一主多从的时候,在某些从库执行查询请求会存在过期读的现象;
  2. 在持续延迟的情况下,可能出现过度等待的问题。

等主库位点方案

1
select master_pos_wait(file, pos[, timeout]);

命令的逻辑如下:

  1. 它是在从库执行的;
  2. 参数 file 和 pos 指的是主库上的文件名和位置;
  3. timeout 可选,设置为正整数 N 表示这个函数最多等待 N 秒。

GTID 方案

如果你的数据库开启了 GTID 模式

1
select wait_for_executed_gtid_set(gtid_set, 1);

这条命令的逻辑是:

  1. 等待,直到这个库执行的事务中包含传入的 gtid_set,返回 0;
  2. 超时返回 1。

如何判断一个数据库是不是出问题了

select 1 判断

select 1 成功返回,只能说明这个库的进程还在,并不能说明主库没问题。

查表判断

为了能够检测 InnoDB 并发线程数过多导致的系统不可用情况,我们需要找一个访问 InnoDB 的场景。一般的做法是,在系统库(mysql 库)里创建一个表,比如命名为 health_check,里面只放一行数据,然后定期执行:

1
select * from mysql.health_check;

使用这个方法,我们可以检测出由于并发线程过多导致的数据库不可用的情况。

更新事务要写 binlog,而一旦 binlog 所在磁盘的空间占用率达到 100%,那么所有的更新语句和事务提交的 commit 语句就都会被堵住。但是,系统这时候还是可以正常读数据的。

更新判断

1
2
3
4
5
6
7
8
CREATE TABLE `health_check` (
`id` int(11) NOT NULL,
`t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

/* 检测命令 */
insert into mysql.health_check(id, t_modified) values (@@server_id, now()) on duplicate key update t_modified=now();

由于 MySQL 规定了主库和备库的 server_id 必须不同(否则创建主备关系的时候就会报错),这样就可以保证主、备库各自的检测命令不会发生冲突。

更新语句,如果失败或者超时,就可以发起主备切换了,为什么还会有判定慢的问题呢?

IO 利用率 100% 表示系统的 IO 是在工作的,每个请求都有机会获得 IO 资源,执行自己的任务。而我们的检测使用的 update 命令,需要的资源很少,所以可能在拿到 IO 资源的时候就可以提交成功,并且在超时时间 N 秒未到达之前就返回给了检测系统。

检测系统一看,update 命令没有超时,于是就得到了“系统正常”的结论。

内部统计

MySQL 5.6 版本以后提供的 performance_schema 库,就在 file_summary_by_event_name 表里统计了每次 IO 请求的时间。

打开 redo log 的时间监控

1
update setup_instruments set ENABLED='YES', Timed='YES' where name like '%wait/io/file/innodb/innodb_log_file%';

可以通过 MAX_TIMER 的值来判断数据库是否出问题了。

1
select event_name,MAX_TIMER_WAIT  FROM performance_schema.file_summary_by_event_name where event_name in ('wait/io/file/innodb/innodb_log_file','wait/io/file/sql/binlog') and MAX_TIMER_WAIT>200*1000000000;

发现异常后,取到你需要的信息,再通过下面这条语句:

1
truncate table performance_schema.file_summary_by_event_name;

把之前的统计信息清空。这样如果后面的监控中,再次出现这个异常,就可以加入监控累积值了。

答疑文章(二):用动态的观点看加锁

误删数据后除了跑路还能怎么办?

误删行

事后处理:

  • 使用 delete 语句误删了数据行,可以用 Flashback 工具通过闪回把数据恢复回来。
  • Flashback 恢复数据的原理,是修改 binlog 的内容,拿回原库重放。而能够使用这个方案的前提是,需要确保 binlog_format=row 和 binlog_row_image=FULL。

事前预防:

  • 把 sql_safe_updates 参数设置为 on。这样一来,如果我们忘记在 delete 或者 update 语句中写 where 条件,或者 where 条件里面没有包含索引字段的话,这条语句的执行就会报错。
  • 代码上线前,必须经过 SQL 审计。

误删库 / 表

这种情况下,要想恢复数据,就需要使用全量备份,加增量日志的方式了。这个方案要求线上有定期的全量备份,并且实时备份 binlog。

恢复数据的流程如下:

  1. 取最近一次全量备份,假设这个库是一天一备,上次备份是当天 0 点;
  2. 用备份恢复出一个临时库;
  3. 从日志备份里面,取出凌晨 0 点之后的日志;
  4. 把这些日志,除了误删除数据的语句外,全部应用到临时库。

延迟复制备库

一般的主备复制结构存在的问题是,如果主库上有个表被误删了,这个命令很快也会被发给所有从库,进而导致所有从库的数据表也都一起被误删了。

延迟复制的备库是一种特殊的备库,通过 CHANGE MASTER TO MASTER_DELAY = N 命令,可以指定这个备库持续保持跟主库有 N 秒的延迟。只要在延迟时间内发现了这个误操作命令,这个命令就还没有在这个延迟复制的备库执行。这时候到这个备库上执行 stop slave,再通过之前介绍的方法,跳过误操作命令,就可以恢复出需要的数据。

预防误删库 / 表的方法

第一条建议是,账号分离。这样做的目的是,避免写错命令。比如:

  • 我们只给业务开发同学 DML 权限,而不给 truncate/drop 权限。而如果业务开发人员有 DDL 需求的话,也可以通过开发管理系统得到支持。
  • 即使是 DBA 团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号。

第二条建议是,制定操作规范。这样做的目的,是避免写错要删除的表名。比如:

  • 在删除数据表之前,必须先对表做改名操作。然后,观察一段时间,确保对业务无影响以后再删除这张表。
  • 改表名的时候,要求给表名加固定的后缀(比如加 _to_be_deleted),然后删除表的动作必须通过管理系统执行。并且,管理系删除表的时候,只能删除固定后缀的表。

rm 删除数据

对于一个有高可用机制的 MySQL 集群来说,最不怕的就是 rm 删除数据了。只要不是恶意地把整个集群删除,而只是删掉了其中某一个节点的数据的话,HA 系统就会开始工作,选出一个新的主库,从而保证整个集群的正常工作。

这时,你要做的就是在这个节点上把数据恢复回来,再接入整个集群。

为什么还有 kill 不掉的语句

MySQL 中有两个 kill 命令:一个是 kill query + 线程 id,表示终止这个线程中正在执行的语句;一个是 kill connection + 线程 id,这里 connection 可缺省,表示断开这个线程的连接,当然如果这个线程有语句正在执行,也是要先停止正在执行的语句的。

收到 kill 以后,线程做什么?

当用户执行 kill query thread_id_B 时,MySQL 里处理 kill 命令的线程做了两件事:

  1. 把 session B 的运行状态改成 THD::KILL_QUERY(将变量 killed 赋值为 THD::KILL_QUERY);
  2. 给 session B 的执行线程发一个信号。

kill 不掉语句的情况

  • 线程没有执行到判断线程状态的逻辑
  • 终止逻辑耗时较长
    • 超大事务执行期间被 kill。这时候,回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长。
    • 大查询回滚。如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删除临时文件可能需要等待 IO 资源,导致耗时较长。
    • DDL 命令执行到最后阶段,如果被 kill,需要删除中间过程的临时文件,也可能受 IO 资源影响耗时较久。

关于客户端的误解

如果库里面的表特别多,连接就会很慢。

客户端在连接成功后,需要多做一些操作:

  1. 执行 show databases;
  2. 切到 db1 库,执行 show tables;
  3. 把这两个命令的结果用于构建一个本地的哈希表。

我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢。

我查了这么多数据会不会把数据库内存打爆

全表扫描对 server 层的影响

MySQL 是“边读边发的”

InnoDB 的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表 t 的主键索引。这条查询语句由于没有其他的判断条件,所以查到的每一行都可以直接放到结果集里面,然后返回给客户端。

查询的结果是分段发给客户端的,因此扫描全表,查询返回大量的数据,并不会把内存打爆。

全表扫描对 InnoDB 的影响

对于 InnoDB 引擎内部,由于有淘汰策略,大查询也不会导致内存暴涨。并且,由于 InnoDB 对 LRU 算法做了改进,冷数据的全表扫描,对 Buffer Pool 的影响也能做到可控。

到底可不可以使用 join

  1. 如果可以使用被驱动表的索引,join 语句还是有其优势的;
  2. 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用;
  3. 在使用 join 的时候,应该让小表做驱动表。

join 语句如何优化

大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。

MRR

MRR 优化后的语句执行流程:

  1. 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 ;
  2. 将 read_rnd_buffer 中的 id 进行递增排序;
  3. 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。

这里,read_rnd_buffer 的大小是由 read_rnd_buffer_size 参数控制的。如果步骤 1 中,read_rnd_buffer 放满了,就会先执行完步骤 2 和 3,然后清空 read_rnd_buffer。之后继续找索引 a 的下个记录,并继续循环。

MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键 id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。临时表在使用上有以下几个特点:

  1. 建表语法是 create temporary table …。
  2. 一个临时表只能被创建它的 session 访问,对其他线程不可见。所以,图中 session A 创建的临时表 t,对于 session B 就是不可见的。
  3. 临时表可以与普通表同名。
  4. session A 内有同名的临时表和普通表的时候,show create 语句,以及增删改查语句访问的是临时表。
  5. show tables 命令不显示临时表。

为什么临时表可以重名

临时表在使用上有以下几个特点:

  1. 建表语法是 create temporary table …。
  2. 一个临时表只能被创建它的 session 访问,对其他线程不可见。所以,图中 session A 创建的临时表 t,对于 session B 就是不可见的。
  3. 临时表可以与普通表同名。
  4. session A 内有同名的临时表和普通表的时候,show create 语句,以及增删改查语句访问的是临时表。
  5. show tables 命令不显示临时表。

临时表特别适合 join 优化这种场景,原因是:

  1. 不同 session 的临时表是可以重名的,如果有多个 session 同时执行 join 优化,不需要担心表名重复导致建表失败的问题。
  2. 不需要担心数据删除问题。如果使用普通表,在流程执行过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门来清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作。

临时表的应用

由于不用担心线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中。其中,分库分表系统的跨库查询就是一个典型的使用场景。

分库分表两种实现思路:

第一种思路是,在 proxy 层的进程代码中实现排序。

这种方式的优势是处理速度快,拿到分库的数据以后,直接在内存中参与计算。不过,这个方案的缺点也比较明显:

  1. 需要的开发工作量比较大。我们举例的这条语句还算是比较简单的,如果涉及到复杂的操作,比如 group by,甚至 join 这样的操作,对中间层的开发能力要求比较高;
  2. 对 proxy 端的压力比较大,尤其是很容易出现内存不够用和 CPU 瓶颈的问题。

另一种思路就是,把各个分库拿到的数据,汇总到一个 MySQL 实例的一个表中,然后在这个汇总实例上做逻辑操作。

比如上面这条语句,执行流程可以类似这样:

  • 在汇总库上创建一个临时表 temp_ht,表里包含三个字段 v、k、t_modified;
  • 在各个分库上执行
1
select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100;
  • 把分库执行的结果插入到 temp_ht 表中;
  • 执行
1
select v from temp_ht order by t_modified desc limit 100;

得到结果。

在实践中,我们往往会发现每个分库的计算量都不饱和,所以会直接把临时表 temp_ht 放到 32 个分库中的某一个上。

什么时候会使用内部临时表

  1. 如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null;
  2. 尽量让 group by 过程用上表的索引,确认方法是 explain 结果里没有 Using temporary 和 Using filesort;
  3. 如果 group by 需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大 tmp_table_size 参数,来避免用到磁盘临时表;
  4. 如果数据量实在太大,使用 SQL_BIG_RESULT 这个提示,来告诉优化器直接使用排序算法得到 group by 的结果。

都说 InnoDB 好,那还要不要使用 Memory 引擎

InnoDB 和 Memory 引擎的数据组织方式是不同的:

  • InnoDB 引擎把数据放在主键索引上,其他索引上保存的是主键 id。这种方式,我们称之为索引组织表(Index Organizied Table)。
  • 而 Memory 引擎采用的是把数据单独存放,索引上保存数据位置的数据组织形式,我们称之为堆组织表(Heap Organizied Table)。

内存表不支持行锁,只支持表锁。

数据放在内存中,是内存表的优势,但也是一个劣势。因为,数据库重启的时候,所有的内存表都会被清空。

自增主键为什么不是连续的

表的结构定义存放在后缀名为.frm 的文件中,但是并不会保存自增值。

在 MyISAM 引擎里面,自增值是被写在数据文件上的。而在 InnoDB 中,自增值是被记录在内存的。

InnoDB 中,只保证了自增 id 是递增的,但不保证是连续的。这么做是处于性能考虑:语句执行失败也不回退自增 id。

insert 语句的锁为什么这么多

insert … select 是很常见的在两个表之间拷贝数据的方法。你需要注意,在可重复读隔离级别下,这个语句会给 select 的表里扫描到的记录和间隙加读锁。

而如果 insert 和 select 的对象是同一个表,则有可能会造成循环写入。这种情况下,我们需要引入用户临时表来做优化。

insert 语句如果出现唯一键冲突,会在冲突的唯一值上加共享的 next-key lock(S 锁)。因此,碰到由于唯一键约束导致报错后,要尽快提交或回滚事务,避免加锁时间过长。

怎么最快地复制一张表

mysqldump 方法

使用 mysqldump 命令将数据导出成一组 INSERT 语句。

1
mysqldump -h$host -P$port -u$user --add-locks=0 --no-create-info --single-transaction  --set-gtid-purged=OFF db1 t --where="a>900" --result-file=/client_tmp/t.sql

这条命令中,主要参数含义如下:

  1. –single-transaction 的作用是,在导出数据的时候不需要对表 db1.t 加表锁,而是使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 的方法;
  2. –add-locks 设置为 0,表示在输出的文件结果里,不增加” LOCK TABLES t WRITE;” ;
  3. –no-create-info 的意思是,不需要导出表结构;
  4. –set-gtid-purged=off 表示的是,不输出跟 GTID 相关的信息;
  5. –result-file 指定了输出文件的路径,其中 client 表示生成的文件是在客户端机器上的。

导出 CSV 文件

另一种方法是直接将结果导出成.csv 文件

1
select * from db1.t where a>900 into outfile '/server_tmp/t.csv';

物理拷贝方法

假设我们现在的目标是在 db1 库下,复制一个跟表 t 相同的表 r,具体的执行步骤如下:

  1. 执行 create table r like t,创建一个相同表结构的空表;
  2. 执行 alter table r discard tablespace,这时候 r.ibd 文件会被删除;
  3. 执行 flush table t for export,这时候 db1 目录下会生成一个 t.cfg 文件;
  4. 在 db1 目录下执行 cp t.cfg r.cfg; cp t.ibd r.ibd;这两个命令(这里需要注意的是,拷贝得到的两个文件,MySQL 进程要有读写权限);
  5. 执行 unlock tables,这时候 t.cfg 文件会被删除;
  6. 执行 alter table r import tablespace,将这个 r.ibd 文件作为表 r 的新的表空间,由于这个文件的数据内容和 t.ibd 是相同的,所以表 r 中就有了和表 t 相同的数据。

grant 之后为什么要跟着 flush privilege

grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据。因此,规范地使用 grant 和 revoke 语句,是不需要随后加上 flush privileges 语句的。

flush privileges 语句本身会用数据表的数据重建一份内存权限数据,所以在权限数据可能存在不一致的情况下再使用。而这种不一致往往是由于直接用 DML 语句操作系统权限表导致的,所以我们尽量不要使用这类语句。

要不要使用分区表

自增 ID 用完了怎么办

参考资料

《极客时间教程 - SQL 必知必会》笔记

01 丨了解 SQL:一门半衰期很长的语言

SQL 语言按照功能划分成以下的 4 个部分:

  • DDL 是 Data Definition Language 的缩写,即数据定义语言,它用来定义我们的数据库对象,包括数据库、数据表和列。通过使用 DDL,我们可以创建,删除和修改数据库和表结构。
  • DML 是 Data Manipulation Language 的缩写,即数据操作语言,我们用它操作和数据库相关的记录,比如增加、删除、修改数据表中的记录。
  • DCL 是 Data Control Language 的缩写,即数据控制语言,我们用它来定义访问权限和安全级别。
  • DQL 是 Data Query Language 的缩写,即数据查询语言,我们用它查询想要的记录,它是 SQL 语言的重中之重。在实际的业务中,我们绝大多数情况下都是在和查询打交道,因此学会编写正确且高效的查询语句,是学习的重点。

02 丨 DBMS 的前世今生

DB、DBS 和 DBMS 的区别:

  • DBMS 的英文全称是 DataBase Management System,数据库管理系统,实际上它可以对多个数据库进行管理,所以你可以理解为 DBMS = 多个数据库(DB) + 管理程序。
  • DB 的英文是 DataBase,也就是数据库。数据库是存储数据的集合,你可以把它理解为多个数据表。
  • DBS 的英文是 DataBase System,数据库系统。它是更大的概念,包括了数据库、数据库管理系统以及数据库管理人员 DBA。

NoSql 不同时期的释义

  • 1970:NoSQL = We have no SQL
  • 1980:NoSQL = Know SQL
  • 2000:NoSQL = No SQL!
  • 2005:NoSQL = Not only SQL
  • 2013:NoSQL = No, SQL!

03 丨学会用数据库的方式思考 SQL 是如何执行的

Oracle 中的 SQL 是如何执行的

  1. 语法检查:检查 SQL 拼写是否正确,如果不正确,Oracle 会报语法错误。
  2. 语义检查:检查 SQL 中的访问对象是否存在。比如我们在写 SELECT 语句的时候,列名写错了,系统就会提示错误。语法检查和语义检查的作用是保证 SQL 语句没有错误。
  3. 权限检查:看用户是否具备访问该数据的权限。
  4. 共享池检查:共享池(Shared Pool)是一块内存池,最主要的作用是缓存 SQL 语句和该语句的执行计划。Oracle 通过检查共享池是否存在 SQL 语句的执行计划,来判断进行软解析,还是硬解析。那软解析和硬解析又该怎么理解呢?
    • 在共享池中,Oracle 首先对 SQL 语句进行 Hash 运算,然后根据 Hash 值在库缓存(Library Cache)中查找,如果存在 SQL 语句的执行计划,就直接拿来执行,直接进入“执行器”的环节,这就是软解析
    • 如果没有找到 SQL 语句和执行计划,Oracle 就需要创建解析树进行解析,生成执行计划,进入“优化器”这个步骤,这就是硬解析
  5. 优化器:优化器中就是要进行硬解析,也就是决定怎么做,比如创建解析树,生成执行计划。
  6. 执行器:当有了解析树和执行计划之后,就知道了 SQL 该怎么被执行,这样就可以在执行器中执行语句了。

共享池是 Oracle 中的术语,包括了库缓存,数据字典缓冲区等。它主要缓存 SQL 语句和执行计划。而数据字典缓冲区存储的是 Oracle 中的对象定义,比如表、视图、索引等对象。当对 SQL 语句进行解析的时候,如果需要相关的数据,会从数据字典缓冲区中提取。

MySQL 中的 SQL 是如何执行的

MySQL 是典型的 C/S 架构,即 Client/Server 架构,服务器端程序使用的 mysqld。

Mysql 可分为三层:

  1. 连接层:客户端和服务器端建立连接,客户端发送 SQL 至服务器端;
  2. SQL 层:对 SQL 语句进行查询处理;
  3. 存储引擎层:与数据库文件打交道,负责数据的存储和读取。

SQL 层的结构

  1. 查询缓存:Server 如果在查询缓存中发现了这条 SQL 语句,就会直接将结果返回给客户端;如果没有,就进入到解析器阶段。需要说明的是,因为查询缓存往往效率不高,所以在 MySQL8.0 之后就抛弃了这个功能。
  2. 解析器:在解析器中对 SQL 语句进行语法分析、语义分析。
  3. 优化器:在优化器中会确定 SQL 语句的执行路径,比如是根据全表检索,还是根据索引来检索等。
  4. 执行器:在执行之前需要判断该用户是否具备权限,如果具备权限就执行 SQL 查询并返回结果。在 MySQL8.0 以下的版本,如果设置了查询缓存,这时会将查询结果进行缓存。

与 Oracle 不同的是,MySQL 的存储引擎采用了插件的形式,每个存储引擎都面向一种特定的数据库应用环境。同时开源的 MySQL 还允许开发人员设置自己的存储引擎,下面是一些常见的存储引擎:

  1. InnoDB 存储引擎:它是 MySQL 5.5 版本之后默认的存储引擎,最大的特点是支持事务、行级锁定、外键约束等。
  2. MyISAM 存储引擎:在 MySQL 5.5 版本之前是默认的存储引擎,不支持事务,也不支持外键,最大的特点是速度快,占用资源少。
  3. Memory 存储引擎:使用系统内存作为存储介质,以便得到更快的响应速度。不过如果 mysqld 进程崩溃,则会导致所有的数据丢失,因此我们只有当数据是临时的情况下才使用 Memory 存储引擎。
  4. NDB 存储引擎:也叫做 NDB Cluster 存储引擎,主要用于 MySQL Cluster 分布式集群环境,类似于 Oracle 的 RAC 集群。
  5. Archive 存储引擎:它有很好的压缩机制,用于文件归档,在请求写入时会进行压缩,所以也经常用来做仓库。

04 丨使用 DDL 创建数据库&数据表时需要注意什么?

DDL 的核心指令是 CREATEALTERDROP

执行 DDL 的时候,不需要 COMMIT,就可以完成执行任务。

设计数据表的原则

  • 数据表的个数越少越好 - RDBMS 的核心在于对实体和联系的定义,也就是 E-R 图(Entity Relationship Diagram),数据表越少,证明实体和联系设计得越简洁,既方便理解又方便操作。
  • 数据表中的字段个数越少越好 - 字段个数越多,数据冗余的可能性越大。设置字段个数少的前提是各个字段相互独立,而不是某个字段的取值可以由其他字段计算出来。当然字段个数少是相对的,我们通常会在数据冗余和检索效率中进行平衡。
  • 数据表中联合主键的字段个数越少越好 - 设置主键是为了确定唯一性,当一个字段无法确定唯一性的时候,就需要采用联合主键的方式(也就是用多个字段来定义一个主键)。联合主键中的字段越多,占用的索引空间越大,不仅会加大理解难度,还会增加运行时间和索引空间,因此联合主键的字段个数越少越好。
  • 使用主键和外键越多越好 - 数据库的设计实际上就是定义各种表,以及各种字段之间的关系。这些关系越多,证明这些实体之间的冗余度越低,利用度越高。这样做的好处在于不仅保证了数据表之间的独立性,还能提升相互之间的关联使用率。——不同意

05 丨检索数据:你还在 SELECT 么?

SELECT 的作用是从一个表或多个表中检索出想要的数据行。

  • SELECT 语句用于从数据库中查询数据。
  • DISTINCT 用于返回唯一不同的值。它作用于所有列,也就是说所有列的值都相同才算相同。
  • LIMIT 限制返回的行数。可以有两个参数,第一个参数为起始行,从 0 开始;第二个参数为返回的总行数。
    • ASC :升序(默认)
    • DESC :降序

SELECT 查询的基础语法

查询单列

1
SELECT name FROM world.country;

查询多列

1
SELECT name, continent, region FROM world.country;

查询所有列

1
SELECT * FROM world.country;

查询过滤重复值

1
SELECT distinct(continent) FROM world.country;

限制查询数量

1
2
3
4
5
-- 返回前 5 行
SELECT * FROM world.country LIMIT 5;
SELECT * FROM world.country LIMIT 0, 5;
-- 返回第 3 ~ 5 行
SELECT * FROM world.country LIMIT 2, 3;

SELECT 的执行顺序

关键字的顺序是不能颠倒的:

1
SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ...

SELECT 语句的执行顺序(在 MySQL 和 Oracle 中,SELECT 执行顺序基本相同):

1
FROM > WHERE > GROUP BY > HAVING > SELECT 的字段 > DISTINCT > ORDER BY > LIMIT

比如你写了一个 SQL 语句,那么它的关键字顺序和执行顺序是下面这样的:

1
2
3
4
5
6
7
SELECT DISTINCT player_id, player_name, count(*) as num -- 顺序 5
FROM player JOIN team ON player.team_id = team.team_id -- 顺序 1
WHERE height > 1.80 -- 顺序 2
GROUP BY player.team_id -- 顺序 3
HAVING num > 2 -- 顺序 4
ORDER BY num DESC -- 顺序 6
LIMIT 2 -- 顺序 7

06 丨数据过滤:SQL 数据过滤都有哪些方法?

比较操作符

运算符 描述
= 等于
<> 不等于。注释:在 SQL 的一些版本中,该操作符可被写成 !=
> 大于
< 小于
>= 大于等于
<= 小于等于

范围操作符

运算符 描述
BETWEEN 在某个范围内
IN 指定针对某个列的多个可能值

逻辑操作符

运算符 描述
AND 并且(与)
OR 或者(或)
NOT 否定(非)

通配符

运算符 描述
LIKE 搜索某种模式
% 表示任意字符出现任意次数
_ 表示任意字符出现一次
[] 必须匹配指定位置的一个字符

07 丨什么是 SQL 函数?为什么使用 SQL 函数可能会带来问题?

  • 数学函数
  • 字符串函数
  • 日期函数
  • 转换函数
  • 聚合函数

08 丨什么是 SQL 的聚集函数,如何利用它们汇总表的数据?

聚合函数

函 数 说 明
AVG() 返回某列的平均值
COUNT() 返回某列的行数
MAX() 返回某列的最大值
MIN() 返回某列的最小值
SUM() 返回某列值之和

09 丨子查询:子查询的种类都有哪些,如何提高子查询的性能?

子查询可以分为关联子查询和非关联子查询。

子查询从数据表中查询了数据结果,如果这个数据结果只执行一次,然后这个数据结果作为主查询的条件进行执行,那么这样的子查询叫做非关联子查询。

如果子查询需要执行多次,即采用循环的方式,先从外部查询开始,每次都传入子查询进行查询,然后再将结果反馈给外部,这种嵌套的执行方式就称为关联子查询。

子查询关键词:EXISTS、IN、ANY、ALL、SOME

如果表 A 比表 B 大,那么 IN 子查询的效率要比 EXIST 子查询效率高,因为这时 B 表中如果对 cc 列进行了索引,那么 IN 子查询的效率就会比较高。

ANY 和 ALL 都需要使用比较符,比较符包括了(>)(=)(<)(>=)(<=)和(<>)等。

子查询可以作为主查询的列

10 丨常用的 SQL 标准有哪些,在 SQL92 中是如何使用连接的?

内连接(INNER JOIN)

自连接(=

自然连接(NATURAL JOIN)

外连接(OUTER JOIN)

左连接(LEFT JOIN)

右连接(RIGHT JOIN)

11 丨 SQL99 是如何使用连接的,与 SQL92 的区别是什么?

12 丨视图在 SQL 中的作用是什么,它是怎样工作的?

视图是基于 SQL 语句的结果集的可视化的表。视图是虚拟的表,本身不存储数据,也就不能对其进行索引操作。对视图的操作和对普通表的操作一样。

视图的作用:

  • 简化复杂的 SQL 操作,比如复杂的连接。
  • 只使用实际表的一部分数据。
  • 通过只给用户访问视图的权限,保证数据的安全性。
  • 更改数据格式和表示。

13 丨什么是存储过程,在实际项目中用得多么?

存储过程的英文是 Stored Procedure。它可以视为一组 SQL 语句的批处理。一旦存储过程被创建出来,使用它就像使用函数一样简单,我们直接通过调用存储过程名即可。

存储过程的优点:

  • 执行效率高:一次编译多次使用。
  • 安全性强:在设定存储过程的时候可以设置对用户的使用权限,这样就和视图一样具有较强的安全性。
  • 可复用:将代码封装,可以提高代码复用。
  • 性能好
    • 由于是预先编译,因此具有很高的性能。
    • 一个存储过程替代大量 T_SQL 语句 ,可以降低网络通信量,提高通信速率。

存储过程的缺点:

  • 可移植性差:存储过程不能跨数据库移植。由于不同数据库的存储过程语法几乎都不一样,十分难以维护(不通用)。
  • 调试困难:只有少数 DBMS 支持存储过程的调试。对于复杂的存储过程来说,开发和维护都不容易。
  • 版本管理困难:比如数据表索引发生变化了,可能会导致存储过程失效。我们在开发软件的时候往往需要进行版本管理,但是存储过程本身没有版本控制,版本迭代更新的时候很麻烦。
  • 不适合高并发的场景:高并发的场景需要减少数据库的压力,有时数据库会采用分库分表的方式,而且对可扩展性要求很高,在这种情况下,存储过程会变得难以维护,增加数据库的压力,显然就不适用了。

_综上,存储过程的优缺点都非常突出,是否使用一定要慎重,需要根据具体应用场景来权衡_。

14 丨什么是事务处理,如何使用 COMMIT 和 ROLLBACK 进行操作?

ACID:

  1. A,也就是原子性(Atomicity)。原子的概念就是不可分割,你可以把它理解为组成物质的基本单位,也是我们进行数据处理操作的基本单位。
  2. C,就是一致性(Consistency)。一致性指的就是数据库在进行事务操作后,会由原来的一致状态,变成另一种一致的状态。也就是说当事务提交后,或者当事务发生回滚后,数据库的完整性约束不能被破坏。
  3. I,就是隔离性(Isolation)。它指的是每个事务都是彼此独立的,不会受到其他事务的执行影响。也就是说一个事务在提交之前,对其他事务都是不可见的。
  4. 最后一个 D,指的是持久性(Durability)。事务提交之后对数据的修改是持久性的,即使在系统出故障的情况下,比如系统崩溃或者存储介质发生故障,数据的修改依然是有效的。因为当事务完成,数据库的日志就会被更新,这时可以通过日志,让系统恢复到最后一次成功的更新状态。

事务的控制语句:

  1. START TRANSACTION 或者 BEGIN,作用是显式开启一个事务。
  2. COMMIT:提交事务。当提交事务后,对数据库的修改是永久性的。
  3. ROLLBACK 或者 ROLLBACK TO [SAVEPOINT],意为回滚事务。意思是撤销正在进行的所有没有提交的修改,或者将事务回滚到某个保存点。
  4. SAVEPOINT:在事务中创建保存点,方便后续针对保存点进行回滚。一个事务中可以存在多个保存点。
  5. RELEASE SAVEPOINT:删除某个保存点。
  6. SET TRANSACTION,设置事务的隔离级别。

15 丨初识事务隔离:隔离的级别有哪些,它们都解决了哪些异常问题?

事务隔离级别从低到高分别是:读未提交(READ UNCOMMITTED )、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)和可串行化(SERIALIZABLE)。

16 丨游标:当我们需要逐条处理数据时,该怎么做?

17 丨如何使用 Python 操作 MySQL?

18 丨 SQLAlchemy:如何使用 PythonORM 框架来操作 MySQL?

19 丨基础篇总结:如何理解查询优化、通配符以及存储过程?

20 丨当我们思考数据库调优的时候,都有哪些维度可以选择?

我的理解:

  • 选择合适数据库
  • 配置优化
  • 硬件优化
  • 优化表设计
  • 优化查询
  • 使用缓存
  • 读写分离+分库分表

21 丨范式设计:数据表的范式有哪些,3NF 指的是什么?

范式定义:

  • 1NF:指的是数据库表中的任何属性都是原子性的,不可再分。
  • 2NF:指的数据表里的非主属性都要和这个数据表的候选键有完全依赖关系。
  • 3NF:在满足 2NF 的同时,对任何非主属性都不传递依赖于候选键。
  • BCNF:在 3NF 的基础上消除了主属性对候选键的部分依赖或者传递依赖关系。

范式化的目标是尽力减少冗余列,节省空间

22 丨反范式设计:3NF 有什么不足,为什么有时候需要反范式设计?

反范式化的目标是适当增加冗余列,以避免关联查询

范式化优点

  • 更节省空间
  • 更新操作更快
  • 更少需要 DISTINCTGROUP BY 语句

范式化缺点

  • 增加了关联查询,而关联查询代价很高

23 丨索引的概览:用还是不用索引,这是一个问题

索引的优缺点

索引的优点

  • 大大减少了服务器需要扫描的数据量
  • 可以帮助服务器避免排序和临时表
  • 可以将随机 I/O 变为顺序 I/O

索引的缺点

  • 创建和维护索引要耗费时间,这会随着数据量的增加而增加。
  • 占用额外物理空间
  • 写操作时很可能需要更新索引,导致数据库的写操作性能降低

索引的适用场景

适用场景

  • 频繁读操作(SELECT)
  • 表的数据量比较大
  • 列名经常出现在 WHERE 或连接(JOIN)条件中

不适用场景

  • 频繁写操作(INSERT/UPDATE/DELETE)
  • 列名不经常出现在 WHERE 或连接(JOIN)条件中
  • 索引会经常无法命中,没有意义
  • 非常小的表(比如不到 1000 行):简单的全表扫描更高效
  • 特大型的表:索引的代价很高昂,可以用分区或 Nosql

24 丨索引的原理:我们为什么用 B+树来做索引?

磁盘的 I/O 操作次数对索引的使用效率至关重要。虽然传统的二叉树数据结构查找数据的效率高,但很容易增加磁盘 I/O 操作的次数,影响索引使用的效率。因此在构造索引的时候,我们更倾向于采用“矮胖”的数据结构。

B 树和 B+ 树都可以作为索引的数据结构,在 MySQL 中采用的是 B+ 树,B+ 树在查询性能上更稳定,在磁盘页大小相同的情况下,树的构造更加矮胖,所需要进行的磁盘 I/O 次数更少,更适合进行关键字的范围查询。

25 丨 Hash 索引的底层原理是什么?

Mysql 中,只有 Memory 存储引擎显示支持哈希索引。

✔️️️️️ 哈希索引的优点

  • 因为索引数据结构紧凑,所以查询速度非常快

❌ 哈希索引的缺点

  • 只支持等值比较查询 - 包括 =IN()<=>
    • 不支持范围查询,如 WHERE price > 100
    • 不支持模糊查询,如 % 开头。
  • 无法用于排序 - 因为 Hash 索引指向的数据是无序的,因此无法起到排序优化的作用。
  • 不支持联合索引的最左侧原则 - 对于联合索引来说,Hash 索引在计算 Hash 值的时候是将索引键合并后再一起计算 Hash 值,所以不会针对每个索引单独计算 Hash 值。因此如果用到联合索引的一个或者几个索引时,联合索引无法被利用。例如:在数据列 (A,B) 上建立哈希索引,如果查询只有数据列 A,无法使用该索引。
  • 不能用索引中的值来避免读取行 - 因为哈希索引只包含哈希值和行指针,不存储字段,所以不能使用索引中的值来避免读取行。不过,访问内存中的行的速度很快,所以大部分情况下这一点对性能影响不大。
  • 哈希索引有可能出现哈希冲突
    • 出现哈希冲突时,必须遍历链表中所有的行指针,逐行比较,直到找到符合条件的行。
    • 如果哈希冲突多的话,维护索引的代价会很高。

提示:因为种种限制,所以哈希索引只适用于特定的场合。而一旦使用哈希索引,则它带来的性能提升会非常显著。

26 丨索引的使用原则:如何通过索引让 SQL 查询效率最大化?

✔️️️️ 什么情况适用索引?

  • 字段的数值有唯一性的限制,如用户名。
  • 频繁作为 WHERE 条件或 JOIN 条件的字段,尤其在数据表大的情况下
  • 频繁用于 GROUP BYORDER BY 的字段。将该字段作为索引,查询时就无需再排序了,因为 B+ 树
  • DISTINCT 字段需要创建索引

❌ 什么情况不适用索引?

  • 频繁写操作INSERT/UPDATE/DELETE ),也就意味着需要更新索引。
  • 列名不经常出现在 WHERE 或连接(JOIN)条件中,也就意味着索引会经常无法命中,没有意义,还增加空间开销。
  • 非常小的表,对于非常小的表,大部分情况下简单的全表扫描更高效。
  • 特大型的表,建立和使用索引的代价将随之增长。可以考虑使用分区技术或 Nosql。

索引失效的场景:

  • 对索引使用左模糊匹配
  • 对索引使用表达式或函数
  • 对索引隐式类型转换
  • 联合索引不遵循最左匹配原则
  • 索引列判空
  • WHERE 子句中的 OR 前后条件存在非索引列

27 丨从数据页的角度理解 B+树查询

在数据库中,不论读一行,还是读多行,都是将这些行所在的页进行加载。也就是说,数据库管理存储空间的基本单位是页(Page)。

一个表空间包括了一个或多个段,一个段包括了一个或多个区,一个区包括了多个页,而一个页中可以有多行记录:

  • 页是数据库存储的最小单位。

  • 区(Extent)是比页大一级的存储结构,在 InnoDB 存储引擎中,一个区会分配 64 个连续的页。因为 InnoDB 中的页大小默认是 16KB,所以一个区的大小是 64*16KB=1MB。

  • 段(Segment)由一个或多个区组成,区在文件系统是一个连续分配的空间(在 InnoDB 中是连续的 64 个页),不过在段中不要求区与区之间是相邻的。段是数据库中的分配单位,不同类型的数据库对象以不同的段形式存在。当我们创建数据表、索引的时候,就会相应创建对应的段,比如创建一张表时会创建一个表段,创建一个索引时会创建一个索引段。

  • 表空间(Tablespace)是一个逻辑容器,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一个表空间。数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间、用户表空间、撤销表空间、临时表空间等。

28 丨从磁盘 I/O 的角度理解 SQL 查询的成本

磁盘 I/O 耗时远大于内存,因此数据库会采用缓冲池的方式提升页的查找效率。

SQL 查询是一个动态的过程,从页加载的角度来看:

  1. 位置决定效率。如果页就在数据库缓冲池中,那么效率是最高的,否则还需要从内存或者磁盘中进行读取,当然针对单个页的读取来说,如果页存在于内存中,会比在磁盘中读取效率高很多。
  2. 批量决定效率。如果我们从磁盘中对单一页进行随机读,那么效率是很低的(差不多 10ms),而采用顺序读取的方式,批量对页进行读取,平均一页的读取效率就会提升很多,甚至要快于单个页面在内存中的随机读取。

29 丨为什么没有理想的索引?

30 丨锁:悲观锁和乐观锁是什么?

基于加锁方式分类,Mysql 可以分为悲观锁和乐观锁。

  • 悲观锁 - 假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作
    • 在查询完数据的时候就把事务锁起来,直到提交事务(COMMIT
    • 实现方式:使用数据库中的锁机制
  • 乐观锁 - 假设最好的情况——每次访问数据时,都假设数据不会被其他线程修改,不必加锁。只在更新的时候,判断一下在此期间是否有其他线程更新该数据。
    • 实现方式:更新数据时,先使用版本号机制或 CAS 算法检查数据是否被修改

31 丨为什么大部分 RDBMS 都会支持 MVCC?

MVCC 的核心就是 Undo Log+ Read View

  • Undo Log 保存数据的历史版本,实现多版本的管理;
  • 通过 Read View 原则来决定数据是否显示;
  • 时针对不同的隔离级别,Read View 的生成策略不同,也就实现了不同的隔离级别

32 丨查询优化器是如何工作的?

MySQL 整个查询执行过程,总的来说分为 6 个步骤,分别对应 6 个组件:

  1. 连接器 - 客户端和 MySQL 服务器建立连接;连接器负责跟客户端建立连接、获取权限、维持和管理连接。
  2. 查询缓存 - MySQL 服务器首先检查查询缓存,如果命中缓存,则立刻返回结果。否则进入下一阶段。
  3. 分析器 - MySQL 服务器进行 SQL 分析:语法分析、词法分析。
  4. 优化器 - MySQL 服务器用优化器生成对应的执行计划。
  5. 执行器 - MySQL 服务器根据执行计划,调用存储引擎的 API 来执行查询。
  6. 返回结果 - MySQL 服务器将结果返回给客户端,同时缓存查询结果。

33 丨如何使用性能分析工具定位 SQL 执行慢的原因?

34 丨答疑篇:关于索引以及缓冲池的一些解惑

35 丨数据库主从同步的作用是什么,如何解决数据不一致问题?

Mysql 支持两种复制:基于行的复制和基于语句的复制。

这两种方式都是在主库上记录二进制日志,然后在从库重放日志的方式来实现异步的数据复制。这意味着:复制过程存在时延,这段时间内,主从数据可能不一致。

主要涉及三个线程:binlog 线程、I/O 线程和 SQL 线程。

  • binlog 线程 :负责将主服务器上的数据更改写入二进制文件(binlog)中。
  • I/O 线程 :负责从主服务器上读取二进制日志文件,并写入从服务器的中继日志中。
  • SQL 线程 :负责读取中继日志并重放其中的 SQL 语句。

如何解决主从同步时的数据一致性问题?

异步复制

异步模式就是客户端提交 COMMIT 之后不需要等从库返回任何结果,而是直接将结果返回给客户端,这样做的好处是不会影响主库写的效率,但可能会存在主库宕机,而 Binlog 还没有同步到从库的情况,也就是此时的主库和从库数据不一致。这时候从从库中选择一个作为新主,那么新主则可能缺少原来主服务器中已提交的事务。所以,这种复制模式下的数据一致性是最弱的。

半异步复制

原理是在客户端提交 COMMIT 之后不直接将结果返回给客户端,而是等待至少有一个从库接收到了 Binlog,并且写入到中继日志中,再返回给客户端。这样做的好处就是提高了数据的一致性,当然相比于异步复制来说,至少多增加了一个网络连接的延迟,降低了主库写的效率。——其实是一种两阶段提交的思想。

组复制

这种复制技术是基于 Paxos 的状态机复制。

将多个节点共同组成一个复制组,在执行读写(RW)事务的时候,需要通过一致性协议层(Consensus 层)的同意,也就是读写事务想要进行提交,必须要经过组里“大多数人”(对应 Node 节点)的同意,大多数指的是同意的节点数量需要大于(N/2+1),这样才可以进行提交,而不是原发起方一个说了算。而针对只读(RO)事务则不需要经过组内同意,直接 COMMIT 即可。

在一个复制组内有多个节点组成,它们各自维护了自己的数据副本,并且在一致性协议层实现了原子消息和全局有序消息,从而保证组内数据的一致性。

36 丨数据库没有备份,没有使用 Binlog 的情况下,如何恢复数据?

37 丨 SQL 注入:你的 SQL 是如何被注入的?

SQL 注入攻击(SQL injection),是发生于应用程序之数据层的安全漏洞。简而言之,是在输入的字符串之中注入 SQL 指令,在设计不良的程序当中忽略了检查,那么这些注入进去的指令就会被数据库服务器误认为是正常的 SQL 指令而运行,因此遭到破坏或是入侵。

攻击示例:

考虑以下简单的登录表单:

1
2
3
4
5
<form action="/login" method="POST">
<p>Username: <input type="text" name="username" /></p>
<p>Password: <input type="password" name="password" /></p>
<p><input type="submit" value="登陆" /></p>
</form>

我们的处理里面的 SQL 可能是这样的:

1
2
3
username:=r.Form.Get("username")
password:=r.Form.Get("password")
sql:="SELECT * FROM user WHERE username='"+username+"' AND password='"+password+"'"

如果用户的输入的用户名如下,密码任意

1
myuser' or 'foo' = 'foo' --

那么我们的 SQL 变成了如下所示:

1
SELECT * FROM user WHERE username='myuser' or 'foo' = 'foo' --'' AND password='xxx'

在 SQL 里面 -- 是注释标记,所以查询语句会在此中断。这就让攻击者在不知道任何合法用户名和密码的情况下成功登录了。

对于 MSSQL 还有更加危险的一种 SQL 注入,就是控制系统,下面这个可怕的例子将演示如何在某些版本的 MSSQL 数据库上执行系统命令。

1
2
sql:="SELECT * FROM products WHERE name LIKE '%"+prod+"%'"
Db.Exec(sql)

如果攻击提交 a%' exec master..xp_cmdshell 'net user test testpass /ADD' -- 作为变量 prod 的值,那么 sql 将会变成

1
sql:="SELECT * FROM products WHERE name LIKE '%a%' exec master..xp_cmdshell 'net user test testpass /ADD'--%'"

MSSQL 服务器会执行这条 SQL 语句,包括它后面那个用于向系统添加新用户的命令。如果这个程序是以 sa 运行而 MSSQLSERVER 服务又有足够的权限的话,攻击者就可以获得一个系统帐号来访问主机了。

虽然以上的例子是针对某一特定的数据库系统的,但是这并不代表不能对其它数据库系统实施类似的攻击。针对这种安全漏洞,只要使用不同方法,各种数据库都有可能遭殃。

攻击手段和目的

  • 数据表中的数据外泄,例如个人机密数据,账户数据,密码等。
  • 数据结构被黑客探知,得以做进一步攻击(例如 SELECT * FROM sys.tables)。
  • 数据库服务器被攻击,系统管理员账户被窜改(例如 ALTER LOGIN sa WITH PASSWORD='xxxxxx')。
  • 获取系统较高权限后,有可能得以在网页加入恶意链接、恶意代码以及 XSS 等。
  • 经由数据库服务器提供的操作系统支持,让黑客得以修改或控制操作系统(例如 xp_cmdshell “net stop iisadmin”可停止服务器的 IIS 服务)。
  • 破坏硬盘数据,瘫痪全系统(例如 xp_cmdshell “FORMAT C:”)。

应对手段

  • 使用参数化查询 - 建议使用数据库提供的参数化查询接口,参数化的语句使用参数而不是将用户输入变量嵌入到 SQL 语句中,即不要直接拼接 SQL 语句。例如使用 database/sql 里面的查询函数 PrepareQuery ,或者 Exec(query string, args ...interface{})
  • 单引号转换 - 在组合 SQL 字符串时,先针对所传入的参数进行字符替换(将单引号字符替换为连续 2 个单引号字符)。

38 丨如何在 Excel 中使用 SQL 语言?

39 丨 WebSQL:如何在 H5 中存储一个本地数据库?

40 丨 SQLite:为什么微信用 SQLite 存储聊天记录?

41 丨初识 Redis:Redis 为什么会这么快?

42 丨如何使用 Redis 来实现多用户抢票问题

43 丨如何使用 Redis 搭建玩家排行榜?

44 丨 DBMS 篇总结和答疑:用 SQLite 做词云

45 丨数据清洗:如何使用 SQL 对数据进行清洗?

SQL 可以帮我们进行数据处理,总的来说可以分成 OLTP 和 OLAP 两种方式。

  • OLTP:称之为联机事务处理。对数据进行增删改查,SQL 查询优化,事务处理等就属于 OLTP 的范畴。它对实时性要求高,需要将用户的数据有效地存储到数据库中,同时有时候针对互联网应用的需求,我们还需要设置数据库的主从架构保证数据库的高并发和高可用性。
  • OLAP:称之为联机分析处理。它是对已经存储在数据库中的数据进行分析,帮我们得出报表,指导业务。它对数据的实时性要求不高,但数据量往往很大,存储在数据库(数据仓库)中的数据可能还存在数据质量的问题,比如数据重复、数据中有缺失值,或者单位不统一等,因此在进行数据分析之前,首要任务就是对收集的数据进行清洗,从而保证数据质量。

46 丨数据集成:如何对各种数据库进行集成和转换?

ETL 是英文 Extract、Transform 和 Load 的缩写,也就是将数据从不同的数据源进行抽取,然后通过交互转换,最终加载到目的地的过程。

  • 在 Extract 数据抽取这个过程中,需要做大量的工作,我们需要了解企业分散在不同地方的数据源都采用了哪种 DBMS,还需要了解这些数据源存放的数据结构等,是结构化数据,还是非结构化数据。在抽取中,我们也可以采用全量抽取和增量抽取两种方式。相比于全量抽取,增量抽取使用得更为广泛,它可以帮我们动态捕捉数据源的数据变化,并进行同步更新。
  • 在 Transform 数据转换的过程中,我们可以使用一些数据转换的组件,比如说数据字段的映射、数据清洗、数据验证和数据过滤等,这些模块可以像是在流水线上进行作业一样,帮我们完成各种数据转换的需求,从而将不同质量,不同规范的数据进行统一。
  • 在 Load 数据加载的过程中,我们可以将转换之后的数据加载到目的地,如果目标是 RDBMS,我们可以直接通过 SQL 进行加载,或者使用批量加载的方式进行加载。

47 丨如何利用 SQL 对零售数据进行分析?

参考资料

《RocketMQ 技术内幕》笔记

读源代码前的准备

RocketMQ 源代码的目录结构

  • broker:broker 模块(broker 启动进程) 。
  • client:消息客户端,包含生产者、消息消费者相关类。
  • common:公共包。
  • dev:开发者信息(非源代码) 。
  • distribution:部署实例文件夹(非源代码) 。
  • example:RocketMQ 示例代码。
  • filter:消息过滤相关基础类。
  • filter:消息过滤服务器实现相关类(Filter 启动进程) 。
  • logappender:日志实现相关类。
  • namesrv:N ameServer 实现相关类(Names 巳 rver 启动进程) 。
  • openmessaging:消息开放标准,正在制定中。
  • remoting:远程通信模块,基于 Netty 。
  • srvutil:服务器工具类。
  • store:消息存储实现相关类。
  • style:checkstyle 相关实现。
  • test:测试相关类。
  • tools:工具类,监控命令相关实现类。

RocketMQ 的设计理念和目标

设计理念

RocketMQ 设计基于主题的订阅与发布模式, 其核心功能包括:消息发送、消息存储( Broker )、消息消费。其整体设计追求简单与性能第一,主要体现在如下三个方面:

  • 自研 NameServer,而不是用 ZooKeeper 作为注册中心。因为 ZooKeeper 采用 CAP 模型中的 CP 模型,其实并不适用于注册中心的业务模式。
  • RocketMQ 的消息存储文件设计成文件组的概念,组内单个文件大小固定,方便引入内存映射机制,所有主
    题的消息存储基于顺序写, 极大地提供了消息写性能,同时为了兼顾消息消费与消息查找,引入了消息消费队列文件与索引文件。
  • 容忍存在设计缺陷,适当将某些工作下放给 RocketMQ 使用者。消息中间件的实现者经常会遇到一个难题:如何保证消息一定能被消息消费者消费,并且保证只消费一次。RocketMQ 的设计者给出的解决办法是不解决这个难题,而是退而求其次,只保证消息被消费者消费,但设计上允许消息被重复消费,这样极大地简化了消息中间件的内核,使得实现消息发送高可用变得非常简单与高效,消息重复问题由消费者在消息消费时实现幂等。

设计目标

  • 架构模式:RocketMQ 与大部分消息中间件一样,采用发布订阅模式,基本的参与组件主要包括:消息发送者、消息服务器(消息存储)、消息消费、路由发现。
  • 顺序消息:所谓顺序消息,就是消息消费者按照消息达到消息存储服务器的顺序消费。RocketMQ 可以严格保证消息有序。
  • 消息过滤:消息过滤是指在消息消费时,消息消费者可以对同一主题下的消息按照规则只消费自己感兴趣的消息。RocketMQ 消息过滤支持在服务端与消费端的消息过滤机制。
  • 消息在 Broker 端过滤。Broker 只将消息消费者感兴趣的消息发送给消息消费者。
  • 消息在消息消费端过滤,消息过滤方式完全由消息消费者自定义,但缺点是有很多无用的消息会从 Broker 传输到消费端。
  • 消息存储:消息中间件的一个核心实现是消息的存储,对消息存储一般有如下两个维度的考量:消息堆积能力和消息存储性能。RocketMQ 追求消息存储的高性能,引人内存映射机制,所有主题的消息顺序存储在同一个文件中。同时为了避免消息无限在消息存储服务器中累积,引入了消息文件过期机制与文件存储空间报警机制。
  • 消息高可用性
    • 通常影响消息可靠性的有以下几种情况。
      1. Broker 正常关机。
      2. Broker 异常 Crash 。
      3. OS Crash 。
      4. 机器断电,但是能立即恢复供电情况。
      5. 机器无法开机(可能是 CPU 、主板、内存等关键设备损坏) 。
      6. 磁盘设备损坏。
    • 针对上述情况,情况 1~4 的 RocketMQ 在同步刷盘机制下可以确保不丢失消息,在异步刷盘模式下,会丢失少量消息。情况 5-6 属于单点故障,一旦发生,该节点上的消息全部丢失,如果开启了异步复制机制, RoketMQ 能保证只丢失少量消息, RocketMQ 在后续版本中将引人双写机制,以满足消息可靠性要求极高的场合。
  • 消息到达( 消费)低延迟:RocketMQ 在消息不发生消息堆积时,以长轮询模式实现准实时的消息推送模式。
  • 确保消息必须被消费一次:RocketMQ 通过消息消费确认机制(ACK)来确保消息至少被消费一次,但由于 ACK 消息有可能丢失等其他原因, RocketMQ 无法做到消息只被消费一次,有重复消费的可能。
  • 回溯消息:回溯消息是指消息消费端已经消费成功的消息,由于业务要求需要重新消费消息。RocketMQ 支持按时间回溯消息,时间维度可精确到毫秒,可以向前或向后回溯。
  • 消息堆积:消息中间件的主要功能是异步解耦,必须具备应对前端的数据洪峰,提高后端系统的可用性,必然要求消息中间件具备一定的消息堆积能力。RocketMQ 消息存储使用磁盘文件(内存映射机制),并且在物理布局上为多个大小相等的文件组成逻辑文件组,可以无限循环使用。RocketMQ 消息存储文件并不是永久存储在消息服务器端,而是提供了过期机制,默认保留 3 天。
  • 定时消息:定时消息是指消息发送到 Broker 后, 不能被消息消费端立即消费,要到特定的时间点或者等待特定的时间后才能被消费。如果要支持任意精度的定时消息消费,必须在消息服务端对消息进行排序,势必带来很大的性能损耗,故 RocketMQ 不支持任意进度的定时消息,而只支持特定延迟级别。
  • 消息重试机制:消息重试是指消息在消费时,如果发送异常,消息中间件需要支持消息重新投递,RocketMQ 支持消息重试机制。

RocketMQ 路由中心 NameServer

NameServer 架构设计

Broker 消息服务器在启动时向所有 NameServer 注册,生产者(Producer)在发送消息之前先从 NameServer 获取 Broker 服务器地址列表,然后根据负载算法从列表中选择一台消息服务器进行消息发送。NameServer 与每台 Broker 服务器保持长连接,并间隔 30s 检测 Broker 是否存活,如果检测到 Broker 宕机, 则从路由注册表中将其移除。但是路由变化不会马上通知生产者,为什么要这样设计呢?这是为了降低 NameServer 实现的复杂性,在消息发送端提供容错机制来保证消息发送的高可用性。

NameServer 本身的高可用可通过部署多台 NameServer 服务器来实现,但彼此之间互不通信,也就是说 NameServer 服务器之间在某一时刻的数据并不会完全相同,但这对消息发送不会造成任何影响。

NameServer 启动流程

  1. 加载配置,然后根据配置初始化 NamesrvController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public static NamesrvController createNamesrvController(String[] args) throws IOException, JoranException {
System.setProperty(RemotingCommand.REMOTING_VERSION_KEY, Integer.toString(MQVersion.CURRENT_VERSION));
//PackageConflictDetect.detectFastjson();

Options options = ServerUtil.buildCommandlineOptions(new Options());
commandLine = ServerUtil.parseCmdLine("mqnamesrv", args, buildCommandlineOptions(options), new PosixParser());
if (null == commandLine) {
System.exit(-1);
return null;
}

// 1. 初始化 NamesrvConfig 配置和 NettyServerConfig 配置
final NamesrvConfig namesrvConfig = new NamesrvConfig();
final NettyServerConfig nettyServerConfig = new NettyServerConfig();
nettyServerConfig.setListenPort(9876);

// 1.1. 加载配置文件中的配置
if (commandLine.hasOption('c')) {
String file = commandLine.getOptionValue('c');
if (file != null) {
InputStream in = new BufferedInputStream(new FileInputStream(file));
properties = new Properties();
properties.load(in);
MixAll.properties2Object(properties, namesrvConfig);
MixAll.properties2Object(properties, nettyServerConfig);

namesrvConfig.setConfigStorePath(file);

System.out.printf("load config properties file OK, %s%n", file);
in.close();
}
}

// 1.2. 加载启动命令中的配置
if (commandLine.hasOption('p')) {
InternalLogger console = InternalLoggerFactory.getLogger(LoggerName.NAMESRV_CONSOLE_NAME);
MixAll.printObjectProperties(console, namesrvConfig);
MixAll.printObjectProperties(console, nettyServerConfig);
System.exit(0);
}

MixAll.properties2Object(ServerUtil.commandLine2Properties(commandLine), namesrvConfig);

// 2. 强制必须设置环境变量 ROCKETMQ_HOME
if (null == namesrvConfig.getRocketmqHome()) {
System.out.printf("Please set the %s variable in your environment to match the location of the RocketMQ installation%n", MixAll.ROCKETMQ_HOME_ENV);
System.exit(-2);
}

// 3. 打印配置项
LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
JoranConfigurator configurator = new JoranConfigurator();
configurator.setContext(lc);
lc.reset();
configurator.doConfigure(namesrvConfig.getRocketmqHome() + "/conf/logback_namesrv.xml");

log = InternalLoggerFactory.getLogger(LoggerName.NAMESRV_LOGGER_NAME);

MixAll.printObjectProperties(log, namesrvConfig);
MixAll.printObjectProperties(log, nettyServerConfig);

final NamesrvController controller = new NamesrvController(namesrvConfig, nettyServerConfig);

// remember all configs to prevent discard
controller.getConfiguration().registerConfig(properties);

return controller;
}

  1. 根据启动属性创建 NamesrvController 实例,并初始化该实例, NameServerController 实例为 NameServer 核心控制器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public boolean initialize() {

// 加载KV 配置
this.kvConfigManager.load();

// 创建 NettyRemotingServer 网络处理对象
this.remotingServer = new NettyRemotingServer(this.nettyServerConfig, this.brokerHousekeepingService);

this.remotingExecutor =
Executors.newFixedThreadPool(nettyServerConfig.getServerWorkerThreads(), new ThreadFactoryImpl("RemotingExecutorThread_"));

// 注册进程
this.registerProcessor();

// 开启两个定时任务(心跳检测)
// 任务一:NameServer 每隔 1O 秒扫描一次 Broker,移除不活跃的 Broker
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {

@Override
public void run() {
NamesrvController.this.routeInfoManager.scanNotActiveBroker();
}
}, 5, 10, TimeUnit.SECONDS);
// 任务二:NameServer 每隔 1O 分钟打印一次 KV 配置
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {

@Override
public void run() {
NamesrvController.this.kvConfigManager.printAllPeriodically();
}
}, 1, 10, TimeUnit.MINUTES);

// 如果是 TLS 模式,加载证书,开启安全模式
if (TlsSystemConfig.tlsMode != TlsMode.DISABLED) {
// Register a listener to reload SslContext
try {
fileWatchService = new FileWatchService(
new String[] {
TlsSystemConfig.tlsServerCertPath,
TlsSystemConfig.tlsServerKeyPath,
TlsSystemConfig.tlsServerTrustCertPath
},
new FileWatchService.Listener() {
boolean certChanged, keyChanged = false;
@Override
public void onChanged(String path) {
if (path.equals(TlsSystemConfig.tlsServerTrustCertPath)) {
log.info("The trust certificate changed, reload the ssl context");
reloadServerSslContext();
}
if (path.equals(TlsSystemConfig.tlsServerCertPath)) {
certChanged = true;
}
if (path.equals(TlsSystemConfig.tlsServerKeyPath)) {
keyChanged = true;
}
if (certChanged && keyChanged) {
log.info("The certificate and private key changed, reload the ssl context");
certChanged = keyChanged = false;
reloadServerSslContext();
}
}
private void reloadServerSslContext() {
((NettyRemotingServer) remotingServer).loadSslContext();
}
});
} catch (Exception e) {
log.warn("FileWatchService created error, can't load the certificate dynamically");
}
}

return true;
}
  1. 注册 JVM 钩子函数并启动服务器,以便监昕 Broker 、生产者的网络请求。
1
2
3
4
5
6
7
8
9
// 注册 JVM 钩子函数并启动服务器,以便监昕Broker、 生产者的网络请求
// 如果代码中使用了线程池,一种优雅停机的方式就是注册一个 JVM 钩子函数,在 JVM 进程关闭之前,先将线程池关闭,及时释放资源
Runtime.getRuntime().addShutdownHook(new ShutdownHookThread(log, new Callable<Void>() {
@Override
public Void call() throws Exception {
controller.shutdown();
return null;
}
}));

NameServer 路由注册、故障剔除

NameServer 主要作用是为生产者和消息消费者提供关于主题 Topic 的路由信息,那么 NameServer 需要存储路由的基础信息,还要能够管理 Broker 节点,包括路由注册、路由删除等功能。

路由元信息

NameServer 路由实现类:org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager。它主要存储了以下信息:

  • topicQueueTable:Topic 消息队列路由信息,消息发送时根据路由表进行负载均衡。
  • brokerAddrTable:Broker 基础信息, 包含 brokerName 、所属集群名称、主备 Broker 地址。
  • clusterAddrTable:Broker 集群信息,存储集群中所有 Broker 名称。
  • brokerLiveTable:Broker 状态信息。NameServer 每次收到心跳包时会替换该信息。
  • filterServerTable:Broker 上的 FilterServer 列表,用于类模式消息过滤。

RocketMQ 基于订阅发布机制,一个 Topic 拥有多个消息队列,一个 Broker 为每一主题默认创建 4 个读队列 4 个写队列。多个 Broker 组成一个集群,BrokerName 由相同的多台 Broker 组成 Master-Slave 架构, brokerId 为 0 代表 Master,大于 0 表示 Slave。BrokerLiveInfo 中的 lastUpdateTimestamp 存储上次收到 Broker 心跳包的时间。

路由注册

RocketMQ 路由注册是通过 Broker 与 NameServer 的心跳功能实现的。Broker 启动时向集群中所有的 NameServer 发送心跳语句,每隔 30s 向集群中所有 NameServer 发送心跳包, NameServer 收到 Broker 心跳包时会更新 brokerLiveTable 缓存中 BrokerLiveInfo 的 lastUpdateTimestamp ,然后 NameServer 每隔 10s 扫描 brokerLiveTable ,如果连续 120s 没有收到心跳包, NameServer 将移除该 Broker 的路由信息同时关闭 Socket 连接。

(1)Broker 发送心跳包

Broker 会遍历 NameServer 列表, 依次向所有 NameServer 发送心跳包。

(2)NameServer 处理心跳包

  • 路由注册需要加写锁,防止并发修改 RouteInfoManager 中的路由表。
  • 判断 Broker 所属集群是否存在,如果不存在,则创建,然后将 broker 加入到 Broker 集群。
  • 维护 BrokerData 信息,首先从 brokerAddrTable 根据 BrokerName 尝试获取 Broker 信息。
    • 如果不存在,则新建 BrokerData 并放入到 brokerAddrTable, registerFirst 设置为 true;
    • 如果存在,直接将 registerFirst 设置为 false,表示非第一次注册。
  • 如果 Broker 为 Master,并且 Broker Topic 配置信息发生变化或者是初次注册,则需要创建或更新 Topic 路由元数据。填充 topicQueueTable,其实就是为默认主题自动注册路由信息,其中包含 MixAII.DEFAULT_TOPIC 的路由信息。当生产者发送主题时,如果该主题未创建并且 BrokerConfig 的 autoCreateTopicEnable 为 true 时,将返回 MixAII.DEFAULT_TOPIC 的路由信息。
  • 更新 BrokerLiveInfo,存活 Broker 信息表,BrokeLiveInfo 是执行路由删除的重要依据。
  • 注册 Broker 的过滤器 Server 地址列表,一个 Broker 上会关联多个 FilterServer 消息过滤服务器;如果此 Broker 为从节点,则需要查找该 Broker 的 Master 的节点信息,并更新对应的 masterAddr 属性。

设计亮点: NameServe 与 Broker 保持长连接, Broker 状态存储在 brokerLiveTable 中,NameServer 每收到一个心跳包,将更新 brokerLiveTable 中关于 Broker 的状态信息以及路由表(topicQueueTable 、brokerAddrTable 、brokerLiveTable 、filterServerTable) 。更新上述路由表(HashTable)使用了锁粒度较少的读写锁,允许多个消息发送者(Producer )并发读,保证消息发送时的高并发。但同一时刻 NameServer 只处理一个 Broker 心跳包,多个心跳包请求串行执行。

路由删除

Broker 每隔 30s 向 NameServe 发送一个心跳包,心跳包中包含 BrokerId 、Broker 地址、Broker 名称、Broker 所属集群名称、Broker 关联的 FilterServer 列表。但是如果 Broker 宕机,NameServer 无法收到心跳包,此时 NameServer 如何来剔除这些失效的 Broker 呢? NameServer 会每隔 10s 扫描 brokerLiveTable 状态表,如果 BrokerLive 的 lastUpdateTimestamp 的时间戳距当前时间超过 120s ,则认为 Broker 失效,移除该 Broker,关闭与 Broker 连接,并同时更新 topicQueueTable 、brokerAddrTable 、brokerLiveTable 、filterServerTable 。

RocktMQ 有两个触发点来触发路由删除。

  • NameServer 定时扫描 brokerLiveTable 检测上次心跳包与当前系统时间的时间差,如果时间戳大于 120s ,则需要移除该 Broker 信息。

  • Broker 在正常被关闭的情况下,会执行 unregisterBroker 指令。

由于不管是何种方式触发的路由删除,路由删除的方法都是一样的,就是从 topicQueueTable 、rokerAddrTable 、brokerLiveTable 、filterServerTable 删除与该 Broker 相关的信息,但 RocketMQ 这两种方式维护路由信息时会抽取公共代码。

scanNotActiveBroker 在 NameServer 中每 10s 执行一次。逻辑很简单:遍历 brokerLiveInfo 路由表(HashMap),检测 BrokerLiveInfo 的 lastUpdateTimestamp。上次收到心跳包的时间如果超过当前时间 120s,NameServer 则认为该 Broker 已不可用,故需要将它移除,关闭 Channel,然后删除与该 Broker 相关的路由信息,路由表维护过程,需要申请写锁。

(1)申请写锁,根据 brokerAddress 从 brokerLiveTable 、filterServerTable 移除

(2)维护 brokerAddrTable 。遍历从 HashMap<String /* brokerName */, BrokerData> brokerAddrTable,从 BrokerData 的 HashMap<Long /* brokerId */, String /* broker address */> brokerAddrs 中,找到具体的 Broker ,从 BrokerData 中移除,如果移除后在 BrokerData 中不再包含其他 Broker,则在 brokerAddrTable 中移除该 brokerName 对应的条目。

(3)根据 brokerName,从 clusterAddrTable 中找到 Broker 并从集群中移除。如果移除后,集群中不包含任何 Broker,则将该集群从 clusterAddrTable 中移除。

(4)根据 brokerName,遍历所有主题的队列,如果队列中包含了当前 Broker 的队列, 则移除,如果 topic 只包含待移除 Broker 的队列的话,从路由表中删除该 topic。

路由发现

RocketMQ 路由发现是非实时的,当 Topic 路由出现变化后,NameServer 不主动推送给客户端,而是由客户端定时拉取主题最新的路由。根据主题名称拉取路由信息的命令编码为:GET_ROUTEINTO_BY_TOPIC 。

orderTopicConf :顺序消息配置内容,来自于 kvConfig 。

List<QueueData> queueData:topic 队列元数据。

List<BrokerData> brokerDatas:topic 分布的 broker 元数据。

HashMap<String/*brokerAdress*/,List<String> /*filterServer*/> :broker 上过滤服务器地址列表。

NameServer 路由发现实现方法:DefaultRequestProcessor#getRouteInfoByTopic

  1. 调用 RouterlnfoManager 的方法,从路由表 topicQueueTable 、brokerAddrTable 、filterServerTable 中分别填充 TopicRouteData 中的 List<QueueData>List<BrokerData> 和 filterServer 地址表。

  2. 如果找到主题对应的路由信息并且该主题为顺序消息,则从 NameServer KVconfig 中获取关于顺序消息相关的配置填充路由信息。

如果找不到路由信息 CODE 则使用 TOPIC NOT_EXISTS ,表示没有找到对应的路由。

RocketMQ 消息发送

漫谈 RocketMQ 消息发送

RocketMQ 支持 3 种消息发送方式:同步(sync) 、异步(async)、单向(oneway) 。

  • 同步:发送者向 MQ 执行发送消息 API 时,同步等待, 直到消息服务器返回发送结果。
  • 异步:发送者向 MQ 执行发送消息 API 时,指定消息发送成功后的回掉函数,然后调用消息发送 API 后,立即返回,消息发送者线程不阻塞,直到运行结束,消息发送成功或失败的回调任务在一个新的线程中执行。
  • 单向:消息发送者向 MQ 执行发送消息 API 时,直接返回,不等待消息服务器的结果,也不注册回调函数,简单地说,就是只管发,不在乎消息是否成功存储在消息服务器上。

RocketMQ 消息发送需要考虑以下几个问题。

  • 消息队列如何进行负载?
  • 消息发送如何实现高可用?
  • 批量消息发送如何实现一致性?

认识 RocketMQ 消息

RocketMQ 消息的封装类是 org.apache.rocketmq.common.message.Message。其主要属性有:

  • topic:主题
  • properties:属性容器。RocketMQ 会向其中添加一些扩展属性:
    • tags:消息标签,用于消息过滤。
    • keys:消息索引,多个用空格隔开,RocketMQ 可以根据这些 key 快速检索到消息。
    • waitStoreMsgOK:消息发送时是否等消息存储完成后再返回。
    • delayTimeLevel:消息延迟级别,用于定时消息或消息重试。
  • body:消息体
  • transactionId:事务 ID

生产者启动流程

DefaultMQProducer 是默认的生产者实现类。它实现了 MQAdmin 的接口。

初识 DefaultMQProducer 消息发送者

DefaultMQProducer 的主要方法
  • void createTopic(String key, String newTopic, int queueNum, int topicSysFlag):创建主题
    • key:目前未实际作用,可以与 newTopic 相同。
    • newTopic:主题名称。
    • queueNum:队列数量。
    • topicSysFlag:主题系统标签,默认为 0 。
  • long searchOffset(final MessageQueue mq, final long timestamp):根据时间戳从队列中查找其偏移量。
  • long maxOffset(final MessageQueue mq):查找该消息队列中最大的物理偏移量。
  • long minOffset(final MessageQueue mq):查找该消息队列中最小物理偏移量。
  • MessageExt viewMessage(final String offsetMsgld):根据消息偏移量查找消息。
  • QueryResult queryMessage(final String topic, final String key, final int maxNum, final long begin, final long end):根据条件查询消息。
    • topic:消息主题。
    • key:消息索引字段。
    • maxNum:本次最多取出消息条数。
    • begin:开始时间。
    • end:结束时间。
  • MessageExt viewMessage(String topic,String msgld):根据主题与消息 ID 查找消息。
  • List<MessageQueue> fetchPublishMessageQueues(final String topic):查找该主题下所有的消息队列。
  • SendResult send(final Message msg):同步发送消息,具体发送到主题中的哪个消息队列由负载算法决定。
  • SendResult send(final Message msg, final long timeout):同步发送消息,如果发送超过 timeout 则抛出超时异常。
  • void send(final Message msg, final SendCallback sendCallback):异步发送消息, sendCallback 参数是消息发送成功后的回调方法。
  • void send(final Message msg, final SendCallback sendCallback, final long timeout):异步发送消息,如果发送超过 timeout 指定的值,则抛出超时异常。
  • void sendOneway(final Message msg):单向消息发送,就是不在乎发送结果,消息发送出去后该方法立即返回。
  • SendResult send(final Message msg, final MessageQueue mq):同步方式发送消息,发送到指定消息队列。
  • void send(final Message msg, final MessageQueue mq, final SendCallback sendCallback):异步方式发送消息,发送到指定消息队列。
  • void sendOneway(final Message msg, final MessageQueue mq):单向方式发送消息,发送到指定的消息队列。
  • SendResult send(final Message msg , final MessageQueueSelector selector, final Object arg):消息发送,指定消息选择算法,覆盖生产者默认的消息队列负载。
  • SendResult send(final Collection<Message> msgs, final MessageQueue mq, final long timeout):同步批量消息发送。
DefaultMQProducer 的核心属性
  • producerGroup:生产者所属组,消息服务器在回查事务状态时会随机选择该组中任何一个生产者发起事务回查请求。
  • createTopicKey:默认 topicKey 。
  • defaultTopicQueueNums:默认主题在每一个 Broker 队列数量。
  • sendMsgTimeout:发送消息默认超时时间, 默认 3s 。
  • compressMsgBodyOverHowmuch:消息体超过该值则启用压缩,默认 4K。
  • retryTimesWhenSendFailed:同步方式发送消息重试次数,默认为 2 ,总共执行 3 次。
  • retryTimesWhenSendAsyncFailed:异步方式发送消息重试次数,默认为 2 。
  • retryAnotherBrokerWhenNotStoreOK:消息重试时选择另外一个 Broker ,是否不等待存储结果就返回, 默认为 false 。
  • maxMessageSize:允许发送的最大消息长度,默认为 4M ,眩值最大值为 2^32-1 。

生产者启动流程

DefaultMQProducerImpl#start() 是生产者的启动方法,其主要工作流程如下:

  1. 检查生产者组(productGroup)是否符合要求;并改变生产者的 instanceName 为进程 ID 。
  2. 获取或创建 MQClientInstance 实例。
    • 整个 JVM 实例中只存在一个 MQClientManager 实例(单例)。
    • MQClientManager 中维护一个 ConcurrentMap 类型的缓存,用于保证同一个 clientId 只会创建一个 MQClientInstance
  3. 将当前生产者注册到 MQClientInstance 中,方便后续调用网络请求、进行心跳检测等。
  4. 启动 MQClientInstance ,如果 MQClientInstance 已经启动,则本次启动不会真正执行。
  5. 向所有 Broker 发送心跳。
  6. 启动一个定时任务,用于定期清理过时的发送请求。

消息发送基本流程

消息发送的核心方法是 DefaultMQProducerImpl#sendDefaultImpl

消息长度验证

消息发送之前,首先确保生产者处于运行状态,然后验证消息是否符合相应的规范,具体的规范要求是主题名称、消息体不能为空、消息长度不能等于 0 且默认不能超过允许发送消息的最大长度 4M(maxMessageSize=l024 * 1024 * 4) 。

查找主题路由信息

消息发送之前,首先需要获取主题的路由信息,只有获取了这些信息我们才知道消息要发送到具体的 Broker 节点。

tryToFindTopicPublishInfo 是查找主题的路由信息的方法。

如果生产者中缓存了 topic 的路由信息,或路由信息中包含了消息队列,则直接返回该路由信息。

如果没有缓存或没有包含消息队列, 则向 NameServer 查询该 topic 的路由信息。

如果最终未找到路由信息,则抛出异常:无法找到主题相关路由信息异常。

TopicPublishinfo 的属性:

  • orderTopic:是否为顺序消息。
  • haveTopicRouterInfo:是否有主题路由信息。
  • List<MessageQueue> messageQueueList:Topic 的消息队列。
  • sendWhichQueue:用于选择消息队列。每选择一次消息队列, 该值会自增 1。
  • topicRouteData:主题路由数据。

MQClientlnstance#updateTopicRouteInfoFromNameServer 这个方法的功能是生产者更新和维护路由缓存。

  1. 如果 isDefault 为 true,则使用默认主题去查询,如果查询到路由信息,则替换路由信息中读写队列个数为生产者默认的队列个数(defaultTopicQueueNums);如果 isDefault 为 false,则使用参数 topic 去查询;如果未查询到路由信息,则返回 false ,表示路由信息未变化。
  2. 如果路由信息找到,与本地缓存中的路由信息进行对比,判断路由信息是否发生了改变,如果未发生变化,则直接返回 false 。
  3. 更新 MQClientInstance 的 Broker 地址缓存表。
  4. 根据 topicRouteData 中的 List<QueueData> 转换成 topicPublishInfoList<MessageQueue> 列表。其具体实现在 topicRouteData2TopicPublishInfo 中, 然后会更新该 MQClientInstance 所管辖的所有消息,发送关于 topic 的路由信息。
  5. 循环遍历路由信息的 QueueData 信息,如果队列没有写权限,则继续遍历下一个 QueueData;根据 brokerName 找到 brokerData 信息,找不到或没有找到 Master 节点,则遍历下一个 QueueData;根据写队列个数,根据 topic +序号 创建 MessageQueue ,填充 TopicPublishInfoList<QueueMessage>

选择 Broker

消息发送

RocketMQ 消息存储

RocketMQ 消息消费

消息过滤 FilterServer

RocketMQ 主从同步

RocketMQ 事务消息

RocketMQ 实战

参考资料

《极客时间教程 - 软件工程之美》笔记

到底应该怎样理解软件工程?

软件产品危机:软件产品质量低劣、软件维护工作量大、成本不断上升、进度不可控、程序人员无限度地增加。

软件工程,它是为研究和克服软件危机而生。

软件工程的本质:用工程化方法去规范软件开发,让项目可以按时完成、成本可控、质量有保证。

软件工程的核心:是围绕软件项目开发,对开发过程的组织,对方法的运用,对工具的使用。

软件工程 = 过程 + 方法 + 工具。

工程思维:把每件事都当作一个项目来推进

有目的、有计划、有步骤地解决问题的方法就是工程方法。

工程方法通常会分成六个阶段:想法、概念、计划、设计、开发和发布。

  • 想法:想法阶段通常是想要解决问题。最开始问题通常是模糊的,所以需要清晰地定义好问题,研究其可行性,检查是否有可行的解决方案。
  • 概念:概念阶段就是用图纸、草图、模型等方式,提出一些概念性的解决方案。这些方案可能有多个,最终会确定一个解决方案。
  • 计划:计划阶段是关于如何实施的计划,通常会包含人员、任务、任务持续时间、任务的依赖关系,以及完成项目所需要的预算。
  • 设计:设计阶段就是要针对产品需求,将解决方案进一步细化,设计整体架构和划分功能模块,作为分工合作和开发实施的一个依据和参考。
  • 开发:开发阶段就是根据设计方案,将解决方案构建实施。开发阶段通常是一个迭代的过程,这个阶段通常会有构建、测试、调试和重新设计的迭代。
  • 发布:将最终结果包括文档发布。

瀑布模型:像工厂流水线一样把软件开发分层化

瀑布模型把整个项目过程分成了六个主要阶段:

  • 问题的定义及规划:这个阶段是需求方和开发方共同确定软件开发目标,同时还要做可行性研究,以确定项目可行。这个阶段会产生需求文档和可行性研究报告。
  • 需求分析:对需求方提出的所有需求,进行详细的分析。这个阶段一般需要和客户反复确认,以保证能充分理解客户需求。最终会形成需求分析文档。
  • 软件设计:根据需求分析的结果,对整个软件系统进行抽象和设计,如系统框架设计,数据库设计等等。最后会形成架构设计文档。
  • 程序编码:将架构设计和界面设计的结果转换成计算机能运行的程序代码。
  • 软件测试:在编码完成后,对可运行的结果对照需求分析文档进行严密的测试。如果测试发现问题,需要修复。最终测试完成后,形成测试报告。
  • 运行维护:在软件开发完成,正式运行投入使用。后续需要继续维护,修复错误和增加功能。交付时需要提供使用说明文档。

瀑布模型之外,还有哪些开发模型?

快速原型模型

快速原型模型,就是为了要解决客户的需求不明确和需求多变的问题。

先迅速建造一个可以运行的软件原型,然后收集用户反馈,再反复修改确认,使开发出的软件能真正反映用户需求,这种开发模型就叫快速原型模型,也叫原型模型。

原型模型因为能快速修改,所以能快速对用户的反馈和变更作出响应,同时原型模型注重和客户的沟通,所以最终开发出来的软件能够真正反映用户的需求。

但这种快速原型开发往往是以牺牲质量为代价的。

增量模型

增量模型是把待开发的软件系统模块化,然后在每个小模块的开发过程中,应用一个小瀑布模型,对这个模块进行需求分析、设计、编码和测试。相对瀑布模型而言,增量模型周期更短,不需要一次性把整个软件产品交付给客户,而是分批次交付。

因为增量模型的根基是模块化,所以,如果系统不能模块化,那么将很难采用增量模型的模式来开发。另外,对模块的划分很抽象,这本身对于系统架构的水平是要求很高的。

基于这样的特点,增量模型主要适用于:需求比较清楚,能模块化的软件系统,并且可以按模块分批次交付。

迭代模型

迭代模型每次只设计和实现产品的一部分,然后逐步完成更多功能。每次设计和实现一个阶段叫做一个迭代。

在迭代模型中,整个项目被拆分成一系列小的迭代。通常一个迭代的时间都是固定的,不会太长,例如 2-4 周。每次迭代只实现一部分功能,做能在这个周期内完成的功能。

在一个迭代中都会包括需求分析、设计、实现和测试,类似于一个小瀑布模型。迭代结束时要完成一个可以运行的交付版本。

增量模型是按照功能模块来拆分;而迭代模型则是按照时间来拆分,看单位时间内能完成多少功能。

V 模型

V 模型适合外包项目。V 模型本质上还是瀑布模型,只不过它是更重视对每个阶段验收测试的过程模型。

针对从需求定义一直到编码阶段,每个阶段都有对应的测试验收。

螺旋模型

如果你现在要做一个风险很高的项目,客户可能随时不给你钱了。这种情况下,如果采用传统瀑布模型,无疑风险很高,可能做完的时候才发现客户给不了钱,损失就很大了!

这种情况,基于增量模型或者迭代模型进行开发,就可以有效降低风险。你需要注意的是,在每次交付的时候,要同时做一个风险评估,如果风险过大就不继续后续开发了,及时止损。

这种强调风险,以风险驱动的方式完善项目的开发模型就是螺旋模型。

敏捷开发到底是想解决什么问题?

敏捷开发是一套价值观和原则。

瀑布模型面向的是过程,而敏捷开发面向的是人。

大厂都在用哪些敏捷方法?(上)

一切工作任务围绕 Ticket 开展

  • 每一个任务的状态都可以被跟踪起来:什么时候开始做的,谁在做,做完没有。
  • 整个团队在做什么一目了然。
  • Ticket 和敏捷开发中的 Backlog(任务清单)正好结合起来,通过 Ticket 可以收集管理整个项目的 Backlog 和当前 Sprint(迭代)的 Backlog。

基于 Git 和 CI 的开发流程

Git 本来只是源代码管理工具,但是其强大的分支管理和灵活的权限控制,结合一定的开发流程,却可以帮助你很好的控制代码质量。

站立会议

  • 每个人轮流介绍一下,昨天干了什么事情,今天计划做什么事情,工作上有没有障碍无法推进。有问题,记录到“问题停车场”。
  • 检查最近的 Ticket,甄别一下优先级。有需要讨论的先收集到问题停车场。
  • 针对未讨论的问题展开讨论,能在会议时间内解决的问题,就马上解决,不能解决的会后再私下讨论或者再组织会议。

大厂都在用哪些敏捷方法?(下)

在分工上:

  • 产品经理:写需求设计文档,将需求整理成 Ticket,随时和项目成员沟通确认需求;
  • 开发人员:每天从看板上按照优先级从高到低领取 Ticket,完成日常开发任务;
  • 测试人员:测试已经部署到测试环境的程序,如果发现 Bug,提交 Ticket;
  • 项目经理:保障日常工作流程正常执行,让团队成员可以专注工作,提供必要的帮助,解决问题。

如何完成需求和修复 Bug?

日常工作,是围绕 Ticket 来开展的。所有的需求、Bug、任务都作为 Ticket 提交到项目的 Backlog,每个 Sprint 的任务都以看板的形式展现出来。

每个人手头事情忙完后,就可以去看板上的“To Do”栏,按照优先级从高到低选取新的 Ticket。选取后移动到“In Progress”栏。

每周一部署生产环境

参考资料

RocketMQ FAQ

API 问题

connect to <172.17.0.1:10909> failed

启动后,Producer 客户端连接 RocketMQ 时报错:

1
2
3
4
5
6
7
8
9
10
11
12
org.apache.rocketmq.remoting.exception.RemotingConnectException: connect to <172.17.0.1:10909> failed
at org.apache.rocketmq.remoting.netty.NettyRemotingClient.invokeSync(NettyRemotingClient.java:357)
at org.apache.rocketmq.client.impl.MQClientAPIImpl.sendMessageSync(MQClientAPIImpl.java:343)
at org.apache.rocketmq.client.impl.MQClientAPIImpl.sendMessage(MQClientAPIImpl.java:327)
at org.apache.rocketmq.client.impl.MQClientAPIImpl.sendMessage(MQClientAPIImpl.java:290)
at org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl.sendKernelImpl(DefaultMQProducerImpl.java:688)
at org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl.sendSelectImpl(DefaultMQProducerImpl.java:901)
at org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl.send(DefaultMQProducerImpl.java:878)
at org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl.send(DefaultMQProducerImpl.java:873)
at org.apache.rocketmq.client.producer.DefaultMQProducer.send(DefaultMQProducer.java:369)
at com.emrubik.uc.mdm.sync.utils.MdmInit.sendMessage(MdmInit.java:62)
at com.emrubik.uc.mdm.sync.utils.MdmInit.main(MdmInit.java:2149)

原因:RocketMQ 部署在虚拟机上,内网 ip 为 10.10.30.63,该虚拟机一个 docker0 网卡,ip 为 172.17.0.1。RocketMQ broker 启动时默认使用了 docker0 网卡,Producer 客户端无法连接 172.17.0.1,造成以上问题。

解决方案

(1)干掉 docker0 网卡或修改网卡名称

(2)停掉 broker,修改 broker 配置文件,重启 broker。

修改 conf/broker.conf,增加两行来指定启动 broker 的 IP:

1
2
namesrvAddr = 10.10.30.63:9876
brokerIP1 = 10.10.30.63

启动时需要指定配置文件

1
nohup sh bin/mqbroker -n localhost:9876 -c conf/broker.conf &

话术

职场“黑话”

二字动词

复盘,赋能,加持,沉淀,倒逼,落地,串联,协同,反哺,兼容,包装,重组,履约,响应,量化,布局,联动,细分,梳理,输出,加速,共建,支撑,融合,聚合,集成,对标,聚焦,抓手,拆解,抽象,摸索,提炼,打通,打透,吃透,迁移,分发,分装,辐射,围绕,复用,渗透,扩展,开拓,皮实,共创,共建,解耦,集成,对齐,拉齐,对焦,给到,拿到,死磕

三字名词

感知度,方法论,组合拳,引爆点,点线面,精细化,差异化,平台化,结构化,影响力,耦合性,便捷性,一致性,端到端,短平快,护城河,体验感,颗粒度

四字名词

生命周期,价值转化,强化认知,资源倾斜,完善逻辑,抽离透传,复用打法,商业模式,快速响应,定性定量,关键路径,去中心化,结果导向,垂直领域,归因分析,体验度量,信息屏障,资源整合

术语应用

比如原来你提问:这个问题你准备怎么解决?

现在的你可以说:你这个问题的底层逻辑是什么?顶层设计在哪?最终交付价值是什么?过程的抓手在哪?如何保证回答闭环?你比别人的亮点在哪?优势在哪?你的思考和沉淀是什么?这个问题换成我来问是否会不一样?在这之前,有自己的思考和沉淀吗?这些问题的颗粒度是怎样拆分的,能作为爆点,引发回答者对问题关键路径的探索吗?别人回答了,你能反哺赋能他们,共建团队意识生态吗?只会问而不会解决,你有自己独有的价值吗?

比如你之前只会一脸懵逼的看着他,愣着不敢说话,现在你可以这么回复他:

我们这款产品底层逻辑是打通信息屏障,创建行业新生态。顶层设计是聚焦用户感知赛道,通过差异化和颗粒度达到引爆点。交付价值是在垂直领域采用复用打法达成持久收益。抽离透传归因分析作为抓手为产品赋能,体验度量作为闭环的评判标准。亮点是载体,优势是链路。思考整个生命周期,完善逻辑考虑资源倾斜。方法论是组合拳达到平台化标准。

模板

yes and 法则

当别人提出一个观点,自己不认同时,我们往往会说,yes,but,这样别人会觉得“那就这样吧,你说什么就是什么吧”的想法,无法形成有效的沟通。

yes-and 的原则首先是要接纳,而不是全身都是刺,即当别人提出观点的时候还没仔细思考就先给予否定,尤其当对方不认可攻击你时,很多人立马想到的就是反击。

然后关键是这里的 and,这里的 and 并不是转折或反驳,而是并且或附加的内容,可以巧妙地避免意见不同甚至冲突,所以 yes-and 不仅仅是做事方式,也是一种特别好的沟通方式,让对方感受很舒服。

【示例】

男朋友特别喜欢爬山,有一天男朋友邀请女生去爬山,女生说了下面的一段话 “我不爱爬上,但是我特别好奇你到山顶后看到的风景,你能爬上去之后能给我拍几样张照片吗?专门为我拍的,然后你下来以后,我在哪个咖啡馆等你,你跟我讲讲此次的经历!”

可以听出来,这位女生并不爱爬山,也没有勉强自己去迎合新的男朋友,而是肯定了男朋友的爱好,并且在此基础上创造了新的情景来继续他们的交流!

PREP 模型

PREP 模型用于表达观点

PREP 四个英文字母分别代表:Point,观点;Reason,理由;Example,案例;Point,再次讲观点。这是最经典的表达结构。

整个 PREP 结构的关键是,开始就要讲出你的观点,点明主题;后面再举出理由来论证观点;案例部分,最好讲自己的经历或故事来解释,这样听众比较容易听懂;最后再重复和强调一下你的观点。

SCQ-A 模型

SCQ-A 模型 用于提出问题,请求帮助

  • situation:阐述背景
  • conflict:阐述冲突
  • question:为了解决冲突,你提出要解决的问题
  • answer:你的看法

【示例】

老板,最近竞争对手上任了新的 CEO,做了一系列措施,比如下调了产品的价格,增大推广和营销,导致现在我们的很多市场被对方蚕食了。

我们现在该如何调整应应对当前的情况,保持市场上的领导位置。

目前我的看法就是优化目前的营销渠道,全面包围对手。

FFC 赞美法则

所谓 FFC 赞美法,就是指在赞美人的时候,先用自己的语言来表达感受(Feel),然后再进一步通过陈述事实(Fact)来论证自己的感受,最后再通过比较(Compare),来加深对对方的认可,这样对方会感觉特别好。

让对方服从你行为的经典话术

这个话术一般来讲模板是这样的: 是..还是…/是否/要不要,xxx 好处是这样。

这里的关键是尽量不让用户思考,提供非 A 即 B 的选项,说某个选项的好处,从而让对方服从你。

比如麦当劳、肯德基有 3 个经典话术,进行快推式产品营销:

  • 您是否要加一包薯条,这样可以凑成一个套餐,节省 2 元?(实际上多消费 5 元)
  • 您要不要加 3 元把可乐换成大杯,可以多一半哦?
  • 您要不要加 10 元买个玩具给小朋友呢?

沟通中的万能表达模型- 观察+感受+需求+请求

  • 观察:即你观察的客观事实是什么?注意这里要是事实才行
  • 感受:通过观察之后,你心情感受是怎样的?
  • 需求:你内心希望要解决的问题是什么?
  • 请求:向对方请求你的需求需要得到满足

FFA 法则

  • Fact——事实
  • Feeling——感受
  • Action——行动

最近一段团队不少新人加入,整体运营效率下降了 30%。(事实)我感觉主要是业务知识传递有些跟不上。(感受)接下来的一周,我会安排老员工一对一辅导每位新人。(行动)

实战

别人求你办事,如果你说:“这事儿不太好办”,那么资源置换就来了。

不好办说明能办,但需要附加条件,懂的人自然知道接下来应该怎么办。

拒绝借钱:“你知道的,我最近 XX,也没钱。”

遇到借钱,只要你平时不太露富,就好用。

朋友管你借钱,用“你知道的”直接把皮球踢回去,再给个具体理由,比如买了什么东西、投入基金股票里了,随便什么都行。

只要把对方放在知道你没钱的位置上,他就不好再开下一句口,一般会主动结束对话。

表示体贴,但不想真的去接客户,先打电话沟通:“约的地方有点远,需要我去接您吗?”

绝大部分人遇到这样的问句,本能反应是不用,你的目的就达到了。

不过如果真遇到要你接的,这人多半有点矫情,以后相处要注意多恭维一下。

经典汇报话术 1:“老板,我们团队做了 A、B 两版方案,各有优势,您给提提意见,看选哪个好。”

该话术利用了沉锚效应,抛出了二者选其一的锚,避开全部拒绝的选项,引导领导“选一个”、“提意见”,减小被全部驳回的风险。

经典汇报话术 2:“领导,我是这么想的,XXX。第一,X;第二,X;第三,X。”

我们的大脑被训练得听到“第一、第二”就默认为其中有条理、有逻辑,不管其中是不是真的严丝合缝地支撑你的观点。善用一二三做汇报,领导会觉得你准备得很充分,考虑周全。

遇到问题请求领导帮忙:“经过了解,现在碰到了一些情况,我的解决办法是 XX,您看还有没有什么更好的办法。”

不要直接说自己解决不了,让领导想办法。不管自己提出的办法多平庸,都一定要提。

请求他人帮忙:“能请你帮我打印一下文件吗,因为我一会儿真有事。”、“不好意思,能插一下队吗,因为我真的着急。”

善用“因为所以”,“因为所以”是十几年语文教学留给我们的条件反射,不管多离奇的理由,听到的时候都会默认有道理。

聊天想聊下去:揪住对方句子里的关键词+延伸过往彼此交流过的信息。

例 1

朋友:“今天又加班,烦死了。”

你:“怎么又加班啊,又是上回让你加班的那个领导吗?”

例 2

闺蜜:“我爱豆塌房子了!”

你:“哪个爱豆?上回你说的 XXX?我去!”

聊天不想继续聊下去:重复关键词+感叹词。重点!不要扩展任何有效信息。

例 1

朋友:“今天又加班,烦死了”

你:“怎么又加班啊,唉,这叫啥事啊,我无语。”

例 2

闺蜜:“我爱豆又塌房子了!”

你:“又塌房子!我去,也太那个了,绝了!”

领导说:“辛苦了。”

你:“从中学到很多,很有收获。”

敏而好学、不居功,领导更喜欢这样的下属。

给领导的节日问候:尊称+感谢+互动+祝福

尊称放在前面,引起注意。互动要具体、细节,才有记忆点。

XX,过年好!
感谢您一直的关照,从您身上学到很多。

上次 XX,您说 XXX 我一直记得,受益匪浅。

又到新的一年,祝您和家人新年快乐!

改变一个人的想法:认同立场,替换观点。

无论任何人,观点不是不可改变的,但立场很难动摇。

比如你的预算交上去,被砍了很大一块,你能做的不是抱怨老板抠门,而是认同老板砍预算是为了控制成本,开公司是为了赚钱的,这就是老板该有的立场。

所以,如果你不想自己的预算被砍掉,只有你能向老板展示,你做的方案能为他赚更多钱,老板就不会不同意。

工作汇报

实情 话术
新项目玩砸了 进行了积极的试错,吸收了宝贵的经验。
数据不好看 有较大的增长空间
啥也没干 稳定发展
接下来,我依旧打算啥也不干 保持现有成绩,稳定成果
数据稍微好看一点 取得了较大增长

沟通

当你觉得对方特别啰嗦,又不好意思打断对方谈话

直接给对方说的话下定义,作评判。例如:“好的”、“那确实不错”、“的确是这样”、“嗯,你说的对” 等等。

对方会瞬间失去表达欲望。

当你接到你不想做的任务时

  • 我仔细看了一下这个需求,我这里可能存在 XXX XXX XXX 方面的短板
  • “领导,我仔细看了一下这个需求,我这里可能存在 XXX XXX XXX 方面的短板”
  • “想要推进这件事情的话,我可能需要 XXX XXX XXX 方面的支持”

好的可能:领导感觉你是认真经过调研,分析了可行性,确实你不太适合,他表示会再考虑考虑

坏的可能:你还是得做,但是这句话的意思已经很明确了:我可是给你说清楚了啊,这件事办砸了,你可不能赖我

当我想刺探什么秘密的时候,我会先说一个结论,然后看对方的反应

  • “我们公司下个月要发奖金你知道对吧,想好怎么花了么?”
    • “卧槽你这消息灵通啊”——真的要发
    • “你听谁说的”——不知道真假,但是可以继续测
  • 这个时候我会再补一句:“我能得到这个消息,说明肯定有人放风给我”
    • “胡扯,没有这个规划”——下个月不发奖金
    • “你每天花花肠子怎么这么多”——没有明确的回应这个问题,我的猜测 80%是真实的

临时要交什么汇报,或者做工作总结的时候,不要紧张

【你日常都在做什么】+【这件事的目的是什么】+【你在做这件事的时候有什么困难】+【怎么把这件事做的更好】

比如:

我最近在更新我的知乎账号,坚持回答问题,这是为了能够积累更多的粉丝数,获得更多的认同,也培养自己输出的习惯

但是我发现我的阅读量上去了,但是点赞量还没有起来

所以我决定在写到这一段的时候给读者老爷们磕个头求个赞,哐哐哐

想要说服别人的时候

注意两点:

  • 先肯定对方的想法

  • 尽量不要出现比较主观的用语,如:“我觉得”、“照我看”、“我认为”

【示例】我仔细听了你的诉求,很有道理,这个需求是可以理解的。但是,我们换个角度想一想:……。而且,我还听别人说:…..。所以,不妨折中一下:…..。你觉得这样如何呢?

当别人给你布置杂活的时候

我们要死扣细节,不停问细节:

  • “你说的这件事大概什么时候需要?”

  • “这个时间点具体到几点?”

  • “我是微信给你还是邮箱给你?”

  • “那需要我先给一个计划,你帮我看看合适不合适么?”

  • ……

一个杂活而已,你不停地追问细节,会极大地增加你们之间的沟通成本,让对方崩溃,从此再也不想给你安排杂活。

参考资料

《极客时间教程 - 职场求生攻略》笔记

学会如何工作,和学习技术同等重要

以利益为视角,以换位思考为手段。

优先级:工作中那么多事情,我要如何安排优先级?

基于工作性质安排优先级

工作可以划分为:

业务拓展:需求分析、设计、开发都属于这范畴。

安全问题:安全无小事。要高度重视安全问题。

线上问题:直接影响用户体验和权益。要第一优先级去处理。

基于合作安排优先级

事情如果没有明显的轻重缓急,优先做那些会阻塞别人工作的事情。

做事情本身的优先级

我们做事情的时候,如果能把其中的每一步都想清楚,理清依赖关系,安排得井井有条,这就已经事半功倍了。

沟通:邮件那么重要,你还在轻视邮件吗?

邮件的特性

  1. 异步交流:邮件是一种异步交流的方式,双方有足够的时间准备邮件内容。
  2. 无法修改:邮件内容无法修改,这是邮件可靠的基石。
  3. 方便扩散:邮件有邮件组,可以很方便地把相关人员加进来,并且保留邮件历史记录。

邮件是公司内部的合同

场景 1:设计确认(邮件的“确认”功能)

场景 2:优先级(邮件的“证据链”功能)

场景 3:大促(邮件的“沟通协调”功能)

场景 4:新业务接入(邮件的“防遗忘”功能)

场景 5:技术升级和 Bug 修复(邮件的“广而告之”功能)

邮件的魅力

我们都是普通人,普通人没有“背锅”的压力,就没有持久的把事情做好的动力。

沟通:程序员为什么应该爱上交流?

主观能动性:为什么程序员,需要发挥主观能动性?

责任的边界:程序员的职责范围仅仅只是被安排的任务吗?

RocketMQ 基本原理

原理

分布式消息系统作为实现分布式系统可扩展、可伸缩性的关键组件,需要具有高吞吐量、高可用等特点。而谈到消息系统的设计,就回避不了两个问题:

  1. 消息的顺序问题
  2. 消息的重复问题

顺序消息

第一种模型

假如生产者产生了 2 条消息:M1、M2,要保证这两条消息的顺序,应该怎样做?你脑中想到的可能是这样:

假定 M1 发送到 S1,M2 发送到 S2,如果要保证 M1 先于 M2 被消费,那么需要 M1 到达消费端被消费后,通知 S2,然后 S2 再将 M2 发送到消费端。

这个模型存在的问题是,如果 M1 和 M2 分别发送到两台 Server 上,就不能保证 M1 先达到 MQ 集群,也不能保证 M1 被先消费。换个角度看,如果 M2 先于 M1 达到 MQ 集群,甚至 M2 被消费后,M1 才达到消费端,这时消息也就乱序了,说明以上模型是不能保证消息的顺序的。

第二种模型

如何才能在 MQ 集群保证消息的顺序?一种简单的方式就是将 M1、M2 发送到同一个 Server 上:

这样可以保证 M1 先于 M2 到达 MQServer(生产者等待 M1 发送成功后再发送 M2),根据先达到先被消费的原则,M1 会先于 M2 被消费,这样就保证了消息的顺序。

这个模型也仅仅是理论上可以保证消息的顺序,在实际场景中可能会遇到下面的问题:

只要将消息从一台服务器发往另一台服务器,就会存在网络延迟问题。如上图所示,如果发送 M1 耗时大于发送 M2 的耗时,那么 M2 就仍将被先消费,仍然不能保证消息的顺序。即使 M1 和 M2 同时到达消费端,由于不清楚消费端 1 和消费端 2 的负载情况,仍然有可能出现 M2 先于 M1 被消费的情况。

如何解决这个问题?将 M1 和 M2 发往同一个消费者,且发送 M1 后,需要消费端响应成功后才能发送 M2。

这可能产生另外的问题:如果 M1 被发送到消费端后,消费端 1 没有响应,那是继续发送 M2 呢,还是重新发送 M1?一般为了保证消息一定被消费,肯定会选择重发 M1 到另外一个消费端 2,就如下图所示。

这样的模型就严格保证消息的顺序,细心的你仍然会发现问题,消费端 1 没有响应 Server 时有两种情况,一种是 M1 确实没有到达(数据在网络传送中丢失),另外一种消费端已经消费 M1 且已经发送响应消息,只是 MQ Server 端没有收到。如果是第二种情况,重发 M1,就会造成 M1 被重复消费。也就引入了我们要说的第二个问题,消息重复问题,这个后文会详细讲解。

回过头来看消息顺序问题,严格的顺序消息非常容易理解,也可以通过文中所描述的方式来简单处理。总结起来,要实现严格的顺序消息,简单且可行的办法就是:

保证生产者 - MQServer - 消费者是一对一对一的关系。

这样的设计虽然简单易行,但也会存在一些很严重的问题,比如:

  1. 并行度就会成为消息系统的瓶颈(吞吐量不够)
  2. 更多的异常处理,比如:只要消费端出现问题,就会导致整个处理流程阻塞,我们不得不花费更多的精力来解决阻塞的问题。

RocketMQ 的解决方案:通过合理的设计或者将问题分解来规避。如果硬要把时间花在解决问题本身,实际上不仅效率低下,而且也是一种浪费。从这个角度来看消息的顺序问题,我们可以得出两个结论:

  1. 不关注乱序的应用实际大量存在
  2. 队列无序并不意味着消息无序

最后我们从源码角度分析 RocketMQ 怎么实现发送顺序消息。

RocketMQ 通过轮询所有队列的方式来确定消息被发送到哪一个队列(负载均衡策略)。比如下面的示例中,订单号相同的消息会被先后发送到同一个队列中:

1
2
3
4
5
6
7
8
9
10
11
// RocketMQ 通过 MessageQueueSelector 中实现的算法来确定消息发送到哪一个队列上
// RocketMQ 默认提供了两种 MessageQueueSelector 实现:随机/Hash
// 当然你可以根据业务实现自己的 MessageQueueSelector 来决定消息按照何种策略发送到消息队列中
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Integer id = (Integer) arg;
int index = id % mqs.size();
return mqs.get(index);
}
}, orderId);

在获取到路由信息以后,会根据 MessageQueueSelector 实现的算法来选择一个队列,同一个 OrderId 获取到的肯定是同一个队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
private SendResult send()  {
// 获取topic路由信息
TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic());
if (topicPublishInfo != null && topicPublishInfo.ok()) {
MessageQueue mq = null;
// 根据我们的算法,选择一个发送队列
// 这里的arg = orderId
mq = selector.select(topicPublishInfo.getMessageQueueList(), msg, arg);
if (mq != null) {
return this.sendKernelImpl(msg, mq, communicationMode, sendCallback, timeout);
}
}
}

消息重复

造成消息重复的根本原因是:网络不可达。只要通过网络交换数据,就无法避免这个问题。所以解决这个问题的办法就是绕过这个问题。那么问题就变成了:如果消费端收到两条一样的消息,应该怎样处理?

  1. 消费端处理消息的业务逻辑保持幂等性。
  2. 保证每条消息都有唯一编号且保证消息处理成功与去重表的日志同时出现。

第 1 条很好理解,只要保持幂等性,不管来多少条重复消息,最后处理的结果都一样。

第 2 条原理就是利用一张日志表来记录已经处理成功的消息的 ID,如果新到的消息 ID 已经在日志表中,那么就不再处理这条消息。

第 1 条解决方案,很明显应该在消费端实现,不属于消息系统要实现的功能。

第 2 条可以消息系统实现,也可以业务端实现。正常情况下出现重复消息的概率其实很小,如果由消息系统来实现的话,肯定会对消息系统的吞吐量和高可用有影响,所以最好还是由业务端自己处理消息重复的问题,这也是 RocketMQ 不解决消息重复的问题的原因。

RocketMQ 不保证消息不重复,如果你的业务需要保证严格的不重复消息,需要你自己在业务端去重。

事务消息

RocketMQ 除了支持普通消息,顺序消息,另外还支持事务消息。

假设这样的场景:

img

图中执行本地事务(Bob 账户扣款)和发送异步消息应该保证同时成功或者同时失败,也就是扣款成功了,发送消息一定要成功,如果扣款失败了,就不能再发送消息。那问题是:我们是先扣款还是先发送消息呢?

img

RocketMQ 分布式事务步骤:

发送 Prepared 消息 2222222222222222222,并拿到接受消息的地址。
执行本地事务
通过第 1 步骤拿到的地址去访问消息,并修改消息状态。

参考资料

《Kafka 核心源码解读》笔记

开篇词

从功能上讲,Kafka 源码分为四大模块。

  • 服务器端源码:实现 Kafka 架构和各类优秀特性的基础。
  • Java 客户端源码:定义了与 Broker 端的交互机制,以及通用的 Broker 端组件支撑代码。
  • Connect 源码:用于实现 Kafka 与外部系统的高性能数据传输。
  • Streams 源码:用于实现实时的流处理功能。

导读

构建 Kafka 工程和源码阅读环境、Scala 语言热身

kafka 项目主要目录结构

  • bin 目录:保存 Kafka 工具行脚本,我们熟知的 kafka-server-start 和 kafka-consoleproducer 等脚本都存放在这里。
  • clients 目录:保存 Kafka 客户端代码,比如生产者和消费者的代码都在该目录下。
  • config 目录:保存 Kafka 的配置文件,其中比较重要的配置文件是 server.properties。
  • connect 目录:保存 Connect 组件的源代码。我在开篇词里提到过,Kafka Connect 组件是用来实现 Kafka 与外部系统之间的实时数据传输的。
  • core 目录:保存 Broker 端代码。Kafka 服务器端代码全部保存在该目录下。
  • streams 目录:保存 Streams 组件的源代码。Kafka Streams 是实现 Kafka 实时流处理的组件。

日志段

保存消息文件的对象是怎么实现的?

Kafka 日志结构

Kafka 日志对象由多个日志段对象组成,而每个日志段对象会在磁盘上创建一组文件,包括消息日志文件(.log)位移索引文件(.index)时间戳索引文件(.timeindex)以及已中止(Aborted)事务的索引文件(.txnindex)。当然,如果你没有使用 Kafka 事务,已中止事务的索引文件是不会被创建出来的。

一个 Kafka 主题有很多分区,每个分区就对应一个 Log 对象,在物理磁盘上则对应于一个子目录。比如你创建了一个双分区的主题 test-topic,那么,Kafka 在磁盘上会创建两个子目录:test-topic-0 和 test-topic-1。而在服务器端,这就是两个 Log 对象。每个子目录下存在多组日志段,也就是多组 .log.index.timeindex 文件组合,只不过文件名不同,因为每个日志段的起始位移不同。

日志段源码解析

日志段源码位于 Kafka 的 core 工程的 LogSegment.scala 中。该文件下定义了三个 Scala 对象:

  • LogSegment class:日志段类
  • LogSegment object:保存静态变量或静态方法。相当于 LogSegment class 的工具类。
  • LogFlushStats object:尾部有个 stats,用于统计,负责为日志落盘进行计时。

LogSegment class 声明

1
2
3
4
5
6
7
8
class LogSegment private[log] (val log: FileRecords,
val lazyOffsetIndex: LazyIndex[OffsetIndex],
val lazyTimeIndex: LazyIndex[TimeIndex],
val txnIndex: TransactionIndex,
val baseOffset: Long,
val indexIntervalBytes: Int,
val rollJitterMs: Long,
val time: Time) extends Logging { ... }

参数说明:

  • log包含日志条目的文件记录FileRecords 就是实际保存 Kafka 消息的对象。
  • lazyOffsetIndex偏移量索引
  • lazyTimeIndex时间戳索引
  • txnIndex事务索引
  • baseOffset此段中偏移量的下限。事实上,在磁盘上看到的 Kafka 文件名就是 baseOffset 的值。每个 LogSegment 对象实例一旦被创建,它的起始位移就是固定的了,不能再被更改。
  • indexIntervalBytes索引中条目之间的近似字节数。indexIntervalBytes 值其实就是 Broker 端参数 log.index.interval.bytes 值,它控制了日志段对象新增索引项的频率。默认情况下,日志段至少新写入 4KB 的消息数据才会新增一条索引项。
  • rollJitterMs日志段对象新增倒计时的“扰动值”。因为目前 Broker 端日志段新增倒计时是全局设置,这就是说,在未来的某个时刻可能同时创建多个日志段对象,这将极大地增加物理磁盘 I/O 压力。有了 rollJitterMs 值的干扰,每个新增日志段在创建时会彼此岔开一小段时间,这样可以缓解物理磁盘的 I/O 负载瓶颈。
  • time:**Timer 实例**。

append 方法

append 方法接收 4 个参数:分别表示

  • largestOffset:待写入消息批次中消息的最大位移值
  • largestTimestamp:最大时间戳
  • shallowOffsetOfMaxTimestamp:最大时间戳对应消息的位移
  • records:真正要写入的消息集合

  • 第一步:在源码中,首先调用 log.sizeInBytes 方法判断该日志段是否为空,如果是空的话, Kafka 需要记录要写入消息集合的最大时间戳,并将其作为后面新增日志段倒计时的依据。
  • 第二步:代码调用 ensureOffsetInRange 方法确保输入参数最大位移值是合法的。那怎么判断是不是合法呢?标准就是看它与日志段起始位移的差值是否在整数范围内,即 largestOffset - baseOffset 的值是不是 介于 [0,Int.MAXVALUE] 之间。在极个别的情况下,这个差值可能会越界,这时, append 方法就会抛出异常,阻止后续的消息写入。一旦你碰到这个问题,你需要做的是升级你的 Kafka 版本,因为这是由已知的 Bug 导致的。
  • 第三步:待这些做完之后,append 方法调用 FileRecordsappend 方法执行真正的写入。它的工作是将内存中的消息对象写入到操作系统的页缓存就可以了。
  • 第四步:再下一步,就是更新日志段的最大时间戳以及最大时间戳所属消息的位移值属性。每个日志段都要保存当前最大时间戳信息和所属消息的位移信息。还记得 Broker 端提供定期删除日志的功能吗?比如我只想保留最近 7 天的日志,没错,当前最大时间戳这个值就是判断的依据;而最大时间戳对应的消息的位移值则用于时间戳索引项。虽然后面我会详细介绍,这里我还是稍微提一下:时间戳索引项保存时间戳与消息位移的对应关系。在这步操作中,Kafka 会更新并保存这组对应关系。
  • 第五步:append 方法的最后一步就是更新索引项和写入的字节数了。我在前面说过,日志段每写入 4KB 数据就要写入一个索引项。当已写入字节数超过了 4KB 之后,append 方法会调用索引对象的 append 方法新增索引项,同时清空已写入字节数,以备下次重新累积计算。

read 方法

read 方法作用:从第一个偏移量 >= startOffset 的 Segment 开始读取消息集。如果指定了 maxOffset,则消息集将包含不超过 maxSize 字节,并将在 maxOffset 之前结束。

read 方法入参

  • startOffset:要读取的第一条消息的位移;
  • maxSize:能读取的最大字节数;
  • maxPosition :能读到的最大文件位置;
  • minOneMessage:是否允许在消息体过大时至少返回第一条消息。

read 方法代码逻辑:

  1. 调用 translateOffset 方法定位要读取的起始文件位置 (startPosition)。输入参数 startOffset 仅仅是位移值,Kafka 需要根据索引信息找到对应的物理文件位置才能开始读取消息。
  2. 待确定了读取起始位置,日志段代码需要根据这部分信息以及 maxSizemaxPosition 参数共同计算要读取的总字节数。举个例子,假设 maxSize=100,maxPosition=300,startPosition=250,那么 read 方法只能读取 50 字节,因为 maxPosition - startPosition = 50。我们把它和 maxSize 参数相比较,其中的最小值就是最终能够读取的总字节数。
  3. 调用 FileRecordsslice 方法,从指定位置读取指定大小的消息集合。

recover 方法

recover 开始时,代码依次调用索引对象的 reset 方法清空所有的索引文件,之后会开始遍历日志段中的所有消息集合或消息批次(RecordBatch)。对于读取到的每个消息集合,日志段必须要确保它们是合法的,这主要体现在两个方面:

  • 该集合中的消息必须要符合 Kafka 定义的二进制格式;
  • 该集合中最后一条消息的位移值不能越界,即它与日志段起始位移的差值必须是一个正整数值。

校验完消息集合之后,代码会更新遍历过程中观测到的最大时间戳以及所属消息的位移值。同样,这两个数据用于后续构建索引项。再之后就是不断累加当前已读取的消息字节数,并根据该值有条件地写入索引项。最后是更新事务型 Producer 的状态以及 Leader Epoch 缓存。不过,这两个并不是理解 Kafka 日志结构所必需的组件,因此,我们可以忽略它们。

遍历执行完成后,Kafka 会将日志段当前总字节数和刚刚累加的已读取字节数进行比较,如果发现前者比后者大,说明日志段写入了一些非法消息,需要执行截断操作,将日志段大小调整回合法的数值。同时, Kafka 还必须相应地调整索引文件的大小。把这些都做完之后,日志段恢复的操作也就宣告结束了。

日志

日志是日志段的容器,里面定义了很多管理日志段的操作。

Log 源码结构

  • LogAppendInfo(C):保存消息元数据信息
  • LogAppendInfo(O):LogAppendInfo(C)工厂方法类
  • UnifiedLog(C):UnifiedLog.scala 中最核心的代码
  • UnifiedLog(O):UnifiedLog(C)工厂方法类
  • RollParams(C):用于控制日志段是否切分(Roll)的数据结构。
  • RollParams(O):RollParams 伴生类的工厂方法。
  • LogMetricNames(O):定义了 Log 对象的监控指标。
  • LogOffsetSnapshot(C):封装分区所有位移元数据的容器类。
  • LogReadInfo(C):封装读取日志返回的数据及其元数据。
  • CompletedTxn(C):记录已完成事务的元数据,主要用于构建事务索引。

Log Class & Object

Log Object 作用:

  • 定义了 Kafka 支持的文件类型
    • .log:Kafka 日志文件
    • .index:Kafka 偏移量索引文件
    • .timeindex:Kafka 时间戳索引文件
    • .txnindex:Kafka 事务索引文件
    • .snapshot:Kafka 为幂等型或事务型 Producer 所做的快照文件
    • .deleted:被标记为待删除的文件
    • .cleaned:用于日志清理的临时文件
    • .swap:将文件交换到日志中时使用的临时文件
    • -delete:被标记为待删除的目录
    • -future:用于变更主题分区文件夹地址的目录
  • 定义了多种工具类方法

UnifiedLog Class 定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// UnifiedLog 定义
class UnifiedLog(@volatile var logStartOffset: Long,
private val localLog: LocalLog,
brokerTopicStats: BrokerTopicStats,
val producerIdExpirationCheckIntervalMs: Int,
@volatile var leaderEpochCache: Option[LeaderEpochFileCache],
val producerStateManager: ProducerStateManager,
@volatile private var _topicId: Option[Uuid],
val keepPartitionMetadataFile: Boolean) extends Logging with KafkaMetricsGroup { ... }

// LocalLog 定义
class LocalLog(@volatile private var _dir: File,
@volatile private[log] var config: LogConfig,
private[log] val segments: LogSegments,
@volatile private[log] var recoveryPoint: Long,
@volatile private var nextOffsetMetadata: LogOffsetMetadata,
private[log] val scheduler: Scheduler,
private[log] val time: Time,
private[log] val topicPartition: TopicPartition,
private[log] val logDirFailureChannel: LogDirFailureChannel) extends Logging with KafkaMetricsGroup { ... }

上面属性中最重要的两个属性是:_dirlogStartOffset_dir 就是这个日志所在的文件夹路径,也就是主题分区的路径。logStartOffset,表示日志的当前最早位移。_dirlogStartOffset 都是 volatile var 类型,表示它们的值是变动的,而且可能被多个线程更新。

Log End Offset(LEO),是表示日志下一条待插入消息的位移值,而这个 Log Start Offset 是跟它相反的,它表示日志当前对外可见的最早一条消息的位移值。

图中绿色的位移值 3 是日志的 Log Start Offset,而位移值 15 表示 LEO。另外,位移值 8 是高水位值,它是区分已提交消息和未提交消息的分水岭。

有意思的是,Log End Offset 可以简称为 LEO,但 Log Start Offset 却不能简称为 LSO。因为在 Kafka 中,LSO 特指 Log Stable Offset,属于 Kafka 事务的概念。

Log 类的其他属性你暂时不用理会,因为它们要么是很明显的工具类属性,比如 timer 和 scheduler,要么是高阶用法才会用到的高级属性,比如 producerStateManager 和 logDirFailureChannel。工具类的代码大多是做辅助用的,跳过它们也不妨碍我们理解 Kafka 的核心功能;而高阶功能代码设计复杂,学习成本高,性价比不高。

其他一些重要属性:

  • nextOffsetMetadata:它封装了下一条待插入消息的位移值,你基本上可以把这个属性和 LEO 等同起来。
  • highWatermarkMetadata:是分区日志高水位值。
  • segments:我认为这是 Log 类中最重要的属性。它保存了分区日志下所有的日志段信息,只不过是用 Map 的数据结构来保存的。Map 的 Key 值是日志段的起始位移值,Value 则是日志段对象本身。Kafka 源码使用 ConcurrentNavigableMap 数据结构来保存日志段对象,就可以很轻松地利用该类提供的线程安全和各种支持排序的方法,来管理所有日志段对象。
  • Leader Epoch Cache 对象。Leader Epoch 是社区于 0.11.0.0 版本引入源码中的,主要是用来判断出现 Failure 时是否执行日志截断操作(Truncation)。之前靠高水位来判断的机制,可能会造成副本间数据不一致的情形。这里的 Leader Epoch Cache 是一个缓存类数据,里面保存了分区 Leader 的 Epoch 值与对应位移值的映射关系,我建议你查看下 LeaderEpochFileCache 类,深入地了解下它的实现原理。

LOG 类初始化逻辑

Log 的常见操作

Log 的常见操作可以分为 4 类:

  • 高水位管理操作:高水位的概念在 Kafka 中举足轻重,对它的管理,是 Log 最重要的功能之一。
  • 日志段管理:Log 是日志段的容器。高效组织与管理其下辖的所有日志段对象,是源码要解决的核心问题。
  • 关键位移值管理:日志定义了很多重要的位移值,比如 Log Start Offset 和 LEO 等。确保这些位移值的正确性,是构建消息引擎一致性的基础。
  • 读写操作:所谓的操作日志,大体上就是指读写日志。读写操作的作用之大,不言而喻。

高水位管理操作

高水位定义:

1
@volatile private var highWatermarkMetadata: LogOffsetMetadata = LogOffsetMetadata(logStartOffset)

高水位值是 volatile(易变型)的。因为多个线程可能同时读取它,因此需要设置成 volatile,保证内存可见性。另外,由于高水位值可能被多个线程同时修改,因此源码使用 Java Monitor 锁来确保并发修改的线程安全。

高水位值的初始值是 Log Start Offset 值。上节课我们提到,每个 Log 对象都会维护一个 Log Start Offset 值。当首次构建高水位时,它会被赋值成 Log Start Offset 值。

LogOffsetMetadata 定义

1
2
3
case class LogOffsetMetadata(messageOffset: Long,
segmentBaseOffset: Long = Log.UnknownOffset,
relativePositionInSegment: Int = LogOffsetMetadata.UnknownFilePosition) {

三个参数:

  • messageOffset:消息位移值,这是最重要的信息。我们总说高水位值,其实指的就是这个变量的值。
  • segmentBaseOffset:保存该位移值所在日志段的起始位移。日志段起始位移值辅助计算两条消息在物理磁盘文件中位置的差值,即两条消息彼此隔了多少字节。这个计算有个前提条件,即两条消息必须处在同一个日志段对象上,不能跨日志段对象。否则它们就位于不同的物理文件上,计算这个值就没有意义了。这里的 segmentBaseOffset,就是用来判断两条消息是否处于同一个日志段的。
  • relativePositionSegment:保存该位移值所在日志段的物理磁盘位置。这个字段在计算两个位移值之间的物理磁盘位置差值时非常有用。你可以想一想,Kafka 什么时候需要计算位置之间的字节数呢?答案就是在读取日志的时候。假设每次读取时只能读 1MB 的数据,那么,源码肯定需要关心两个位移之间所有消息的总字节数是否超过了 1MB。
获取和设置高水位值
1
2
3
4
5
6
7
8
9
10
11
private def updateHighWatermarkMetadata(newHighWatermark: LogOffsetMetadata): Unit = {
if (newHighWatermark.messageOffset < 0) // 高水位值不能是负数
throw new IllegalArgumentException("High watermark offset should be non-negative")

lock synchronized { // 保护Log对象修改的Monitor锁
highWatermarkMetadata = newHighWatermark // 赋值新的高水位值
producerStateManager.onHighWatermarkUpdated(newHighWatermark.messageOffset)
maybeIncrementFirstUnstableOffset() // First Unstable Offset是Kafka事务机制
}
trace(s"Setting high watermark $newHighWatermark")
}
更新高水位值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def updateHighWatermark(hw: Long): Long = {
// 新高水位值一定介于[Log Start Offset,Log End Offset]之间
val newHighWatermark = if (hw < logStartOffset)
logStartOffset
else if (hw > logEndOffset)
logEndOffset
else
hw
// 设置高水位值
updateHighWatermarkMetadata(LogOffsetMetadata(newHighWatermark))
// 最后返回新高水位值
newHighWatermark
}

def maybeIncrementHighWatermark(newHighWatermark: LogOffsetMetadata): Option[LogOffsetMetadata] = {
// 新高水位值不能越过Log End Offset
if (newHighWatermark.messageOffset > logEndOffset)
throw new IllegalArgumentException(s"High watermark $newHighWatermark update exceeds current " +
s"log end offset $logEndOffsetMetadata")

lock.synchronized {
val oldHighWatermark = fetchHighWatermarkMetadata // 获取老的高水位值

// 保证高水位单调递增。当新的偏移元数据位于较新的段上时,我们还会更新高水位线,每当日志滚动到新段时就会发生这种情况。
if (oldHighWatermark.messageOffset < newHighWatermark.messageOffset ||
(oldHighWatermark.messageOffset == newHighWatermark.messageOffset && oldHighWatermark.onOlderSegment(newHighWatermark))) {
updateHighWatermarkMetadata(newHighWatermark)
Some(oldHighWatermark) // 返回老的高水位值
} else {
None
}
}
}

这两个方法有着不同的用途。updateHighWatermark 方法,主要用在 Follower 副本从 Leader 副本获取到消息后更新高水位值。一旦拿到新的消息,就必须要更新高水位值;而 maybeIncrementHighWatermark 方法,主要是用来更新 Leader 副本的高水位值。需要注意的是,Leader 副本高水位值的更新是有条件的——某些情况下会更新高水位值,某些情况下可能不会。

读取高水位值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private def fetchHighWatermarkMetadata: LogOffsetMetadata = {
checkIfMemoryMappedBufferClosed() // 读取时确保日志不能被关闭

val offsetMetadata = highWatermarkMetadata // 保存当前高水位值到本地变量
if (offsetMetadata.messageOffsetOnly) { // 没有获得到完整的高水位元数据
lock.synchronized {
// 给定消息偏移量,在日志中找到其对应的偏移量元数据。如果消息偏移量超出范围,则抛出异常
val fullOffset = convertToOffsetMetadataOrThrow(highWatermark)
updateHighWatermarkMetadata(fullOffset) // 然后再更新一下高水位对象
fullOffset
}
} else {
offsetMetadata
}
}

日志段管理

添加

1
2
@threadsafe
def addSegment(segment: LogSegment): LogSegment = this.segments.put(segment.baseOffset, segment)

删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def deleteOldSegments(): Int = {
if (config.delete) {
deleteRetentionMsBreachedSegments() + deleteRetentionSizeBreachedSegments() + deleteLogStartOffsetBreachedSegments()
} else {
deleteLogStartOffsetBreachedSegments()
}
}

private def deleteOldSegments(predicate: (LogSegment, Option[LogSegment]) => Boolean, reason: String): Int = {
lock synchronized {
val deletable = deletableSegments(predicate)
if (deletable.nonEmpty)
info(s"Found deletable segments with base offsets [${deletable.map(_.baseOffset).mkString(",")}] due to $reason")
deleteSegments(deletable)
}
}

修改

源码里面不涉及修改日志段对象,所谓的修改或更新也就是替换而已,用新的日志段对象替换老的日志段对象。举个简单的例子。segments.put(1L, newSegment) 语句在没有 Key=1 时是添加日志段,否则就是替换已有日志段。

查询

主要都是利用了 ConcurrentSkipListMap 的现成方法。

  • segments.firstEntry:获取第一个日志段对象;
  • segments.lastEntry:获取最后一个日志段对象,即 Active Segment;
  • segments.higherEntry:获取第一个起始位移值 ≥ 给定 Key 值的日志段对象;
  • segments.floorEntry:获取最后一个起始位移值 ≤ 给定 Key 值的日志段对象。

关键位移值管理

Log 对象维护了一些关键位移值数据,比如 Log Start Offset、LEO 等。

Log 对象中的 LEO 永远指向下一条待插入消息,也就是说,LEO 值上面是没有消息的

1
@volatile private var nextOffsetMetadata: LogOffsetMetadata = _

Log End Offset 对象被更新的时机:

  • 对象初始化时:当 Log 对象初始化时,我们必须要创建一个 LEO 对象,并对其进行初始化。
  • 写入新消息时:这个最容易理解。以上面的图为例,当不断向 Log 对象插入新消息时,LEO 值就像一个指针一样,需要不停地向右移动,也就是不断地增加。
  • Log 对象发生日志切分(Log Roll)时:日志切分是啥呢?其实就是创建一个全新的日志段对象,并且关闭当前写入的日志段对象。这通常发生在当前日志段对象已满的时候。一旦发生日志切分,说明 Log 对象切换了 Active Segment,那么,LEO 中的起始位移值和段大小数据都要被更新,因此,在进行这一步操作时,我们必须要更新 LEO 对象。
  • 日志截断(Log Truncation)时:这个也是显而易见的。日志中的部分消息被删除了,自然可能导致 LEO 值发生变化,从而要更新 LEO 对象。

Log Start Offset 被更新的时机:

  • Log 对象初始化时:和 LEO 类似,Log 对象初始化时要给 Log Start Offset 赋值,一般是将第一个日志段的起始位移值赋值给它。
  • 日志截断时:同理,一旦日志中的部分消息被删除,可能会导致 Log Start Offset 发生变化,因此有必要更新该值。
  • Follower 副本同步时:一旦 Leader 副本的 Log 对象的 Log Start Offset 值发生变化。为了维持和 Leader 副本的一致性,Follower 副本也需要尝试去更新该值。
  • 删除日志段时:这个和日志截断是类似的。凡是涉及消息删除的操作都有可能导致 LogStart Offset 值的变化。
  • 删除消息时:严格来说,这个更新时机有点本末倒置了。在 Kafka 中,删除消息就是通过抬高 Log Start Offset 值来实现的,因此,删除消息时必须要更新该值。

读写操作

写操作流程:

读操作

read 方法中有 4 个参数:

  • startOffset,即从 Log 对象的哪个位移值开始读消息。
  • maxLength,即最多能读取多少字节。
  • isolation,设置读取隔离级别,主要控制能够读取的最大位移值,多用于 Kafka 事务。
  • minOneMessage,即是否允许至少读一条消息。设想如果消息很大,超过了 maxLength,正常情况下 read 方法永远不会返回任何消息。但如果设置了该参数为 true,read 方法就保证至少能够返回一条消息。

索引

索引类图及源文件组织架构

  • AbstractIndex.scala:它定义了最顶层的抽象类,这个类封装了所有索引类型的公共操作。
  • LazyIndex.scala:它定义了 AbstractIndex 上的一个包装类,实现索引项延迟加载。这个类主要是为了提高性能。
  • OffsetIndex.scala:定义位移索引,保存“< 位移值,文件磁盘物理位置 >”对。
  • TimeIndex.scala:定义时间戳索引,保存“< 时间戳,位移值 >”对。
  • TransactionIndex.scala:定义事务索引,为已中止事务(Aborted Transcation)保存重要的元数据信息。只有启用 Kafka 事务后,这个索引才有可能出现。

AbstractIndex 代码结构

  • 索引文件(file)。每个索引对象在磁盘上都对应了一个索引文件。你可能注意到了,这个字段是 var 型,说明它是可以被修改的。难道索引对象还能动态更换底层的索引文件吗?是的。自 1.1.0 版本之后,Kafka 允许迁移底层的日志路径,所以,索引文件自然要是可以更换的。
  • 起始位移值(baseOffset)。索引对象对应日志段对象的起始位移值。举个例子,如果你查看 Kafka 日志路径的话,就会发现,日志文件和索引文件都是成组出现的。比如说,如果日志文件是 00000000000000000123.log,正常情况下,一定还有一组索引文件 00000000000000000123.index、00000000000000000123.timeindex 等。这里的“123”就是这组文件的起始位移值,也就是 baseOffset 值。
  • 索引文件最大字节数(maxIndexSize)。它控制索引文件的最大长度。Kafka 源码传入该参数的值是 Broker 端参数 segment.index.bytes 的值,即 10MB。这就是在默认情况下,所有 Kafka 索引文件大小都是 10MB 的原因。
  • 索引文件打开方式(writable)。“True”表示以“读写”方式打开,“False”表示以“只读”方式打开。

位移索引

位移索引也就是所谓的 OffsetIndex。Key 就是消息的相对位移,Value 是保存该消息的日志段文件中该消息第一个字节的物理文件位置。

参考资料

《极客时间教程 - 分布式协议与算法实战》笔记

拜占庭将军问题

拜占庭将军问题是由莱斯利·兰波特在其同名论文中提出的分布式对等网络通信容错问题。其实是借拜占庭将军的例子,抛出了分布式共识性问题,并探讨和论证了解决的方法。

分布式计算中,不同的节点通过通讯交换信息达成共识而按照同一套协作策略行动。但有时候,系统中的节点可能出错而发送错误的信息,用于传递信息的通讯网络也可能导致信息损坏,使得网络中不同的成员关于全体协作的策略得出不同结论,从而破坏系统一致性。拜占庭将军问题被认为是容错性问题中最难的问题类型之一。

问题描述

一群拜占庭将军各领一支军队共同围困一座城市。

为了简化问题,军队的行动策略只有两种:进攻(Attack,后面简称 A)或 撤退(Retreat,后面简称 R)。如果这些军队不是统一进攻或撤退,就可能因兵力不足导致失败。因此,将军们通过投票来达成一致策略:同进或同退

因为将军们分别在城市的不同方位,所以他们只能通过信使互相联系。在投票过程中,每位将军都将自己的投票信息(A 或 R)通知其他所有将军,这样一来每位将军根据自己的投票和其他所有将军送来的信息就可以分析出共同的投票结果而决定行动策略。

这个抽象模型的问题在于:将军中可能存在叛徒,他们不仅会发出误导性投票,还可能选择性地发送投票信息

由于将军之间需要通过信使通讯,叛变将军可能通过伪造信件来以其他将军的身份发送假投票。而即使在保证所有将军忠诚的情况下,也不能排除信使被敌人截杀,甚至被敌人间谍替换等情况。因此很难通过保证人员可靠性及通讯可靠性来解决问题。

假使那些忠诚(或是没有出错)的将军仍然能通过多数决定来决定他们的战略,便称达到了拜占庭容错。在此,票都会有一个默认值,若消息(票)没有被收到,则使用此默认值来投票。

上述的故事可以映射到分布式系统中,_将军代表分布式系统中的节点;信使代表通信系统;叛徒代表故障或异常_。

img

问题分析

兰伯特针对拜占庭将军问题,给出了两个解决方案:口头协议和书面协议。

本文介绍一下口头协议。

在口头协议中,拜占庭将军问题被简化为将军 - 副官模型,其核心规则如下:

  • 忠诚的副官遵守同一命令。
  • 若将军是忠诚的,所有忠诚的副官都执行他的命令。
  • 如果叛徒人数为 m,将军人数不能少于 3m + 1 ,那么拜占庭将军问题就能解决了。——关于这个公式,可以不必深究,如果对推导过程感兴趣,可以参考论文。

示例一、叛徒人数为 1,将军人数为 3

img

这个示例中,将军人数不满足 3m + 1,无法保证忠诚的副官都执行将军的命令。

示例二、叛徒人数为 1,将军人数为 4

img

这个示例中,将军人数满足 3m + 1,无论是副官中有叛徒,还是将军是叛徒,都能保证忠诚的副官执行将军的命令。

CAP 理论

CAP 是指:在一个分布式系统中, 一致性、可用性和分区容忍性,最多只能同时满足其中两项。

  • 一致性(C:Consistency):多个数据副本是否能保持一致
  • 可用性(A:Availability):分布式系统在面对各种异常时可以提供正常服务的能力
  • 分区容忍性(P:Partition Tolerance):分布式系统在遇到任何网络分区故障的时候,仍然需要能对外提供一致性和可用性的服务,除非是整个网络环境都发生了故障

CAP 权衡

在分布式系统中,分区容忍性必不可少,因为需要总是假设网络是不可靠的;CAP 理论实际在是要在可用性和一致性之间做权衡。

  • CP:需要让所有节点下线成为不可用的状态,等待同步完成。
  • AP:在同步过程中允许读取所有节点的数据,但是数据可能不一致。

ACID 理论

ACID 特性:

  • 原子性(Atomicity)
    • 事务被视为不可分割的最小单元,事务中的所有操作要么全部提交成功,要么全部失败回滚。
    • 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
  • 一致性(Consistency)
    • 数据库在事务执行前后都保持一致性状态。
    • 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
  • 隔离性(Isolation)
    • 一个事务所做的修改在最终提交以前,对其它事务是不可见的。
  • 持久性(Durability)
    • 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
    • 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。

img

在分布式系统中实现 ACID 比单机复杂的多。

在分布式系统中实现 ACID,即实现分布式事务,具体的方案有如下几种:

  • 两阶段提交(2PC)
  • 三阶段提交(3PC)
  • 补偿事务(TCC)
  • 本地消息表(异步确保)
  • MQ 事务消息
  • Sagas 事务模型

BASE 理论

BASE 理论是对 CAP 中一致性和可用性权衡的结果。

BASE 是指:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。

BASE 特性

  • 基本可用(Basically Available):指分布式系统在出现故障的时候,保证核心可用,允许损失部分可用性。
  • 软状态(Soft State):指允许系统中的数据存在中间状态,并认为该中间状态不会影响系统整体可用性,即允许系统不同节点的数据副本之间进行同步的过程存在延时。
  • 最终一致性(Eventually Consistent):最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能达到一致的状态。

img

Paxos 算法

Paxos 是 Leslie Lamport 于 1990 年提出的一种基于消息传递且具有高度容错特性的共识(consensus)算法。

Paxos 算法包含 2 个部分:

  • Basic Paxos 算法:描述的多节点之间如何就某个值达成共识。
  • Multi Paxos 思想:描述的是执行多个 Basic Paxos 实例,就一系列值达成共识。

Paxos 算法解决的问题正是分布式共识性问题,即一个分布式系统中的各个进程如何就某个值(决议)达成一致。

Paxos 算法运行在允许宕机故障的异步系统中,不要求可靠的消息传递,可容忍消息丢失、延迟、乱序以及重复。它利用大多数 (Majority) 机制保证了 2N+1 的容错能力,即 2N+1 个节点的系统最多允许 N 个节点同时出现故障。

Basic Paxos 算法

角色

img

  • 提议者(Proposer):发出提案(Proposal),用于投票表决。Proposal 信息包括提案编号 (Proposal ID) 和提议的值 (Value)。在绝大多数场景中,集群中收到客户端请求的节点,才是提议者。这样做的好处是,对业务代码没有入侵性,也就是说,我们不需要在业务代码中实现算法逻辑。
  • 决策者(Acceptor):对每个 Proposal 进行投票,若 Proposal 获得多数 Acceptor 的接受,则称该 Proposal 被批准。一般来说,集群中的所有节点都在扮演决策者的角色,参与共识协商,并接受和存储数据。
  • 学习者(Learner):不参与决策,从 Proposers/Acceptors 学习、记录最新达成共识的提案(Value)。一般来说,学习者是数据备份节点,比如主从架构中的从节点,被动地接受数据,容灾备份。

在多副本状态机中,每个副本都同时具有 Proposer、Acceptor、Learner 三种角色。

这三种角色,在本质上代表的是三种功能:

  • 提议者代表的是接入和协调功能,收到客户端请求后,发起二阶段提交,进行共识协商;
  • 接受者代表投票协商和存储数据,对提议的值进行投票,并接受达成共识的值,存储保存;
  • 学习者代表存储数据,不参与共识协商,只接受达成共识的值,存储保存。

算法

Paxos 算法通过一个决议分为两个阶段(Learn 阶段之前决议已经形成):

  1. Prepare 阶段:Proposer 向 Acceptors 发出 Prepare 请求,Acceptors 针对收到的 Prepare 请求进行 Promise 承诺。
  2. Accept 阶段:Proposer 收到多数 Acceptors 承诺的 Promise 后,向 Acceptors 发出 Propose 请求,Acceptors 针对收到的 Propose 请求进行 Accept 处理。
  3. Learn 阶段:Proposer 在收到多数 Acceptors 的 Accept 之后,标志着本次 Accept 成功,决议形成,将形成的决议发送给所有 Learners。

Paxos 算法流程中的每条消息描述如下:

  • Prepare: Proposer 生成全局唯一且递增的 Proposal ID (可使用时间戳加 Server ID),向所有 Acceptors 发送 Prepare 请求,这里无需携带提案内容,只携带 Proposal ID 即可。

  • Promise: Acceptors 收到 Prepare 请求后,做出“两个承诺,一个应答”。

    • 两个承诺:

      • 不再接受 Proposal ID 小于等于当前请求的 Prepare 请求。
      • 不再接受 Proposal ID 小于当前请求的 Propose 请求。
    • 一个应答:

      • 不违背以前作出的承诺下,回复已经 Accept 过的提案中 Proposal ID 最大的那个提案的 Value 和 Proposal ID,没有则返回空值。
  • Propose: Proposer 收到多数 Acceptors 的 Promise 应答后,从应答中选择 Proposal ID 最大的提案的 Value,作为本次要发起的提案。如果所有应答的提案 Value 均为空值,则可以自己随意决定提案 Value。然后携带当前 Proposal ID,向所有 Acceptors 发送 Propose 请求。

  • Accept: Acceptor 收到 Propose 请求后,在不违背自己之前作出的承诺下,接受并持久化当前 Proposal ID 和提案 Value。

  • Learn: Proposer 收到多数 Acceptors 的 Accept 后,决议形成,将形成的决议发送给所有 Learners。

Multi Paxos 思想

Basic Paxos 的问题

Basic Paxos 有以下问题,导致它不能应用于实际:

  • Basic Paxos 算法只能对一个值形成决议
  • Basic Paxos 算法会消耗大量网络带宽。Basic Paxos 中,决议的形成至少需要两次网络通信,在高并发情况下可能需要更多的网络通信,极端情况下甚至可能形成活锁。如果想连续确定多个值,Basic Paxos 搞不定了。

Multi Paxos 的改进

Multi Paxos 正是为解决以上问题而提出。Multi Paxos 基于 Basic Paxos 做了两点改进:

  • 针对每一个要确定的值,运行一次 Paxos 算法实例(Instance),形成决议。每一个 Paxos 实例使用唯一的 Instance ID 标识。
  • 在所有 Proposer 中选举一个 Leader,由 Leader 唯一地提交 Proposal 给 Acceptor 进行表决。这样没有 Proposer 竞争,解决了活锁问题。在系统中仅有一个 Leader 进行 Value 提交的情况下,Prepare 阶段就可以跳过,从而将两阶段变为一阶段,提高效率。

Multi Paxos 首先需要选举 Leader,Leader 的确定也是一次决议的形成,所以可执行一次 Basic Paxos 实例来选举出一个 Leader。选出 Leader 之后只能由 Leader 提交 Proposal,在 Leader 宕机之后服务临时不可用,需要重新选举 Leader 继续服务。在系统中仅有一个 Leader 进行 Proposal 提交的情况下,Prepare 阶段可以跳过。

Multi Paxos 通过改变 Prepare 阶段的作用范围至后面 Leader 提交的所有实例,从而使得 Leader 的连续提交只需要执行一次 Prepare 阶段,后续只需要执行 Accept 阶段,将两阶段变为一阶段,提高了效率。为了区分连续提交的多个实例,每个实例使用一个 Instance ID 标识,Instance ID 由 Leader 本地递增生成即可。

Multi Paxos 允许有多个自认为是 Leader 的节点并发提交 Proposal 而不影响其安全性,这样的场景即退化为 Basic Paxos。

Chubby 和 Boxwood 均使用 Multi Paxos。ZooKeeper 使用的 Zab 也是 Multi Paxos 的变形。

Raft 算法

Raft 基础

Raft 将一致性问题分解成了三个子问题:

  • 选举 Leader
  • 日志复制
  • 安全性

服务器角色

在 Raft 中,任何时刻,每个服务器都处于这三个角色之一 :

  • Leader - 领导者,通常一个系统中是一主(Leader)多从(Follower)。Leader 负责处理所有的客户端请求
  • Follower - 跟随者,不会发送任何请求,只是简单的 响应来自 Leader 或者 Candidate 的请求
  • Candidate - 参选者,选举新 Leader 时的临时角色。

img

:bulb: 图示说明:

  • Follower 只响应来自其他服务器的请求。在一定时限内,如果 Follower 接收不到消息,就会转变成 Candidate,并发起选举。
  • Candidate 向 Follower 发起投票请求,如果获得集群中半数以上的选票,就会转变为 Leader。
  • 在一个 Term 内,Leader 始终保持不变,直到下线了。Leader 需要周期性向所有 Follower 发送心跳消息,以阻止 Follower 转变为 Candidate。

任期

img

Raft 把时间分割成任意长度的 任期(Term),任期用连续的整数标记。每一段任期从一次选举开始。Raft 保证了在一个给定的任期内,最多只有一个领导者

  • 如果选举成功,Leader 会管理整个集群直到任期结束。
  • 如果选举失败,那么这个任期就会因为没有 Leader 而结束。

不同服务器节点观察到的任期转换状态可能不一样

  • 服务器节点可能观察到多次的任期转换。
  • 服务器节点也可能观察不到任何一次任期转换。

任期在 Raft 算法中充当逻辑时钟的作用,使得服务器节点可以查明一些过期的信息(比如过期的 Leader)。每个服务器节点都会存储一个当前任期号,这一编号在整个时期内单调的增长。当服务器之间通信的时候会交换当前任期号。

  • 如果一个服务器的当前任期号比其他人小,那么他会更新自己的编号到较大的编号值。
  • 如果一个 Candidate 或者 Leader 发现自己的任期号过期了,那么他会立即恢复成跟随者状态。
  • 如果一个节点接收到一个包含过期的任期号的请求,那么他会直接拒绝这个请求。

RPC

Raft 算法中服务器节点之间的通信使用 **_远程过程调用(RPC)_**。

基本的一致性算法只需要两种 RPC:

  • RequestVote RPC - 请求投票 RPC,由 Candidate 在选举期间发起。
  • AppendEntries RPC - 附加条目 RPC,由 Leader 发起,用来复制日志和提供一种心跳机制。

选举 Leader

选举规则

Raft 使用一种心跳机制来触发 Leader 选举

Leader 需要周期性的向所有 Follower 发送心跳消息,以此维持自己的权威并阻止新 Leader 的产生。

每个 Follower 都设置了一个随机的竞选超时时间,一般为 150ms ~ 300ms,如果在竞选超时时间内没有收到 Leader 的心跳消息,就会认为当前 Term 没有可用的 Leader,并发起选举来选出新的 Leader。开始一次选举过程,Follower 先要增加自己的当前 Term 号,并转换为 Candidate

Candidate 会并行的向集群中的所有服务器节点发送投票请求(RequestVote RPC,它会保持当前状态直到以下三件事情之一发生:

  • 自己成为 Leader
  • 其他的服务器成为 Leader
  • 没有任何服务器成为 Leader
自己成为 Leader
  • 当一个 Candidate 从整个集群半数以上的服务器节点获得了针对同一个 Term 的选票,那么它就赢得了这次选举并成为 Leader。每个服务器最多会对一个 Term 投出一张选票,按照先来先服务(FIFO)的原则。_要求半数以上选票的规则确保了最多只会有一个 Candidate 赢得此次选举_。
  • 一旦 Candidate 赢得选举,就立即成为 Leader。然后它会向其他的服务器发送心跳消息来建立自己的权威并且阻止新的领导人的产生。
其他的服务器成为 Leader

等待投票期间,Candidate 可能会从其他的服务器接收到声明它是 Leader 的 AppendEntries RPC

  • 如果这个 Leader 的 Term 号(包含在此次的 RPC 中)不小于 Candidate 当前的 Term,那么 Candidate 会承认 Leader 合法并回到 Follower 状态。
  • 如果此次 RPC 中的 Term 号比自己小,那么 Candidate 就会拒绝这个消息并继续保持 Candidate 状态。
没有任何服务器成为 Leader

如果有多个 Follower 同时成为 Candidate,那么选票可能会被瓜分以至于没有 Candidate 可以赢得半数以上的投票。当这种情况发生的时候,每一个 Candidate 都会竞选超时,然后通过增加当前 Term 号来开始一轮新的选举。然而,没有其他机制的话,选票可能会被无限的重复瓜分。

Raft 算法使用随机选举超时时间的方法来确保很少会发生选票瓜分的情况,就算发生也能很快的解决。为了阻止选票起初就被瓜分,竞选超时时间是一个随机的时间,在一个固定的区间(例如 150-300 毫秒)随机选择,这样可以把选举都分散开。

  • 以至于在大多数情况下,只有一个服务器会超时,然后它赢得选举,成为 Leader,并在其他服务器超时之前发送心跳包。
  • 同样的机制也被用在选票瓜分的情况下:每一个 Candidate 在开始一次选举的时候会重置一个随机的选举超时时间,然后在超时时间内等待投票的结果;这样减少了在新的选举中另外的选票瓜分的可能性。

理解了上面的选举规则后,我们通过动图来加深认识。

日志复制

日志格式

日志由含日志索引(log index)的日志条目(log entry)组成。每个日志条目包含它被创建时的 Term 号(下图中方框中的数字),和一个复制状态机需要执行的指令。如果一个日志条目被复制到半数以上的服务器上,就被认为可以提交(Commit)了。

  • 日志条目中的 Term 号被用来检查是否出现不一致的情况。
  • 日志条目中的日志索引(一个整数值)用来表明它在日志中的位置。

img

Raft 日志同步保证如下两点:

  • 如果不同日志中的两个日志条目有着相同的日志索引和 Term,则它们所存储的命令是相同的
    • 这个特性基于这条原则:Leader 最多在一个 Term 内、在指定的一个日志索引上创建一条日志条目,同时日志条目在日志中的位置也从来不会改变。
  • 如果不同日志中的两个日志条目有着相同的日志索引和 Term,则它们之前的所有条目都是完全一样的
    • 这个特性由 AppendEntries RPC 的一个简单的一致性检查所保证。在发送 AppendEntries RPC 时,Leader 会把新日志条目之前的日志条目的日志索引和 Term 号一起发送。如果 Follower 在它的日志中找不到包含相同日志索引和 Term 号的日志条目,它就会拒绝接收新的日志条目。

日志复制流程

img

  1. Leader 负责处理所有客户端的请求。
  2. Leader 把请求作为日志条目加入到它的日志中,然后并行的向其他服务器发送 AppendEntries RPC 请求,要求 Follower 复制日志条目。
  3. Follower 复制成功后,返回确认消息。
  4. 当这个日志条目被半数以上的服务器复制后,Leader 提交这个日志条目到它的复制状态机,并向客户端返回执行结果。

注意:如果 Follower 崩溃或者运行缓慢,再或者网络丢包,Leader 会不断的重复尝试发送 AppendEntries RPC 请求 (尽管已经回复了客户端),直到所有的跟随者都最终复制了所有的日志条目。

日志一致性

一般情况下,Leader 和 Followers 的日志保持一致,因此日志条目一致性检查通常不会失败。然而,Leader 崩溃可能会导致日志不一致:旧的 Leader 可能没有完全复制完日志中的所有条目。

Leader 和 Follower 日志不一致的可能

Leader 和 Follower 可能存在多种日志不一致的可能。

img

:bulb: 图示说明:

上图阐述了 Leader 和 Follower 可能存在多种日志不一致的可能,每一个方框表示一个日志条目,里面的数字表示任期号 。

当一个 Leader 成功当选时,Follower 可能出现以下情况(a-f):

  • 存在未更新日志条目,如(a、b)。
  • 存在未提交日志条目,如(c、d)。
  • 两种情况都存在,如(e、f)。

_例如,场景 f 可能会这样发生,某服务器在 Term2 的时候是 Leader,已附加了一些日志条目到自己的日志中,但在提交之前就崩溃了;很快这个机器就被重启了,在 Term3 重新被选为 Leader,并且又增加了一些日志条目到自己的日志中;在 Term 2 和 Term 3 的日志被提交之前,这个服务器又宕机了,并且在接下来的几个任期里一直处于宕机状态_。

Leader 和 Follower 日志一致的保证

Leader 通过强制 Followers 复制它的日志来处理日志的不一致,Followers 上的不一致的日志会被 Leader 的日志覆盖

  • Leader 为了使 Followers 的日志同自己的一致,Leader 需要找到 Followers 同它的日志一致的地方,然后覆盖 Followers 在该位置之后的条目。
  • Leader 会从后往前试,每次日志条目失败后尝试前一个日志条目,直到成功找到每个 Follower 的日志一致位点,然后向后逐条覆盖 Followers 在该位置之后的条目。

安全性

前面描述了 Raft 算法是如何选举 Leader 和复制日志的。

Raft 还增加了一些限制来完善 Raft 算法,以保证安全性:保证了任意 Leader 对于给定的 Term,都拥有了之前 Term 的所有被提交的日志条目。

选举限制

拥有最新的已提交的日志条目的 Follower 才有资格成为 Leader。

Raft 使用投票的方式来阻止一个 Candidate 赢得选举除非这个 Candidate 包含了所有已经提交的日志条目。 Candidate 为了赢得选举必须联系集群中的大部分节点,这意味着每一个已经提交的日志条目在这些服务器节点中肯定存在于至少一个节点上。如果 Candidate 的日志至少和大多数的服务器节点一样新(这个新的定义会在下面讨论),那么他一定持有了所有已经提交的日志条目。

RequestVote RPC 实现了这样的限制:RequestVote RPC 中包含了 Candidate 的日志信息, Follower 会拒绝掉那些日志没有自己新的投票请求

如何判断哪个日志条目比较新?

Raft 通过比较两份日志中最后一条日志条目的日志索引和 Term 来判断哪个日志比较新。

  • 先判断 Term,哪个数值大即代表哪个日志比较新。
  • 如果 Term 相同,再比较 日志索引,哪个数值大即代表哪个日志比较新。

提交旧任期的日志条目

一个当前 Term 的日志条目被复制到了半数以上的服务器上,Leader 就认为它是可以被提交的。如果这个 Leader 在提交日志条目前就下线了,后续的 Leader 可能会覆盖掉这个日志条目。

img

💡 图示说明:

上图解释了为什么 Leader 无法对旧 Term 的日志条目进行提交。

  • 阶段 (a) ,S1 是 Leader,且 S1 写入日志条目为 (Term 2,日志索引 2),只有 S2 复制了这个日志条目。
  • 阶段 (b),S1 下线,S5 被选举为 Term3 的 Leader。S5 写入日志条目为 (Term 3,日志索引 2)。
  • 阶段 (c),S5 下线,S1 重新上线,并被选举为 Term4 的 Leader。此时,Term 2 的那条日志条目已经被复制到了集群中的大多数节点上,但是还没有被提交。
  • 阶段 (d),S1 再次下线,S5 重新上线,并被重新选举为 Term3 的 Leader。然后 S5 覆盖了日志索引 2 处的日志。
  • 阶段 (e),如果阶段 (d) 还未发生,即 S1 再次下线之前,S1 把自己主导的日志条目复制到了大多数节点上,那么在后续 Term 里面这些新日志条目就会被提交。这样在同一时刻就同时保证了,之前的所有旧日志条目就会被提交。

Raft 永远不会通过计算副本数目的方式去提交一个之前 Term 内的日志条目。只有 Leader 当前 Term 里的日志条目通过计算副本数目可以被提交;一旦当前 Term 的日志条目以这种方式被提交,那么由于日志匹配特性,之前的日志条目也都会被间接的提交。

当 Leader 复制之前任期里的日志时,Raft 会为所有日志保留原始的 Term,这在提交规则上产生了额外的复杂性。在其他的一致性算法中,如果一个新的领导人要重新复制之前的任期里的日志时,它必须使用当前新的任期号。Raft 使用的方法更加容易辨别出日志,因为它可以随着时间和日志的变化对日志维护着同一个任期编号。另外,和其他的算法相比,Raft 中的新领导人只需要发送更少日志条目(其他算法中必须在他们被提交之前发送更多的冗余日志条目来为他们重新编号)。

日志压缩

在实际的系统中,不能让日志无限膨胀,否则系统重启时需要花很长的时间进行恢复,从而影响可用性。Raft 采用对整个系统进行快照来解决,快照之前的日志都可以丢弃。

每个副本独立的对自己的系统状态生成快照,并且只能对已经提交的日志条目生成快照。

快照包含以下内容:

  • 日志元数据。最后一条已提交的日志条目的日志索引和 Term。这两个值在快照之后的第一条日志条目的 AppendEntries RPC 的完整性检查的时候会被用上。
  • 系统当前状态。

当 Leader 要发送某个日志条目,落后太多的 Follower 的日志条目会被丢弃,Leader 会将快照发给 Follower。或者新上线一台机器时,也会发送快照给它。

img

生成快照的频率要适中,频率过高会消耗大量 I/O 带宽;频率过低,一旦需要执行恢复操作,会丢失大量数据,影响可用性。推荐当日志达到某个固定的大小时生成快照。

生成一次快照可能耗时过长,影响正常日志同步。可以通过使用 copy-on-write 技术避免快照过程影响正常日志同步。

说明:本文仅阐述 Raft 算法的核心内容,不包括算法论证、评估等

一致性哈希算法

一致性哈希(Consistent Hash)算法的目标是:相同的请求尽可能落到同一个服务器上

一致性哈希 可以很好的解决 稳定性问题,可以将所有的 存储节点 排列在 首尾相接Hash 环上,每个 key 在计算 Hash 后会 顺时针 找到 临接存储节点 存放。而当有节点 加入退出 时,仅影响该节点在 Hash 环上 顺时针相邻后续节点

img

  • 相同的请求是指:一般在使用一致性哈希时,需要指定一个 key 用于 hash 计算,可能是:
    • 用户 ID
    • 请求方 IP
    • 请求服务名称,参数列表构成的串
  • 尽可能是指:服务器可能发生上下线,少数服务器的变化不应该影响大多数的请求。

当某台候选服务器宕机时,原本发往该服务器的请求,会基于虚拟节点,平摊到其它候选服务器,不会引起剧烈变动。

  • 优点

加入删除 节点只影响 哈希环顺时针方向相邻的节点,对其他节点无影响。

  • 缺点

加减节点 会造成 哈希环 中部分数据 无法命中。当使用 少量节点 时,节点变化 将大范围影响 哈希环数据映射,不适合 少量数据节点 的分布式方案。普通一致性哈希分区 在增减节点时需要 增加一倍减去一半 节点才能保证 数据负载的均衡

注意:因为 一致性哈希分区 的这些缺点,一些分布式系统采用 虚拟槽一致性哈希 进行改进,比如 Dynamo 系统。

Gossip 协议

Gossip 协议是集群中节点相互通信的内部通信技术。 Gossip 是一种高效、轻量级、可靠的节点间广播协议,用于传播数据。它是去中心化的、“流行病”的、容错的和点对点通信协议。

Goosip 协议的信息传播和扩散通常需要由种子节点发起。整个传播过程可能需要一定的时间,由于不能保证某个时刻所有节点都收到消息,但是理论上最终所有节点都会收到消息,因此它是一个最终一致性协议。

Gossip 的执行过程

Gossip 协议的执行过程:Gossip 过程是由种子节点发起,当一个种子节点有状态需要更新到网络中的其他节点时,它会随机的选择周围几个节点散播消息,收到消息的节点也会重复该过程,直至最终网络中所有的节点都收到了消息。这个过程可能需要一定的时间,由于不能保证某个时刻所有节点都收到消息,但是理论上最终所有节点都会收到消息,因此它是一个最终一致性协议。

Gossip 类型

Gossip 有两种类型:

  • **Anti-Entropy(反熵)**:以固定的概率传播所有的数据。Anti-Entropy 是 SI model,节点只有两种状态,Suspective 和 Infective,叫做 simple epidemics。
  • **Rumor-Mongering(谣言传播)**:仅传播新到达的数据。Rumor-Mongering 是 SIR model,节点有三种状态,Suspective,Infective 和 Removed,叫做 complex epidemics。

熵是物理学上的一个概念,代表杂乱无章,而反熵就是在杂乱无章中寻求一致。本质上,反熵是一种通过异步修复实现最终一致性的方法。反熵指的是集群中的节点,每隔段时间就随机选择某个其他节点,然后通过互相交换自己的所有数据来消除两者之间的差异,实现数据的最终一致性。由于消息会不断反复的交换,因此消息数量是非常庞大的,无限制的(unbounded),这对一个系统来说是一个巨大的开销。所以,反熵不适合动态变化或节点数比较多的分布式环境

谣言传播模型指的是当一个节点有了新数据后,这个节点变成活跃状态,并周期性地联系其他节点向其发送新数据,直到所有的节点都存储了该新数据。在谣言传播模型下,消息可以发送得更频繁,因为消息只包含最新 update,体积更小。而且,一个谣言消息在某个时间点之后会被标记为 removed,并且不再被传播,因此,谣言传播模型下,系统有一定的概率会不一致。而由于,谣言传播模型下某个时间点之后消息不再传播,因此消息是有限的,系统开销小。

一般来说,为了在通信代价和可靠性之间取得折中,需要将这两种方法结合使用。

Gossip 中的通信模式

在 Gossip 协议下,网络中两个节点之间有三种通信方式:

  • Push: 节点 A 将数据 (key,value,version) 及对应的版本号推送给 B 节点,B 节点更新 A 中比自己新的数据
  • Pull:A 仅将数据 key, version 推送给 B,B 将本地比 A 新的数据(Key, value, version)推送给 A,A 更新本地
  • Push/Pull:与 Pull 类似,只是多了一步,A 再将本地比 B 新的数据推送给 B,B 则更新本地

如果把两个节点数据同步一次定义为一个周期,则在一个周期内,Push 需通信 1 次,Pull 需 2 次,Push/Pull 则需 3 次。虽然消息数增加了,但从效果上来讲,Push/Pull 最好,理论上一个周期内可以使两个节点完全一致。直观上,Push/Pull 的收敛速度也是最快的。

Gossip 的优点

  • 扩展性:网络可以允许节点的任意增加和减少,新增加的节点的状态最终会与其他节点一致。
  • 容错:网络中任何节点的宕机和重启都不会影响 Gossip 消息的传播,Gossip 协议具有天然的分布式系统容错特性。
  • 去中心化:Gossip 协议不要求任何中心节点,所有节点都可以是对等的,任何一个节点无需知道整个网络状况,只要网络是连通的,任意一个节点就可以把消息散播到全网。
  • 一致性收敛:Gossip 协议中的消息会以一传十、十传百一样的指数级速度在网络中快速传播,因此系统状态的不一致可以在很快的时间内收敛到一致。消息传播速度达到了 logN。
  • 简单:Gossip 协议的过程极其简单,实现起来几乎没有太多复杂性。

Gossip 的缺陷

分布式网络中,没有一种完美的解决方案,Gossip 协议跟其他协议一样,也有一些不可避免的缺陷,主要是两个:

  • 消息的延迟:由于 Gossip 协议中,节点只会随机向少数几个节点发送消息,消息最终是通过多个轮次的散播而到达全网的,因此使用 Gossip 协议会造成不可避免的消息延迟。不适合用在对实时性要求较高的场景下。
  • 消息冗余:Gossip 协议规定,节点会定期随机选择周围节点发送消息,而收到消息的节点也会重复该步骤,因此就不可避免的存在消息重复发送给同一节点的情况,造成了消息的冗余,同时也增加了收到消息的节点的处理压力。而且,由于是定期发送,因此,即使收到了消息的节点还会反复收到重复消息,加重了消息的冗余。

QuorumNWR 算法

通过 Quorum NWR,你可以自定义一致性级别,通过临时调整写入或者查询的方式,当 W + R > N 时,就可以实现强一致性了。

Quorum NWR 的三要素

  • **N**:表示副本数,又叫做复制因子(Replication Factor)。也就是说,N 表示集群中同一份数据有多少个副本。在实现 Quorum NWR 的时候,你需要实现自定义副本的功能。也就是说,用户可以自定义指定数据的副本数。
  • **W**:又称写一致性级别(Write Consistency Level),表示成功完成 W 个副本更新,才完成写操作
  • **R**:又称读一致性级别(Read Consistency Level),表示读取一个数据对象时需要读 R 个副本。你可以这么理解,读取指定数据时,要读 R 副本,然后返回 R 个副本中最新的那份数据。

N、W、R 值的不同组合,会产生不同的一致性效果:

  • W + R > N 的时候,对于客户端来讲,整个系统能保证强一致性,一定能返回更新后的那份数据。
  • W + R < N 的时候,对于客户端来讲,整个系统只能保证最终一致性,可能会返回旧数据。

需要注意的是,副本数不能超过节点数:多副本的意义在于冗余备份,如果副本数超过节点数,就意味着在一个节点上会存在多个副本,那么冗余备份的意义就不大了。

PBFT 算法

PoW 算法

ZAB 协议

ZooKeeper 并没有直接采用 Paxos 算法,而是采用了名为 ZAB 的一致性协议。**ZAB 协议不是 Paxos 算法**,只是比较类似,二者在操作上并不相同。

ZAB 协议是 Zookeeper 专门设计的一种支持崩溃恢复的原子广播协议

ZAB 协议是 ZooKeeper 的数据一致性和高可用解决方案。

ZAB 协议定义了两个可以无限循环的流程:

  • 选举 Leader - 用于故障恢复,从而保证高可用。
  • 原子广播 - 用于主从同步,从而保证数据一致性。

选举 Leader

ZooKeeper 的故障恢复

ZooKeeper 集群采用一主(称为 Leader)多从(称为 Follower)模式,主从节点通过副本机制保证数据一致。

  • 如果 Follower 节点挂了 - ZooKeeper 集群中的每个节点都会单独在内存中维护自身的状态,并且各节点之间都保持着通讯,只要集群中有半数机器能够正常工作,那么整个集群就可以正常提供服务
  • 如果 Leader 节点挂了 - 如果 Leader 节点挂了,系统就不能正常工作了。此时,需要通过 ZAB 协议的选举 Leader 机制来进行故障恢复。

ZAB 协议的选举 Leader 机制简单来说,就是:基于过半选举机制产生新的 Leader,之后其他机器将从新的 Leader 上同步状态,当有过半机器完成状态同步后,就退出选举 Leader 模式,进入原子广播模式。

术语

  • myid - 每个 Zookeeper 服务器,都需要在数据文件夹下创建一个名为 myid 的文件,该文件包含整个 Zookeeper 集群唯一的 ID(整数)。
  • zxid - 类似于 RDBMS 中的事务 ID,用于标识一次更新操作的 Proposal ID。为了保证顺序性,该 zkid 必须单调递增。因此 Zookeeper 使用一个 64 位的数来表示,高 32 位是 Leader 的 epoch,从 1 开始,每次选出新的 Leader,epoch 加一。低 32 位为该 epoch 内的序号,每次 epoch 变化,都将低 32 位的序号重置。这样保证了 zkid 的全局递增性。

服务器状态

  • LOOKING - 不确定 Leader 状态。该状态下的服务器认为当前集群中没有 Leader,会发起 Leader 选举
  • FOLLOWING - 跟随者状态。表明当前服务器角色是 Follower,并且它知道 Leader 是谁
  • LEADING - 领导者状态。表明当前服务器角色是 Leader,它会维护与 Follower 间的心跳
  • OBSERVING - 观察者状态。表明当前服务器角色是 Observer,与 Folower 唯一的不同在于不参与选举,也不参与集群写操作时的投票

选票数据结构

每个服务器在进行领导选举时,会发送如下关键信息

  • logicClock - 每个服务器会维护一个自增的整数,名为 logicClock,它表示这是该服务器发起的第多少轮投票
  • state - 当前服务器的状态
  • self_id - 当前服务器的 myid
  • self_zxid - 当前服务器上所保存的数据的最大 zxid
  • vote_id - 被推举的服务器的 myid
  • vote_zxid - 被推举的服务器上所保存的数据的最大 zxid

投票流程

(1)自增选举轮次 - Zookeeper 规定所有有效的投票都必须在同一轮次中。每个服务器在开始新一轮投票时,会先对自己维护的 logicClock 进行自增操作。

(2)初始化选票 - 每个服务器在广播自己的选票前,会将自己的投票箱清空。该投票箱记录了所收到的选票。例:服务器 2 投票给服务器 3,服务器 3 投票给服务器 1,则服务器 1 的投票箱为(2, 3), (3, 1), (1, 1)。票箱中只会记录每一投票者的最后一票,如投票者更新自己的选票,则其它服务器收到该新选票后会在自己票箱中更新该服务器的选票。

(3)发送初始化选票 - 每个服务器最开始都是通过广播把票投给自己。

(4)接收外部投票 - 服务器会尝试从其它服务器获取投票,并记入自己的投票箱内。如果无法获取任何外部投票,则会确认自己是否与集群中其它服务器保持着有效连接。如果是,则再次发送自己的投票;如果否,则马上与之建立连接。

(5)判断选举轮次 - 收到外部投票后,首先会根据投票信息中所包含的 logicClock 来进行不同处理

  • 外部投票的 logicClock 大于自己的 logicClock。说明该服务器的选举轮次落后于其它服务器的选举轮次,立即清空自己的投票箱并将自己的 logicClock 更新为收到的 logicClock,然后再对比自己之前的投票与收到的投票以确定是否需要变更自己的投票,最终再次将自己的投票广播出去。
  • 外部投票的 logicClock 小于自己的 logicClock。当前服务器直接忽略该投票,继续处理下一个投票。
  • 外部投票的 logickClock 与自己的相等。当时进行选票 PK。

(6)选票 PK - 选票 PK 是基于(self_id, self_zxid)(vote_id, vote_zxid) 的对比

  • 外部投票的 logicClock 大于自己的 logicClock,则将自己的 logicClock 及自己的选票的 logicClock 变更为收到的 logicClock
  • 若 logicClock 一致,则对比二者的 vote_zxid,若外部投票的 vote_zxid 比较大,则将自己的票中的 vote_zxid 与 vote_myid 更新为收到的票中的 vote_zxid 与 vote_myid 并广播出去,另外将收到的票及自己更新后的票放入自己的票箱。如果票箱内已存在(self_myid, self_zxid)相同的选票,则直接覆盖
  • 若二者 vote_zxid 一致,则比较二者的 vote_myid,若外部投票的 vote_myid 比较大,则将自己的票中的 vote_myid 更新为收到的票中的 vote_myid 并广播出去,另外将收到的票及自己更新后的票放入自己的票箱

(7)统计选票 - 如果已经确定有过半服务器认可了自己的投票(可能是更新后的投票),则终止投票。否则继续接收其它服务器的投票。

(8)更新服务器状态 - 投票终止后,服务器开始更新自身状态。若过半的票投给了自己,则将自己的服务器状态更新为 LEADING,否则将自己的状态更新为 FOLLOWING

通过以上流程分析,我们不难看出:要使 Leader 获得多数 Server 的支持,则 ZooKeeper 集群节点数必须是奇数。且存活的节点数目不得少于 N + 1

每个 Server 启动后都会重复以上流程。在恢复模式下,如果是刚从崩溃状态恢复的或者刚启动的 server 还会从磁盘快照中恢复数据和会话信息,zk 会记录事务日志并定期进行快照,方便在恢复时进行状态恢复。

原子广播(Atomic Broadcast)

ZooKeeper 通过副本机制来实现高可用

那么,ZooKeeper 是如何实现副本机制的呢?答案是:ZAB 协议的原子广播。

img

ZAB 协议的原子广播要求:

**所有的写请求都会被转发给 Leader,Leader 会以原子广播的方式通知 Follow。当半数以上的 Follow 已经更新状态持久化后,Leader 才会提交这个更新,然后客户端才会收到一个更新成功的响应**。这有些类似数据库中的两阶段提交协议。

在整个消息的广播过程中,Leader 服务器会每个事务请求生成对应的 Proposal,并为其分配一个全局唯一的递增的事务 ID(ZXID),之后再对其进行广播。

InfluxDB 企业版一致性实现剖析

Hashicorp Raft

基于 Raft 的分布式 KV 系统开发实战

参考资料