Dunwu Blog

大道至简,知易行难

《MySQL 实战 45 讲》笔记二

极客时间教程 - MySQL 实战 45 讲 学习笔记

16 order by 是怎么工作的?

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

全字段排序

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

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

执行流程

  • 初始化 sort_buffer,确定放入需要排序的字段(如 namecityage)。
  • 从索引中找到满足条件的记录,取出对应的字段值存入 sort_buffer
  • sort_buffer 中的数据按照排序字段进行排序。
  • 返回排序后的结果。

内存与磁盘排序

  • 如果排序数据量小于 sort_buffer_size,排序在内存中完成。
  • 如果数据量过大,MySQL 会使用临时文件进行外部排序(归并排序)。MySQL 将需要排序的数据分成 N 份,每一份单独排序后存在这些临时文件中。然后把这 N 个有序文件再合并成一个有序的大文件。

优化器追踪:通过 OPTIMIZER_TRACE 可以查看排序过程中是否使用了临时文件(number_of_tmp_files)。

rowid 排序

  • 执行流程
    • 当单行数据过大时,MySQL 会采用 rowid 排序,只将排序字段(如 name)和主键 id 放入 sort_buffer
    • 排序完成后,根据 id 回表查询其他字段(如 cityage)。
  • 性能影响rowid 排序减少了 sort_buffer 的内存占用,但增加了回表操作,导致更多的磁盘 I/O。

全字段排序 VS rowid 排序

  • 内存优先
    • 如果内存足够大,MySQL 优先使用全字段排序,以减少磁盘访问。
    • 只有在内存不足时,才会使用 rowid 排序。
  • 设计思想如果内存够,就要多利用内存,尽量减少磁盘访问。

并不是所有的 order by 语句,都需要排序操作的。MySQL 之所以需要生成临时表,并且在临时表上做排序操作,其原因是原来的数据都是无序的。如果查询的字段和排序字段可以通过联合索引覆盖,MySQL 可以直接利用索引的有序性,避免排序操作。

17 如何正确地显示随机消息?

ORDER BY RAND() 的执行流程

  • 使用 ORDER BY RAND() 时,MySQL 会创建一个临时表,并为每一行生成一个随机数,然后对临时表进行排序。
  • 排序过程可能使用内存临时表或磁盘临时表,具体取决于数据量和 tmp_table_size 的设置。

ORDER BY RAND() 的性能问题ORDER BY RAND() 需要扫描全表并生成随机数,排序过程消耗大量资源,尤其是在数据量大时,性能较差。

内存临时表与磁盘临时表

内存临时表:当临时表大小小于 tmp_table_size 时,MySQL 使用内存临时表,排序过程使用 rowid 排序算法。

磁盘临时表:当临时表大小超过 tmp_table_size 时,MySQL 会使用磁盘临时表,排序过程使用归并排序算法。

优先队列排序:当只需要返回少量数据(如 LIMIT 3)时,MySQL 5.6 引入了优先队列排序算法,避免对整个数据集进行排序,减少计算量。

随机排序的优化方法

  • 随机算法 1:通过 max(id)min(id) 生成随机数,然后使用 LIMIT 获取随机行。问题是:ID 不连续时,某些行的概率不均匀。

  • 随机算法 2:先获取表的总行数 C,然后生成随机数 Y,使用 LIMIT Y, 1 获取随机行。优点:解决了概率不均匀的问题,但需要扫描 C + Y + 1 行。

  • 随机算法 3:扩展随机算法 2,生成多个随机数 Y1, Y2, Y3,分别使用 LIMIT Y, 1 获取多行随机数据。优点:适用于需要返回多行随机数据的场景。

    总结

  • **避免使用 ORDER BY RAND()**:ORDER BY RAND() 的性能较差,尤其是在数据量大时,应尽量避免使用。

  • 应用层处理随机逻辑:将随机逻辑放在应用层处理,数据库只负责数据读取,减少数据库的计算压力。

  • 优化扫描行数:通过合理的随机算法,减少扫描行数,提升查询性能。

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

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

案例一:条件字段函数操作

  • 问题:在 WHERE 条件中对索引字段使用函数(如 month(t_modified)),会导致 MySQL 无法使用索引的快速定位功能,转而进行全索引扫描。
  • 原因:对索引字段进行函数操作会破坏索引值的有序性,优化器会放弃树搜索功能,转而进行全索引扫描。
  • 解决方案:避免在索引字段上使用函数操作,改为基于字段本身的范围查询。例如,将 month(t_modified)=7 改为 t_modified 的范围查询。

案例二:隐式类型转换

  • 问题:当查询条件中的字段类型与索引字段类型不一致时(如 varcharint),MySQL 会进行隐式类型转换,导致无法使用索引。
  • 原因:隐式类型转换相当于对索引字段进行了函数操作(如 CAST),优化器会放弃树搜索功能,转而进行全表扫描。
  • 解决方案:确保查询条件中的字段类型与索引字段类型一致,避免隐式类型转换。

案例三:隐式字符编码转换

  • 问题:当两个表的字符集不同时(如 utf8utf8mb4),在进行表连接查询时,MySQL 会对被驱动表的索引字段进行字符集转换,导致无法使用索引。
  • 原因:字符集转换相当于对索引字段进行了函数操作(如 CONVERT),优化器会放弃树搜索功能,转而进行全表扫描。
  • 解决方案
    • 统一字符集:将两个表的字符集统一为 utf8mb4,避免字符集转换。
    • 手动转换:在 SQL 语句中手动进行字符集转换,确保转换操作发生在驱动表上,而不是被驱动表的索引字段上。

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

查询长时间不返回的可能原因

  • 等 MDL 锁:当查询需要获取表的 MDL 读锁,而其他线程持有 MDL 写锁时,查询会被阻塞。
    • 解决方案:通过 sys.schema_table_lock_waits 表找到持有 MDL 写锁的线程,并 KILL 掉该线程。
  • 等 flush:当有线程正在对表执行 flush tables 操作时,其他查询会被阻塞。
    • 解决方案:找到阻塞 flush 操作的线程并 KILL 掉。
  • 等行锁:当查询需要获取某行的读锁,而其他事务持有该行的写锁时,查询会被阻塞。
    • 解决方案:通过 sys.innodb_lock_waits 表找到持有写锁的线程,并 KILL 掉该连接。

查询慢的可能原因

  • 全表扫描:当查询条件中的字段没有索引时,MySQL 会进行全表扫描,导致查询缓慢。
    • 解决方案:为查询条件中的字段添加索引。
  • 一致性读与当前读
    • 一致性读:当查询使用一致性读时,如果该行有大量 undo log(如被频繁更新),MySQL 需要依次执行这些 undo log 才能返回结果,导致查询缓慢。
    • 当前读:使用 lock in share modefor update 进行当前读时,MySQL 会直接读取最新的数据,因此速度较快。
    • 解决方案:理解一致性读和当前读的区别,根据业务需求选择合适的查询方式。

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

幻读的定义

  • 幻读指的是一个事务在前后两次查询同一个范围时,后一次查询看到了前一次查询没有看到的行。
  • 幻读仅在“当前读”(如select ... for update)时出现,普通的快照读不会出现幻读。

幻读的问题

  • 语义问题:事务 A 声明要锁住所有满足条件的行,但由于幻读的存在,其他事务可以插入或修改这些行,破坏了事务 A 的加锁声明。
  • 数据一致性问题:幻读可能导致数据和日志在逻辑上不一致,尤其是在使用 binlog 进行数据同步或恢复时,可能会导致数据不一致。

幻读的解决方案

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

  • 间隙锁(Gap Lock):为了解决幻读问题,InnoDB 引入了间隙锁。间隙锁锁住的是索引记录之间的间隙,防止新记录的插入。
  • Next-Key Lock:间隙锁和行锁合称 Next-Key Lock,它锁住的是一个前开后闭的区间,确保在锁定范围内无法插入新记录。

间隙锁的影响

  • 间隙锁虽然解决了幻读问题,但也带来了并发度下降和死锁的风险。特别是在高并发场景下,间隙锁可能会导致更多的锁冲突和死锁。

隔离级别的选择

  • 可重复读隔离级别下,间隙锁生效,可以有效防止幻读。
  • 读提交隔离级别下,间隙锁不生效,幻读问题可能会出现,但可以通过将 binlog 格式设置为row来解决数据一致性问题。

实际应用中的考虑

  • 业务开发人员在设计表结构和 SQL 语句时,不仅要考虑行锁,还要考虑间隙锁的影响,避免因间隙锁导致的死锁问题。
  • 隔离级别的选择应根据业务需求来决定,如果业务不需要可重复读的保证,读提交隔离级别可能是一个更合适的选择。

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

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

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

锁的范围与隔离级别

  • 可重复读隔离级别下,Next-Key Lock 和间隙锁生效,防止幻读。
  • 读提交隔离级别下,间隙锁不生效,锁的范围更小,锁的时间更短。

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

短连接风暴

  • 问题:短连接模式下,业务高峰期连接数暴涨,可能导致数据库连接数超过max_connections限制,进而拒绝新连接。
  • 解决方案
    • 方法一:主动断开空闲连接。优先断开事务外空闲的连接,再考虑断开事务内空闲的连接。可以通过kill connection命令手动断开连接。
    • 方法二:减少连接过程的消耗。通过--skip-grant-tables参数重启数据库,跳过权限验证,但这种方法风险极高,尤其是在外网可访问的情况下。
  • 风险:断开连接可能导致应用端未正确处理连接丢失,进而引发更多问题。

慢查询性能问题

  • 慢查询的三种可能原因
    1. 索引没有设计好:通过紧急创建索引来解决,建议在备库先执行alter table语句,再进行主备切换。
    2. SQL 语句没写好:通过改写 SQL 语句来优化,MySQL 5.7 提供了query_rewrite功能,可以自动重写 SQL 语句。
    3. MySQL 选错了索引:通过force index强制使用正确的索引。
  • 预防措施:在上线前,通过慢查询日志和回归测试,提前发现并解决潜在的慢查询问题。

QPS 突增问题

  • 问题:由于业务高峰或应用 bug,某个 SQL 语句的 QPS 突然暴涨,导致数据库压力过大。
  • 解决方案
    1. 下掉新功能:如果新功能有 bug,可以直接从数据库端去掉白名单或删除相关用户。
    2. 重写 SQL 语句:将高 QPS 的 SQL 语句重写为select 1,但这种方法风险较高,可能会误伤其他功能或导致业务逻辑失败。
  • 风险:重写 SQL 语句可能导致业务逻辑错误,应作为最后的手段。

23 Mysql 是怎么保证数据不丢的

binlog 的写入机制

  • 事务执行过程中,日志先写入 binlog cache,事务提交时再将 binlog cache 写入 binlog 文件。
  • binlog cache 是每个线程独有的,而 binlog 文件是共享的。
  • 写入操作分为 write(写入文件系统的 page cache)和 fsync(持久化到磁盘)。
  • 参数 sync_binlog 控制 fsync 的时机:
    • sync_binlog=0:每次提交事务只 write,不 fsync。
    • sync_binlog=1:每次提交事务都 fsync。
    • sync_binlog=N:每 N 个事务提交后 fsync。

redo log 的写入机制

  • 事务执行过程中,redo log 先写入 redo log buffer。
  • redo log 的三种状态:
    • 在 redo log buffer 中(内存)。
    • 写入文件系统的 page cache(write)。
    • 持久化到磁盘(fsync)。
  • 参数 innodb_flush_log_at_trx_commit 控制 redo log 的写入策略:
    • 0:事务提交时只写入 redo log buffer。
    • 1:事务提交时将 redo log 持久化到磁盘。
    • 2:事务提交时只写入 page cache。

redo log 写入磁盘的触发时机

  • 后台线程每秒会将 redo log buffer 中的日志写入磁盘。
  • redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。
  • 并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。

组提交(Group Commit)机制

  • 通过延迟 fsync 操作,将多个事务的 redo log 或 binlog 合并写入磁盘,减少磁盘 I/O 操作。
  • 组提交可以显著提升性能,尤其是在高并发场景下。

WAL 机制的优势

  • redo log 和 binlog 都是顺序写入,顺序写比随机写速度快。
  • 组提交机制减少了磁盘 I/O 操作的次数。

性能优化建议

  • 设置 binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。
  • sync_binlog 设置为大于 1 的值(如 100~1000),减少 fsync 次数,但主机掉电时会丢 binlog 日志。
  • innodb_flush_log_at_trx_commit 设置为 2,减少 redo log 的 fsync 次数,但主机掉电时会丢失数据。

数据一致性与可靠性

  • MySQL 通过 redo log 和 binlog 的持久化来保证 crash-safe。
  • 即使事务未提交,redo log 和 binlog 的丢失也不会导致数据不一致,因为事务未提交的数据不会被应用到数据库中。

常见问题解答

  • 解释了为什么 binlog cache 是线程独有,而 redo log buffer 是全局共享的。
  • 讨论了事务执行期间发生 crash 时,redo log 和 binlog 的丢失不会导致主备不一致。
  • 解释了 binlog 写入后发生 crash 的情况,客户端重连后事务已提交成功是正常现象。

24 Mysql 是怎么保证主备一致的

MySQL 主备同步的基本原理

  • 主库(节点 A)负责处理客户端的读写请求,备库(节点 B)通过同步主库的 binlog 来保持数据一致。
  • 主备切换时,客户端会从主库切换到备库,备库变为新的主库。
  • 备库通常设置为只读模式,防止误操作和双写问题,但同步线程拥有超级权限,可以绕过只读限制。

主备同步的流程

  • 备库通过 change master 命令设置主库的连接信息,并通过 start slave 命令启动两个线程:io_threadsql_thread
  • io_thread 负责从主库读取 binlog 并写入备库的中转日志(relay log)。
  • sql_thread 负责解析并执行中转日志中的命令,保持备库与主库的数据一致。

binlog 的三种格式

  • statement:记录 SQL 语句的原文。优点是日志量小,缺点是某些情况下可能导致主备数据不一致(如使用了 LIMITNOW() 函数)。
  • row:记录每一行数据的变更。优点是保证主备数据一致,缺点是日志量大,尤其是批量操作时。
  • mixed:MySQL 自动选择 statement 或 row 格式,结合两者的优点,避免数据不一致问题。

binlog 格式的选择

  • statement 格式可能导致主备数据不一致,尤其是在使用不确定函数(如 NOW())或 LIMIT 时。
  • row 格式记录了每一行数据的变更,确保主备数据一致,但日志量较大。
  • mixed 格式是 MySQL 的折中方案,自动选择 statement 或 row 格式,避免数据不一致问题。

binlog 的数据恢复

  • row 格式的 binlog 记录了每一行数据的变更,可以用于数据恢复。例如,误删数据后可以通过 binlog 恢复删除的行。
  • insertupdatedelete 操作都可以通过 binlog 进行恢复,尤其是 row 格式的 binlog 记录了完整的行数据。

循环复制问题

  • 在双 M 结构(主备互为主备)中,可能会出现循环复制问题,即主库和备库互相同步 binlog,导致无限循环。
  • MySQL 通过 server id 解决循环复制问题:每个库在收到 binlog 时,会检查 server id,如果与自己的相同,则丢弃该日志,避免循环复制。

binlog 的其他用途

  • binlog 不仅可以用于主备同步,还可以用于数据恢复、审计、数据同步等场景。
  • 通过 mysqlbinlog 工具可以解析 binlog,并将其用于数据恢复或重放。

总结

  • binlog 是 MySQL 主备同步的核心机制,通过不同的格式(statement、row、mixed)来平衡日志大小和数据一致性。
  • 主备同步通过 io_threadsql_thread 实现,确保备库与主库的数据一致。
  • 双 M 结构中的循环复制问题通过 server id 机制解决,避免无限循环。

25 Mysql 是怎么保证高可用的

主备同步与最终一致性

  • MySQL 通过 binlog 实现主备同步,备库接收并执行主库的 binlog,最终达到与主库一致的状态。
  • 最终一致性是主备同步的基础,但要实现高可用性,还需要解决主备延迟等问题。

主备延迟的来源

  • 备库性能不足:备库所在机器的性能较差,导致同步速度慢。
  • 备库压力大:备库承担了过多的读请求,消耗了大量 CPU 资源,影响了同步速度。
  • 大事务:主库上的大事务(如大量数据删除或大表 DDL)会导致备库延迟,因为备库需要等待主库的事务完成后才能同步。
  • 备库的并行复制能力:备库的并行复制能力不足也会导致延迟。

主备切换策略

  • 可靠性优先策略
    • 在主备切换时,确保备库的数据与主库完全一致后再切换。
    • 切换过程中会有短暂的不可用时间,但能保证数据的一致性。
  • 可用性优先策略
    • 在主备切换时,优先保证系统的可用性,允许短暂的数据不一致。
    • 这种策略可能会导致数据不一致,尤其是在使用 statement 或 mixed 格式的 binlog 时。

binlog 格式对数据一致性的影响

  • statement 格式:记录 SQL 语句的原文,可能导致主备数据不一致(如使用 LIMITNOW() 函数时)。
  • row 格式:记录每一行数据的变更,确保主备数据一致,但日志量较大。
  • mixed 格式:MySQL 自动选择 statement 或 row 格式,结合两者的优点,避免数据不一致问题。

高可用性与数据一致性的权衡

  • 大多数情况下,建议使用可靠性优先策略,确保数据的准确性。
  • 在某些特殊场景下(如操作日志记录),可用性优先策略可能更为合适,因为短暂的数据不一致可以通过 binlog 修复,且不会对业务造成严重影响。

异常切换与主备延迟

  • 在主库故障时,主备延迟会影响系统的可用性。延迟越小,系统恢复的时间越短,可用性越高。
  • 如果主备延迟较大,切换时可能会导致系统不可用或数据不一致。

总结

  • MySQL 的高可用性依赖于主备同步机制,主备延迟是影响高可用性的关键因素。
  • 通过优化备库性能、减少大事务、提升并行复制能力等手段,可以减少主备延迟。
  • 在主备切换时,应根据业务需求选择可靠性优先可用性优先策略,确保在数据一致性和系统可用性之间找到平衡。

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

备库延迟的原因

  • 备库执行日志的速度持续低于主库生成日志的速度,导致延迟可能达到小时级别。
  • 单线程复制是备库延迟的主要原因之一,尤其是在主库并发高、TPS 高的情况下。

并行复制的核心原则

  • 不能造成更新覆盖:更新同一行的两个事务必须被分发到同一个 worker 中。
  • 同一个事务不能被拆开:同一个事务的多个更新语句必须放到同一个 worker 中执行。

多线程复制的演进

  • MySQL 5.5 及之前版本:只支持单线程复制,导致备库延迟问题严重。
  • MySQL 5.6 版本:支持了并行复制,允许不同数据库的事务在备库上并行执行。
  • MariaDB 的并行复制策略:基于组提交(group commit)特性,相同 commit_id 的事务可以在备库上并行执行,但存在大事务拖后腿的问题。
  • MySQL 5.7 版本:引入了基于 LOGICAL_CLOCK 的并行复制策略,允许处于 prepare 状态的事务在备库上并行执行,提升了并行度。
  • MySQL 5.7.22 版本:引入了基于 WRITESET 的并行复制策略。算出这一行的 hash 值,组成集合 writeset。如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行。

不同并行复制策略的优缺点

  • 按库并行策略:适用于多数据库场景,但在单数据库或热点表场景下效果不佳。
  • 按表并行策略:适用于多表场景,但在热点场景下会退化为单线程复制。
  • 按行并行策略:并行度最高,但消耗更多的内存和 CPU 资源,适用于大事务较少的场景。
  • MariaDB 的组提交策略:基于 commit_id 的并行复制,简单易实现,但容易受大事务影响。
  • MySQL 5.7 的 LOGICAL_CLOCK 策略:基于 prepare 状态的并行复制,提升了并行度,但依赖于主库的 binlog 组提交机制。
  • MySQL 5.7.22 的 WRITESET 策略:基于行 hash 值的并行复制,减少了计算量和内存消耗,支持 statement 格式的 binlog。

大事务对备库延迟的影响

  • 大事务(如大表 DDL 或大量数据删除)会导致备库延迟增加,因为备库需要等待大事务完成后才能继续执行其他事务。
  • 建议将大事务拆分为小事务,以减少对备库同步的影响。

总结

  • 多线程复制是解决备库延迟问题的关键,MySQL 通过不同版本的演进逐步提升了并行复制的效率和灵活性。
  • 不同的并行复制策略适用于不同的业务场景,DBA 需要根据实际情况选择合适的策略。
  • 大事务是造成备库延迟的主要原因之一,开发人员应尽量避免大事务操作,将其拆分为小事务。

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

一主多从架构

  • 一主多从架构通常用于读写分离,主库负责写操作和部分读操作,从库分担读请求。
  • 当主库发生故障时,需要进行主备切换,从库需要重新指向新的主库,增加了切换的复杂性。

基于位点的主备切换

  • 在切换过程中,从库需要找到与新主库同步的位点(binlog 文件名和偏移量),以确保数据一致性。
  • 位点的获取通常是通过解析新主库的 binlog 文件,找到故障时刻的大致位置。
  • 由于位点不精确,可能会导致从库重复执行某些事务,出现主键冲突等问题。
  • 解决方法包括:
    • 使用 sql_slave_skip_counter 跳过重复事务。
    • 设置 slave_skip_errors 参数,跳过常见的错误(如 1062 主键冲突和 1032 删除数据找不到行)。

GTID(全局事务标识符)

  • GTID 是 MySQL 5.6 引入的机制,用于唯一标识每个事务,格式为 server_uuid:gno
  • GTID 模式简化了主备切换过程,不再需要手动指定位点,系统会自动处理同步问题。
  • GTID 的生成方式有两种:
    • 自动生成:事务提交时分配 GTID。
    • 手动指定:通过 set gtid_next 指定 GTID,适用于跳过某些事务的场景。

基于 GTID 的主备切换

  • 在 GTID 模式下,从库只需要执行 CHANGE MASTER TO 命令,并设置 master_auto_position=1,系统会自动计算需要同步的事务。
  • 新主库会计算自己与从库的 GTID 集合差集,确保从库获取到所有缺失的事务。
  • 如果新主库缺少从库所需的事务,会直接报错,确保数据完整性。

GTID 与在线 DDL

  • 在双 M 结构下,备库执行的 DDL 语句可以通过 GTID 机制确保不会在主库上重复执行。
  • 通过手动设置 GTID,可以确保 DDL 操作的 binlog 记录不会影响主库。

总结

  • 基于位点的主备切换复杂且容易出错,而 GTID 模式简化了这一过程,提升了主备切换的效率和可靠性。
  • 如果 MySQL 版本支持 GTID,建议使用 GTID 模式进行主备切换。
  • GTID 模式不仅适用于主备切换,还可以用于在线 DDL 操作,确保数据一致性。

28 读写分离有哪些坑

读写分离的基本架构

一主多从架构通常用于读写分离,主库负责写操作,从库分担读请求。

读写分离的两种常见架构:

  • 客户端直连:客户端直接连接数据库,性能较好,但主备切换时客户端需要调整连接信息。
  • 带 Proxy 的架构:客户端连接 Proxy,由 Proxy 负责路由请求,对客户端友好,但架构复杂。

过期读问题

由于主从延迟,客户端在从库上可能会读到过期的数据,这种现象称为“过期读”。过期读的常见场景是主库更新后,从库还未同步完成,客户端查询从库时读到旧数据。

解决过期读的几种方案

  • 强制走主库:对于必须读到最新数据的请求,强制查询主库。适用于对数据实时性要求高的场景,但会增加主库的压力。
  • Sleep 方案:在查询从库前先 sleep 一段时间,假设主从延迟在 1 秒内。虽然简单,但不精确,可能导致等待时间过长或仍然读到过期数据。
  • 判断主备无延迟:通过 show slave status 判断主从延迟,确保从库同步完成后再查询。可以通过 seconds_behind_master、位点对比或 GTID 集合对比来判断。
  • 配合 semi-sync:使用半同步复制(semi-sync),确保主库在事务提交后,至少有一个从库收到 binlog 后才返回确认。可以减少过期读的概率,但在多从库场景下仍可能有问题。semi-sync 流程:
    • 事务提交的时候,主库把 binlog 发给从库;
    • 从库收到 binlog 以后,发回给主库一个 ack,表示收到了;
    • 主库收到这个 ack 以后,才能给客户端返回“事务完成”的确认。
  • 等主库位点方案:使用 select master_pos_wait(file, pos, timeout) 命令,等待从库同步到指定位点后再查询。可以精确控制查询时机,避免过期读。
  • 等 GTID 方案:使用 select wait_for_executed_gtid_set(gtid_set, timeout) 命令,等待从库执行到指定 GTID 后再查询。适用于 GTID 模式,减少了查询主库位点的开销。

不同的方案适用于不同的业务场景,需要根据业务需求选择。在实际应用中,可以混合使用多种方案,根据请求的类型选择不同的处理方式。

总结

  • 过期读是读写分离架构中常见的问题,主从延迟是主要原因。
  • 通过合理的方案选择,可以在保证读写分离的同时,尽量减少过期读的发生。
  • 对于高一致性要求的场景,建议使用等主库位点或等 GTID 方案,确保查询结果的准确性。

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

select 1 判断

  • select 1 只能检测数据库进程是否存活,无法检测数据库内部的并发线程数是否过高或是否存在其他问题。
  • 当并发线程数达到 innodb_thread_concurrency 设置的上限时,数据库可能无法处理新请求,但 select 1 仍然可以成功返回,导致误判。

查表判断

  • 在系统库(如 mysql 库)中创建一个健康检查表(如 health_check),里面只放一行数据,然后定期执行 select * from mysql.health_check
  • 这种方法可以检测到由于并发线程过多导致的数据库不可用情况,但无法检测磁盘空间满等问题

更新判断

  • 在健康检查表中加入一个 timestamp 字段,定期执行更新操作(如 update mysql.health_check set t_modified=now())。
  • 这种方法可以检测到磁盘空间满等问题,因为更新操作需要写 binlog,如果磁盘空间满,更新操作会失败。为了避免主备冲突,可以在健康检查表中使用 server_id 作为主键,确保主库和备库的更新操作不会冲突。
  • 更新判断的局限性:
    • 更新判断存在“判定慢”的问题,即在系统 IO 资源紧张时,更新操作可能仍然成功返回,导致误判。
    • 外部检测的随机性可能导致问题无法及时被发现,尤其是在定时轮询的间隔期间。

内部统计

  • MySQL 5.6 版本以后提供了 performance_schema 库,可以统计每次 IO 请求的时间。
  • 通过监控 performance_schema.file_summary_by_event_name 表中的 IO 请求时间,可以更准确地判断数据库是否出现性能问题。
  • 可以设置阈值,当单次 IO 请求时间超过一定值(如 200 毫秒)时,认为数据库出现异常。

总结

  • 不同的检测方法各有优缺点,select 1 简单但不精确,查表和更新判断可以检测更多问题,但仍存在局限性。
  • 内部统计方法(如 performance_schema)可以提供更精确的数据库状态信息,但会带来一定的性能损耗。
  • 在实际应用中,可以根据业务需求选择合适的检测方法,通常建议结合更新判断和内部统计方法,以提高检测的准确性。

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

加锁规则回顾

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

不等号条件里的等值查询

  • 在不等号查询中,虽然条件是不等号,但在索引树的搜索过程中,引擎内部会使用等值查询来定位记录。
  • 例如,select * from t where id>9 and id<12 order by id desc for update; 的加锁范围是 (0,5]、(5,10] 和 (10,15),其中 id=15 不满足条件,next-key lock 退化为间隙锁 (10,15)。

等值查询的过程

  • 对于 select id from t where c in(5,20,10) lock in share mode;,加锁过程是逐个进行的,先加 c=5 的记录锁,再加 c=10 的记录锁,最后加 c=20 的记录锁。
  • 如果并发执行 select id from t where c in(5,20,10) order by c desc for update;,加锁顺序相反,可能导致死锁。

死锁分析

  • 死锁发生时,InnoDB 会选择回滚成本较小的事务来解除死锁。
  • 通过 show engine innodb status 可以查看死锁信息,了解哪些事务持有锁、等待锁,以及最终回滚了哪个事务。

锁等待分析

  • 通过 show engine innodb status 可以查看锁等待信息,了解哪些事务在等待锁,以及等待的锁类型(如间隙锁、插入意向锁等)。
  • 间隙锁的范围是由间隙右边的记录定义的,删除记录后,间隙锁的范围可能会发生变化。

update 语句的加锁行为

  • update 语句的加锁范围可以通过语句的执行逻辑来分析。例如,update t set c=1 where c=5 会先插入新记录,再删除旧记录,可能会被间隙锁阻塞。

参考资料

《极客时间教程 - 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 是保存该消息的日志段文件中该消息第一个字节的物理文件位置。

参考资料