Dunwu Blog

大道至简,知易行难

HBase 架构

HBase 是一个在 HDFS 上开发的面向列的分布式数据库。

HBase 存储架构

在 HBase 中,表被分割成多个更小的块然后分散的存储在不同的服务器上,这些小块叫做 Regions,存放 Regions 的地方叫做 RegionServer。Master 进程负责处理不同的 RegionServer 之间的 Region 的分发。

概览

img

HBase 主要处理两种文件:预写日志(WAL)和实际数据文件 HFile。一个基本的流程是客户端首先联系 ZooKeeper 集群查找行键。上述过程是通过 ZooKeeper 获取欧含有 -ROOT- 的 region 服务器来完成的。通过含有 -ROOT- 的 region 服务器可以查询到含有 .META. 表中对应的 region 服务器名,其中包含请求的行键信息。这两种内容都会被缓存下来,并且只查询一次。最终,通过查询 .META. 服务器来获取客户端查询的行键数据所在 region 的服务器名。

Region

HBase Table 中的所有行按照 Row Key 的字典序排列。HBase Table 根据 Row Key 的范围分片,每个分片叫做 Region。一个 Region 包含了在 start key 和 end key 之间的所有行。

img

HBase 支持自动分区:每个表初始只有一个 Region,随着数据不断增加,Region 会不断增大,当增大到一个阀值的时候,Region 就会分裂为两个新的 Region。当 Table 中的行不断增多,就会有越来越多的 Region

Region 是 HBase 中分布式存储和负载均衡的最小单元。这意味着不同的 Region 可以分布在不同的 Region Server 上。但一个 Region 是不会拆分到多个 Server 上的。

img

Region Server

Region 只不过是表被拆分,并分布在 Region Server。

Region Server 运行在 HDFS 的 DataNode 上。它具有以下组件:

  • **WAL(Write Ahead Log,预写日志)**:用于存储尚未进持久化存储的数据记录,以便在发生故障时进行恢复。如果写 WAL 失败了,那么修改数据的完整操作就是失败的。
    • 通常情况,每个 RegionServer 只有一个 WAL 实例。在 2.0 之前,WAL 的实现叫做 HLog
    • WAL 位于 /hbase/WALs/ 目录下
    • 如果每个 RegionServer 只有一个 WAL,由于 HDFS 必须是连续的,导致必须写 WAL 连续的,然后出现性能问题。MultiWAL 可以让 RegionServer 同时写多个 WAL 并行的,通过 HDFS 底层的多管道,最终提升总的吞吐量,但是不会提升单个 Region 的吞吐量。
  • BlockCache读缓存。它将频繁读取的数据存储在内存中,如果存储不足,它将按照 最近最少使用原则 清除多余的数据。
  • MemStore写缓存。它存储尚未写入磁盘的新数据,并会在数据写入磁盘之前对其进行排序。每个 Region 上的每个列族都有一个 MemStore。
  • HFile将行数据按照 Key/Values 的形式存储在文件系统上。HFile 是 HBase 在 HDFS 中存储数据的格式,它包含多层的索引,这样在 HBase 检索数据的时候就不用完全的加载整个文件。HFile 存储的根目录默认为为 /hbase。索引的大小(keys 的大小,数据量的大小)影响 block 的大小,在大数据集的情况下,block 的大小设置为每个 RegionServer 1GB 也是常见的。
    • 起初,HFile 中并没有任何 Block,数据还存在于 MemStore 中。
    • Flush 发生时,创建 HFile Writer,第一个空的 Data Block 出现,初始化后的 Data Block 中为 Header 部分预留了空间,Header 部分用来存放一个 Data Block 的元数据信息。
    • 而后,位于 MemStore 中的 KeyValues 被一个个 append 到位于内存中的第一个 Data Block 中:

img

Region Server 存取一个子表时,会创建一个 Region 对象,然后对表的每个列族创建一个 Store 实例,每个 Store 会有 0 个或多个 StoreFile 与之对应,每个 StoreFile 则对应一个 HFile,HFile 就是实际存储在 HDFS 上的文件。

HBase 系统架构

img

和 HDFS、YARN 一样,HBase 也遵循 master / slave 架构

  • HBase 有一个 master 节点。master 节点负责协调管理 region server 节点
    • master 负责将 region 分配给 region server 节点;
    • master 负责恢复 region server 节点的故障。
  • HBase 有多个 region server 节点。region server 节点负责零个或多个 region 的管理并响应客户端的读写请求。region server 节点还负责 region 的划分并通知 master 节点有了新的子 region
  • HBase 依赖 ZooKeeper 来实现故障恢复。

Master Server

Master Server 负责协调 Region Server。具体职责如下:

  • 为 Region Server 分配 Region ;
  • 负责 Region Server 的负载均衡 ;
  • 发现失效的 Region Server 并重新分配其上的 Region;
  • GFS 上的垃圾文件回收;
  • 处理 Schema 的更新请求。

img

Region Server

  • Region Server 负责维护 Master Server 分配给它的 Region,并处理发送到 Region 上的 IO 请求;
  • 当 Region 过大,Region Server 负责自动分区,并通知 Master Server 记录更新。

img

ZooKeeper

HBase 依赖 ZooKeeper 作为分布式协调服务来维护集群中的服务器状态。Zookeeper 维护哪些服务器是活动的和可用的,并提供服务器故障通知。集群至少应该有 3 个节点。

ZooKeeper 的作用:

  • 保证任何时候,集群中只有一个 Master;
  • 存储所有 Region 的寻址入口;
  • 实时监控 Region Server 的状态,将 Region Server 的上线和下线信息实时通知给 Master;
  • 存储 HBase 的 Schema,包括有哪些 Table,每个 Table 有哪些 Column Family 等信息。

img

以上,最重要的一点是 ZooKeeper 如何保证 HBase 集群中只有一个 Master Server 的呢?

  • 所有 Master Server 会竞争 Zookeeper 的 znode 锁(一个临时节点),只有一个 Master Server 能够创建成功,此时该 Master 就是主 Master。
  • 主 Master 会定期向 Zookeeper 发送心跳。从 Master 则通过 Watcher 机制对主 Master 所在节点进行监听。
  • 如果,主 Master 未能及时发送心跳,则其持有的 ZooKeeper 会话会过期,相应的 znode 锁(一个临时节点)会被自动删除。这会触发定义在该节点上的 Watcher 事件,所有从 Master 会得到通知,并再次开始竞争 znode 锁,直到完成主 Master 的选举。

HBase 内部保留名为 hbase:meta 的特殊目录表(catalog table)。它维护着当前集群上所有 region 的列表、状态和位置。hbase:meta 表中的项使用 region 作为键。region 名由所属的表名、region 的起始行、region的创建时间以及基于整体计算得出的 MD5 组成。

HBase 读写流程

写入数据的流程

  1. Client 向 Region Server 提交写请求;
  2. Region Server 找到目标 Region;
  3. Region 检查数据是否与 Schema 一致;
  4. 如果客户端没有指定版本,则获取当前系统时间作为数据版本;
  5. 将更新写入 WAL Log;
  6. 将更新写入 Memstore;
  7. 判断 Memstore 存储是否已满,如果存储已满则需要 flush 为 Store Hfile 文件。

更为详细写入流程可以参考:HBase - 数据写入流程解析

读取数据的流程

以下是客户端首次读写 HBase 上数据的流程:

  1. 客户端从 Zookeeper 获取 META 表所在的 Region Server;
  2. 客户端访问 META 表所在的 Region Server,从 META 表中查询到访问行键所在的 Region Server,之后客户端将缓存这些信息以及 META 表的位置;
  3. 客户端从行键所在的 Region Server 上获取数据。

如果再次读取,客户端将从缓存中获取行键所在的 Region Server。这样客户端就不需要再次查询 META 表,除非 Region 移动导致缓存失效,这样的话,则将会重新查询并更新缓存。

注:META 表是 HBase 中一张特殊的表,它保存了所有 Region 的位置信息,META 表自己的位置信息则存储在 ZooKeeper 上。

img

更为详细读取数据流程参考:

HBase 原理-数据读取流程解析

HBase 原理-迟到的‘数据读取流程部分细节

参考资料

Kafka 流式处理

简介

什么是流式处理

数据流是无边界数据集的抽象表示。无边界意味着无限和持续增长。无边界数据集之所以是无限的,是因为随着时间的推移,新的记录会不断加入进来。

  • 事件流是有序的。事件的发生总是有先后顺序。而数据库里的记录是无序的。
  • 不可变的数据记录。事件一旦发生,就不能被改变。
  • 事件流是可重播的。对于大多数业务来说,重播发生在几个月前(甚至几年前)的原始事件流是一个很重要的需求。可能是为了尝试使用新的分析方法纠正过去的错误,或是为了进行审计。如果没有这项能力,流式处理充其量只是数据科学实验室里的一个玩具而已。

流式处理是指实时地处理一个或多个事件流。流式处理是一种编程范式,就像请求与响应范式和批处理范式那样。

编程范式对比

  • 请求与响应 - 这是延迟最小的一种范式,响应时间处于亚毫秒到毫秒之间,而且响应时间一般非常稳定。这种处理模式一般是阻塞的,应用程序向处理系统发出请求,然后等待响应。
  • 批处理 - 这种范式具有高延迟高吞吐量的特点。处理系统按照设定的时间启动处理进程,读取所有的输入数据(从上一次执行之后的所有可用数据,或者从月初开始的所有数据等),输出结果,然后等待下一次启动。处理时间从几分钟到几小时不等,并且用户从结果里读到的都是旧数据。一般用于 BI 生成分析报表。
  • 流式处理 - 这种范式介于上述两者之间。大部分的业务不要求亚毫秒级的响应,不过也接受不了长时间的等待。大部分业务流程都是持续进行的,只要业务报告保持更新,业务产品线能够持续响应,那么业务流程就可以进行下去,而无需等待特定的响应,也不要求在几毫秒内得到响应。一些业务流程具有持续性和非阻塞的特点。

流的定义不依赖任何一个特定的框架、 API 或特性。只要持续地从一个无边界的数据集读取数据,然后对它们进行处理并生成结果,那就是在进行流式处理。重点是,整个处理过程必须是持续的

流处理的核心概念

时间

时间或许是流式处理最为重要的概念。大部分流式应用的操作都是基于时间窗口的。有这么几个时间概念:

  • 事件时间 - 事件时间是指所追踪事件的发生时间和记录的创建时间。
  • 日志追加时间 - 日志追加时间是指事件保存到 broker 的时间。
  • 处理时间 - 处理时间是指应用程序在收到事件之后要对其进行处理的时间。这个时间可以是在事件发生之后的几毫秒、几小时或几天。同一个事件可能会被分配不同的时间戳,这取决于应用程序何时读取这个事件。如果应用程序使用了两个线程来读取同一个事件,这个时间戳也会不一样!所以这个时间戳非常不可靠,应该避免使用它。

注意:在处理与时间有关的问题时,需要注意时区问题。整个数据管道应该使用同一个时区。

状态

如果只是单独处理每一个事件,那么流式处理就很简单。

如果操作里包含了多个事件,流式处理就会变得复杂而有趣。事件与事件之间的信息被称为状态。这些状态一般被保存在应用程序的本地变量里。

流式处理含以下几种状态:

  • 本地状态或内部状态 - 这种状态只能被单个应用程序实例访问,它们一般使用内嵌在应用程序里的数据库进行维护和管理。本地状态的优势在于它的速度,不足之处在于它受到内存大小的限制 。 所以,流式处理的很多设计模式都将数据拆分到多个子流,这样就可以使用有限的本地状态来处理它们。
  • 外部状态 - 这种状态使用外部的数据存储来维护,一般使用 NoSQL 系统,比如 Cassandra。大部分流式处理应用尽量避免使用外部存储,或者将信息缓存在本地,减少与外部存储发生交互,以此来降低延迟,而这就引入了如何维护内部和外部状态一致性的问题。

流和表

流是一系列事件,每个事件就是一个变更。表包含了当前的状态,是多个变更所产生的结果。所以说, 表和流是同一个硬币的两面,世界总是在发生变化,用户有时候关注变更事件,有时候则关注世界的当前状态。如果一个系统允许使用这两种方式来查看数据,那么它就比只支持一种方式的系统强大。

时间窗口

时间窗口有不同的类型,基于以下属性决定:

  • 窗口的大小
  • 窗口移动的频率
  • 窗口的可更新时间多长

流处理的设计模式

单个事件处理

处理单个事件是流式处理最基本的模式。这个模式也叫 mapfilter 模式,因为它经常被用于过滤无用的事件或者用于转换事件( map 这个术语是从 Map-Reduce 模式中来的, map 阶段转换事件, reduce 阶段聚合转换过的事件)。

在这种模式下,应用程序读取流中的事件 ,修改它们,然后把事件生成到另一个流上。

使用本地状态

大部分流式处理应用程序关心的是如何聚合信息,特别是基于时间窗口进行聚合。

要实现这些聚合操作,需要维护流的状态,可以通过本地状态(而不是共享状态)来实现。

如果流式处理应用包含了本地状态,会变得非常复杂,还需要解决下列问题:

  • 内存使用 - 应用实例必须有可用的内存来保存本地状态。
  • 持久化 - 要确保在应用程序关闭时不会丢失状态,并且在应用程序重启后或者切换到另一个应用实例时可以恢复状态。
  • 再均衡 - 有时候,分区会被重新分配给不同的消费者。在这种情况下,失去分区的实例必须把最后的状态保存起来 , 同时获得分区的实例必须知道如何恢复到正确的状态。

多阶段处理和重分区

数据量不大的时候,可以使用本地状态。但面对海量的流数据时,可以使用多阶段处理(类似 Hadoop 的 map reduce)

流和表的连接

有些场景下,流式处理需要将外部数据和流集成在一起。

可以考虑将外部的数据信息(如数据库存储)缓存到流式处理应用程序里。

流和流的连接

有些场景下,需要连接两个真实的事件流。

将两个流里具有相同键和发生在相同时间窗口内的事件匹配起来。这就是为什么流和流的连接也叫作基于时间窗口的连接( windowed-join )。

乱序的事件

不管是对于流式处理还是传统的 ETL 系统来说,处理乱序事件都是一个挑战。

要让流处理应用程序处理好这些场景,需要做到以下几点:

  • 识别乱序的事件。应用程序需要检查事件的时间,并将其与当前时间进行比较。
  • 规定一个时间段用于重排乱序的事件。比如 3 个小时以内的事件可以重排,但 3 周以外的事件就可以直接扔掉。
  • 具有在一定时间段内重排乱序事件的能力。这是流式处理应用与批处理作业的一个主要不同点。假设有一个每天运行的作业, 一些事件在作业结束之后才到达,那么可以重新运行昨天的作业来更新事件。而在流式处理中,“重新运行昨天的作业”这种情况是不存在的,乱序事件和新到达的事件必须一起处理。
  • 具备更新结果的能力。如果处理的结果保存到数据库里,那么可以通过 put 或 update 对结果进行更新。如果流应用程序通过邮件发送结果,那么要对结果进行更新,就需要很巧妙的手段。

重新处理

有两种模式:

模式一:使用新版本应用处理同一个事件流,生成新的结果,并比较两种版本的结果,然后在某个时间点将客户端切换到新的结果流上。

模式二:重置应用,让应用回到输入流的起始位置开始处理,同时重置本地状态(这样就不会将两个版本应用的处理结果棍淆起来了),而且还可能需要清理之前的输出流。

Kafka Streams 的架构

每个流式应用程序至少会实现和执行一个拓扑。拓扑(在其他流式处理框架里叫作 DAG,即有向无环图)是一个操作和变换的集合,每个事件从输入到输出都会流经它。

img

分区和任务

Kafka 的消息传递层对数据进行分区以进行存储和传输。 Kafka Streams 对数据进行分区以进行处理。Kafka Streams 使用分区和任务的概念作为基于 Kafka 主题分区的并行模型的逻辑单元。

每个流分区都是数据记录的完全有序序列,并映射到 Kafka 主题分区。流中的数据记录映射到该主题的 Kafka 消息。更具体地说,Kafka Streams 根据应用程序的输入流分区创建固定数量的任务,每个任务分配了输入流中的分区列表(即 Kafka 主题)。分区对任务的分配永远不会改变,因此每个任务都是应用程序并行性的固定单元。然后,任务可以根据分配的分区实例化其自己的处理器拓扑。它们还为其分配的每个分区维护一个缓冲区,并一次从这些记录缓冲区处理消息。结果,可以在没有人工干预的情况下独立且并行地处理流任务。

img

参考资料

微服务基本原理

微服务技术架构

img

第一层:接入层

外部设备访问的统一接入层。

第二层:聚合服务层

对下层的基础服务做一些聚合,剪裁的工作,适配上层不同设备的数据输出。

第三层:基础服务层

比较细粒度的微服务层,提供基础的核心服务,公共服务。

img

服务通信

通过注册中心,服务消费者和服务提供者就可以感知彼此,但是,要实现交互还必须解决通信问题:

  • 通信协议。即服务提供者和服务消费者之间以什么样的 协议 进行网络通信,说白了,是要解决客户端和服务端如何建立连接、管理连接以及服务端如何处理请求的问题。是采用四层 TCP、UDP 协议,还是采用七层 HTTP 协议,还是采用其他协议?例如:Dubbo 基于 TCP 通信;而 Spring Cloud 基于 HTTP REST 通信。TCP 通信方式,传输效率更高;但是 HTTP 方式天然可以提供对外服务。
  • 传输方式。即服务提供者和服务消费者之间的数据传输采用哪种方式。是同步还是异步?是在单连接上传输,还是多路复用。
  • 序列化和反序列化。它主要解决客户端和服务端采用哪种数据编解码的问题。常见的序列化方式包括:XML、JSON;二进制类如:thriftprotobufhessian、JDK。

序列化方式

序列化方式的选型,一般基于以下考虑:

  • 支持数据结构类型的丰富度
  • 跨语言支持
  • 性能

👉 参考:Java 序列化

通信协议

微服务框架对比:

RPC REST
耦合性 强耦合 松散耦合
协议 Tcp Http、Http2
序列化 二进制(Thrift、Protobuf、Hessian、Avro、JDK 等) Xml、Json
性能
客户端 对编程语言有限制 跨语言支持更好(支持 Http 即可)
代表技术 Dubbo、Motan、Tars、gRpc、Thrift Spring Cloud

服务监控

当服务消费者与服务提供者之间建立了通信,作为管理者需要通过监控手段来观察服务是否正常,调用是否成功。服务监控是很复杂的,在微服务架构下,一次用户调用会因为服务化拆分后,变成多个不同服务之间的相互调用,这也就需要对拆分后的每个服务都监控起来。

监控对象

服务监控一定是通过观察数据来量化分析,所以首先要明确需要监控什么。

一般来说,服务监控数据有以下分类:

  • 业务监控:核心指标、登录、登出、下单、支付等。
  • 应用监控:访问接口、访问服务、SQL、内存使用率、响应时间、TPS、QPS 等。
  • 系统监控:CPU、内存、网络、磁盘等。
  • 基础监控:网络流量、丢包数、错包数、连接数等。
  • 客户端监控:性能、返回码、地域、运营商、版本、系统等。

系统监控原理

一旦明确了要监控的对象,接下就是考虑如何监控。

监控系统主要包括四个环节:数据采集、数据传输、数据处理和数据展示

数据采集

通常有两种数据收集方式:

  • 服务主动上报:这种处理方式通过在业务代码或者服务框架里加入数据收集代码逻辑,在每一次服务调用完成后,主动上报服务的调用信息。这种方式在链路跟踪中较为常见,主流的技术方案有:Zipkin。
  • 代理收集:这种处理方式通过服务调用后把调用的详细信息记录到本地日志文件中,然后再通过代理去解析本地日志文件,然后再上报服务的调用信息。主流的技术方案有:ELK、Flume。

数据传输

数据传输最常用的方式有两种:

  • UDP 传输:这种处理方式是数据处理单元提供服务器的请求地址,数据采集后通过 UDP 协议与服务器建立连接,然后把数据发送过去。
  • Kafka 传输:这种处理方式是数据采集后发送到指定的 Topic,然后数据处理单元再订阅对应的 Topic,就可以从 Kafka 消息队列中读取到对应的数据。由于 Kafka 有非常高的吞吐能力,所以很适合作为大数据量的缓冲池。

数据处理

数据处理是对收集来的原始数据进行聚合并存储。数据聚合通常有两个维度:

  • 接口维度聚合:这个维度是把实时收到的数据按照接口名维度实时聚合在一起,这样就可以得到每个接口的每秒请求量、平均耗时、成功率等信息。
  • 机器维度聚合:这个维度是把实时收到的数据按照调用的节点维度聚合在一起,这样就可以从单机维度去查看每个接口的实时请求量、平均耗时等信息。

聚合后的数据需要持久化到数据库中存储,所选用的数据库一般分为两种:

  • 全文检索数据库:比如 Elasticsearch,以倒排索引的数据结构存储,需要查询的时候,根据索引来查询。
  • 时序数据库:比如 OpenTSDB,以时序序列数据的方式存储,查询的时候按照时序如 1min、5min 等维度来查询。

数据展示

数据展示是把处理后的数据以 Dashboard 的方式展示给用户。数据展示有多种方式,比如曲线图、饼状图、格子图展示等。

监控技术

img

  • ELK 的技术栈比较成熟,应用范围也比较广,除了可用作监控系统外,还可以用作日志查询和分析。
  • Graphite 是基于时间序列数据库存储的监控系统,并且提供了功能强大的各种聚合函数比如 sum、average、top5 等可用于监控分析,而且对外提供了 API 也可以接入其他图形化监控系统如 Grafana。
  • TICK 的核心在于其时间序列数据库 InfluxDB 的存储功能强大,且支持类似 SQL 语言的复杂数据处理操作。
  • Prometheus 的独特之处在于它采用了拉数据的方式,对业务影响较小,同时也采用了时间序列数据库存储,而且支持独有的 PromQL 查询语言,功能强大而且简洁。

服务治理

微服务治理平台就是与服务打交道的统一入口,无论是开发人员还是运维人员,都能通过这个平台对服务进行各种操作,比如开发人员可以通过这个平台对服务进行降级操作,运维人员可以通过这个平台对服务进行上下线操作,而不需要关心这个操作背后的具体实现。

微服务治理平台关键之处就在于它能够封装对微服务架构内的各个基础设施组件的调用,从而对外提供统一的服务操作 API,而且还提供了可视化的界面,以方便开发人员和运维人员操作。

img

服务治理的常用手段有:

  • 节点管理
    • 注册中心主动摘除机制
    • 服务消费者摘除机制
  • 负载均衡
    • 轮询
    • 随机
    • 最近最少连接
    • 一致性 Hash
  • 服务路由
    • 业务存在灰度发布的需求
    • 多机房就近访问的需求
  • 服务容错
    • FailOver:失败自动切换
    • FailBack:失败通知
    • FailCache:失败缓存
    • FailFast:快速失败

API 网关

API 网关是一个服务器,是系统的唯一入口。从面向对象设计的角度看,它与外观模式类似。API 网关封装了系统内部架构,为每个客户端提供一个定制的 API。它可能还具有其它职责,如身份验证、监控、负载均衡、缓存、请求分片与管理、静态响应处理。
API 网关方式的核心要点是,所有的客户端和消费端都通过统一的网关接入微服务,在网关层处理所有的非业务功能。通常,网关也是提供 REST/HTTP 的访问 API。服务端通过 API-GW 注册和管理服务。

Zuul

img

在 zuul 中, 整个请求的过程是这样的,首先将请求给 zuulservlet 处理,zuulservlet 中有一个 zuulRunner 对象,该对象中初始化了 RequestContext:作为存储整个请求的一些数据,并被所有的 zuulfilter 共享。zuulRunner 中还有 FilterProcessor,FilterProcessor 作为执行所有的 zuulfilter 的管理器。FilterProcessor 从 filterloader 中获取 zuulfilter,而 zuulfilter 是被 filterFileManager 所加载,并支持 groovy 热加载,采用了轮询的方式热加载。有了这些 filter 之后,zuulservelet 首先执行的 Pre 类型的过滤器,再执行 route 类型的过滤器,最后执行的是 post 类型的过滤器,如果在执行这些过滤器有错误的时候则会执行 error 类型的过滤器。执行完这些过滤器,最终将请求的结果返回给客户端。

负载均衡

参考:负载均衡基本原理

服务路由

服务路由就是服务消费者在发起服务调用时,必须根据特定的规则来选择服务节点,从而满足某些特定的需求

服务路由的应用场景

  • 分组调用。一般来讲,为了保证服务的高可用性,实现异地多活的需求,一个服务往往不止部署在一个数据中心,而且出于节省成本等考虑,有些业务可能不仅在私有机房部署,还会采用公有云部署,甚至采用多家公有云部署。服务节点也会按照不同的数据中心分成不同的分组,这时对于服务消费者来说,选择哪一个分组调用,就必须有相应的路由规则。
  • 灰度发布。在服务上线发布的过程中,一般需要先在一小部分规模的服务节点上先发布服务,然后验证功能是否正常。如果正常的话就继续扩大发布范围;如果不正常的话,就需要排查问题,解决问题后继续发布。这个过程就叫作灰度发布,也叫金丝雀部署。
  • 流量切换。在业务线上运行过程中,经常会遇到一些不可抗力因素导致业务故障,比如某个机房的光缆被挖断,或者发生着火等事故导致整个机房的服务都不可用。这个时候就需要按照某个指令,能够把原来调用这个机房服务的流量切换到其他正常的机房。
  • 读写分离。对于大多数互联网业务来说都是读多写少,所以在进行服务部署的时候,可以把读写分开部署,所有写接口可以部署在一起,而读接口部署在另外的节点上。

服务路由的规则

服务路由主要有两种规则:一种是条件路由,一种是脚本路由。

条件路由

条件路由是基于条件表达式的路由规则。

1
condition://0.0.0.0/dubbo.test.interfaces.TestService?category=routers&dynamic=true&priority=2&enabled=true&rule=" + URL.encode(" host = 10.20.153.10=> host = 10.20.153.11")

这里面 condition:// 代表了这是一段用条件表达式编写的路由规则,具体的规则是

1
host = 10.20.153.10 => host = 10.20.153.11

分隔符“=>”前面是服务消费者的匹配条件,后面是服务提供者的过滤条件。当服务消费者节点满足匹配条件时,就对该服务消费者执行后面的过滤规则。那么上面这段表达式表达的意义就是 IP 为“10.20.153.10”的服务消费者都调用 IP 为“10.20.153.11”的服务提供者节点。

如果服务消费者的匹配条件为空,就表示对所有的服务消费者应用,就像下面的表达式一样。

1
=> host != 10.20.153.11

如果服务提供者的过滤条件为空,就表示禁止服务消费者访问,就像下面的表达式一样。

1
host = 10.20.153.10=>

下面我举一些 Dubbo 框架中的条件路由,来给你讲解下条件路由的具体应用场景。

  • 排除某个服务节点
1
=> host != 172.22.3.91

一旦这条路由规则被应用到线上,所有的服务消费者都不会访问 IP 为 172.22.3.91 的服务节点,这种路由规则一般应用在线上流量排除预发布机以及摘除某个故障节点的场景。

  • 白名单和黑名单功能
1
host != 10.20.153.10,10.20.153.11 =>

这条路由规则意思是除了 IP 为 10.20.153.10 和 10.20.153.11 的服务消费者可以发起服务调用以外,其他服务消费者都不可以,主要用于白名单访问逻辑,比如某个后台服务只允许特定的几台机器才可以访问,这样的话可以机器控制访问权限。

1
host = 10.20.153.10,10.20.153.11 =>

同理,这条路由规则意思是除了 IP 为 10.20.153.10 和 10.20.153.11 的服务消费者不能发起服务调用以外,其他服务消费者都可以,也就是实现了黑名单功能,比如线上经常会遇到某些调用方不管是出于有意还是无意的不合理调用,影响了服务的稳定性,这时候可以通过黑名单功能暂时予以封杀。

  • 机房隔离
1
host = 172.22.3.* => host = 172.22.3.*

这条路由规则意思是 IP 网段为 172.22.3.* 的服务消费者,才可以访问同网段的服务节点,这种规则一般应用于服务部署在多个 IDC,理论上同一个 IDC 内的调用性能要比跨 IDC 调用性能要好,应用这个规则是为了实现同 IDC 就近访问。

  • 读写分离
1
2
method = find*,list*,get*,is* => host =172.22.3.94,172.22.3.95
method != find*,list*,get*,is* => host = 172.22.3.97,172.22.3.98

这条路由规则意思是 find、get、is* 等读方法调用 IP 为 172.22.3.94 和 172.22.3.95 的节点,除此以外的写方法调用 IP 为 172.22.3.97 和 172.22.3.98 的节点。对于大部分互联网业务来说,往往读请求要远远大于写请求,而写请求的重要性往往要远远高于读请求,所以需要把读写请求进行分离,以避免读请求异常影响到写请求,这时候就可以应用这种规则。

脚本路由

脚本路由是基于脚本语言的路由规则,常用的脚本语言比如 JavaScript、Groovy、JRuby 等。

1
"script://0.0.0.0/com.foo.BarService?category=routers&dynamic=false&rule=" + URL.encode("(function route(invokers) { ... } (invokers))")

这里面“script://”就代表了这是一段脚本语言编写的路由规则,具体规则定义在脚本语言的 route 方法实现里,比如下面这段用 JavaScript 编写的 route() 方法表达的意思是,只有 IP 为 10.20.153.10 的服务消费者可以发起服务调用。

1
2
3
4
5
6
7
8
9
function route(invokers){
var result = new java.util.ArrayList(invokers.size());
for(i =0; i < invokers.size(); i ++){
if("10.20.153.10".equals(invokers.get(i).getUrl().getHost())){
result.add(invokers.get(i));
}
}
return result;
} (invokers));

服务路由的获取方式

服务路由的获取方式主要有三种:

  • 本地配置

顾名思义就是路由规则存储在服务消费者本地上。服务消费者发起调用时,从本地固定位置读取路由规则,然后按照路由规则选取一个服务节点发起调用。

  • 配置中心管理

这种方式下,所有的服务消费者都从配置中心获取路由规则,由配置中心来统一管理。

  • 动态下发

这种方式下,一般是运维人员或者开发人员,通过服务治理平台修改路由规则,服务治理平台调用配置中心接口,把修改后的路由规则持久化到配置中心。因为服务消费者订阅了路由规则的变更,于是就会从配置中心获取最新的路由规则,按照最新的路由规则来执行。

内部服务调用

基础服务之间的调用:结合服务注册中心以及专属的具有负载均衡功能的客户端,如 Eureka+(restTemplate+Ribbon)或者 Eureka+Feign
聚合服务调用:结合服务注册中心以及专属的具有负载均衡功能的客户端,如 Eureka+(restTemplate+Ribbon)或者 Eureka+Feign

img

外部服务调用

基于 Netflix 的 zuul,做了简单了解,SpringCloud 与 zuul 集成的方式。这里先对核心流程做个简单了解,后续会有深入的应用、分析。

Spring Cloud 很好的集成了 zuul,并且可以通过注解的形式来进行请求的反向路由以及 API 网关功能
Spring Cloud 集成 zuul,对与 url 映射的处理方式与 SpringMVC 对 url 的请求方式类似,都是通过 RequestMapping 来进行请求绑定的。核心类:ZuulHandlerMapping
zuul 的核心是 ZuulServlet,一个请求核心流程:HttpServletRequest –>ZuulHandlerMapping –>ZuulController –> ZuulServlet –> ZuulFilter –> HttpServletResponse

配置中心

配置中心的思路就是把服务的各种配置,如代码里配置的各种参数、服务降级的开关甚至依赖的资源等都在一个地方统一进行管理。服务启动时,可以自动从配置中心中拉取所需的配置,并且如果有配置变更的情况,同样可以自动从配置中心拉取最新的配置信息,服务无须重新发布。

配置中心一般包含下面几个功能:

  • 配置注册功能
  • 配置反注册功能
  • 配置查看功能
  • 配置变更订阅功能

Apollo

携程开源的分布式配置中心,支持 Java 和.Net 语言,客户端和配置中心通过 HTTP 长连接实现实时推送,并且有统一的管理界面来实现配置管理。

img

Spring Cloud Git

Spring Cloud 中使用的配置中心组件,只支持 Java 语言,配置存储在 git 中,变更配置也需要通过 git 操作,如果配置中心有配置变更,需要手动刷新。

img

链路追踪

链路追踪的作用

  • 优化系统瓶颈
  • 优化链路调用
  • 生成网络拓扑
  • 透明传输数据

链路追踪的原理

理解链路追踪必须先了解以下概念:

  • traceId,用于标识某一次具体的请求 ID。当用户的请求进入系统后,会在 RPC 调用网络的第一层生成一个全局唯一的 traceId,并且会随着每一层的 RPC 调用,不断往后传递,这样的话通过 traceId 就可以把一次用户请求在系统中调用的路径串联起来。
  • spanId,用于标识一次 RPC 调用在分布式请求中的位置。当用户的请求进入系统后,处在 RPC 调用网络的第一层 A 时 spanId 初始值是 0,进入下一层 RPC 调用 B 的时候 spanId 是 0.1,继续进入下一层 RPC 调用 C 时 spanId 是 0.1.1,而与 B 处在同一层的 RPC 调用 E 的 spanId 是 0.2,这样的话通过 spanId 就可以定位某一次 RPC 请求在系统调用中所处的位置,以及它的上下游依赖分别是谁。
  • annotation,用于业务自定义埋点数据,可以是业务感兴趣的想上传到后端的数据,比如一次请求的用户 UID。

img

链路追踪的实现

一个服务追踪系统一般可以分为三层:

  • 数据采集层,负责数据埋点并上报。
  • 数据处理层,负责数据的存储与计算。
  • 数据展示层,负责数据的图形化展示。

数据采集层

一次 RPC 请求可以分为四个阶段。

  • CS(Client Send)阶段 : 客户端发起请求,并生成调用的上下文。
  • SR(Server Recieve)阶段 : 服务端接收请求,并生成上下文。
  • SS(Server Send)阶段 : 服务端返回请求,这个阶段会将服务端上下文数据上报,下面这张图可以说明上报的数据有:traceId=123456,spanId=0.1,appKey=B,method=B.method,start=103,duration=38。
  • CR(Client Recieve)阶段 : 客户端接收返回结果,这个阶段会将客户端上下文数据上报,上报的数据有:traceid=123456,spanId=0.1,appKey=A,method=B.method,start=103,duration=38。

数据处理层

数据处理层的作用就是把数据采集层上报的数据按需计算,然后落地存储供查询使用。

  • 实时数据处理

针对实时数据处理,一般采用 Storm 或者 Spark Streaming 来对链路数据进行实时聚合加工,存储一般使用 OLTP 数据仓库,比如 HBase,使用 traceId 作为 RowKey,能天然地把一整条调用链聚合在一起,提高查询效率。

  • 离线数据处理

针对离线数据处理,一般通过运行 MapReduce 或者 Spark 批处理程序来对链路数据进行离线计算,存储一般使用 Hive。

数据展示层

数据展示层的作用就是将处理后的链路信息以图形化的方式展示给用户。

实际项目中主要用到两种图形展示,一种是调用链路图,一种是调用拓扑图。

链路追踪方案对比

img

限流熔断

一般而言,集群故障的产生原因不外乎有两种:

一种是代码 bug 所导致,比如说某一段 Java 代码不断地分配大对象,但没有及时回收导致 JVM OOM 退出;

另一种是突发的流量冲击,超出了系统的最大承载能力,比如“双 11”这种购物活动,电商系统会在零点一瞬间涌入大量流量,超出系统的最大承载能力,一下子就把整个系统给压垮了。

应付集群故障的思路,主要有两种:限流降级

限流

限流就是限制流量。通常情况下,系统能够承载的流量根据集群规模的大小是固定的,可以称之为系统的最大容量。当真实流量超过了系统的最大容量后,就会导致系统响应变慢,服务调用出现大量超时,反映给用户的感觉就是卡顿、无响应。所以,应该根据系统的最大容量,给系统设置一个阈值,超过这个阈值的请求会被自动抛弃,这样的话可以最大限度地保证系统提供的服务正常。

除此之外,通常一个微服务系统会同时提供多个服务,每个服务在同一时刻的请求量也是不同的,很可能出现的一种情况就是,系统中某个服务的请求量突增,占用了系统中大部分资源,导致其他服务没有资源可用。因此,还要针对系统中每个服务的请求量也设置一个阈值,超过这个阈值的请求也要被自动抛弃,这样的话不至于因为一个服务影响了其他所有服务。

在实际项目中,可以用两个指标来衡量服务的请求量,一个是 QPS 即每秒请求量,一个是工作线程数。不过 QPS 因为不同服务的响应快慢不同,所以系统能够承载的 QPS 相差很大,因此一般选择工作线程数来作为限流的指标,给系统设置一个总的最大工作线程数以及单个服务的最大工作线程数,这样的话无论是系统的总请求量过大导致整体工作线程数量达到最大工作线程数,还是某个服务的请求量超过单个服务的最大工作线程数,都会被限流,以起到保护整个系统的作用。

降级

什么是降级呢?在我看来,降级就是通过停止系统中的某些功能,来保证系统整体的可用性。降级可以说是一种被动防御的措施,为什么这么说呢?因为它一般是系统已经出现故障后所采取的一种止损措施。

那么降级一般是如何实现的呢?根据我的实践来看, 一种可行的方案是通过开关来实现。

具体来讲,就是在系统运行的内存中开辟一块区域,专门用于存储开关的状态,也就是开启还是关闭。并且需要监听某个端口,通过这个端口可以向系统下发命令,来改变内存中开关的状态。当开关开启时,业务的某一段逻辑就不再执行,而正常情况下,开关是关闭的状态。

开关一般用在两种地方,一种是新增的业务逻辑,因为新增的业务逻辑相对来说不成熟,往往具备一定的风险,所以需要加开关来控制新业务逻辑是否执行;另一种是依赖的服务或资源,因为依赖的服务或者资源不总是可靠的,所以最好是有开关能够控制是否对依赖服务或资源发起调用,来保证即使依赖出现问题,也能通过降级来避免影响。

在实际业务应用的时候,降级要按照对业务的影响程度进行分级,一般分为三级:一级降级是对业务影响最小的降级,在故障的情况下,首先执行一级降级,所以一级降级也可以设置成自动降级,不需要人为干预;二级降级是对业务有一定影响的降级,在故障的情况下,如果一级降级起不到多大作用的时候,可以人为采取措施,执行二级降级;三级降级是对业务有较大影响的降级,这种降级要么是对商业收入有重大影响,要么是对用户体验有重大影响,所以操作起来要非常谨慎,不在最后时刻一般不予采用。

DEVOPS

容器和容器平台

Mesos、Marathon、Kubernetes

RPC 选型

限定语言 RPC

跟语言平台绑定的开源 RPC 框架主要有下面几种。

  • Dubbo:国内最早开源的 RPC 框架,由阿里巴巴公司开发并于 2011 年末对外开源,仅支持 Java 语言。
  • Motan:微博内部使用的 RPC 框架,于 2016 年对外开源,仅支持 Java 语言。
  • Tars:腾讯内部使用的 RPC 框架,于 2017 年对外开源,仅支持 C++ 语言。
  • Spring Cloud:国外 Pivotal 公司 2014 年对外开源的 RPC 框架,仅支持 Java 语言,最近几年生态发展得比较好,是比较火的 RPC 框架。

所以很明显,如果你的业务场景仅仅局限于一种语言的话,可以选择跟语言绑定的 RPC 框架中的一种;如果涉及多个语言平台之间的相互调用,就应该选择跨语言平台的 RPC 框架。

仔细分析,可以看出 Spring Cloud 不仅提供了基本的 RPC 框架功能,还提供了服务注册组件、配置中心组件、负载均衡组件、断路器组件、分布式消息追踪组件等一系列组件,也难怪被技术圈的人称之为“Spring Cloud 全家桶”。如果你不想自己实现以上这些功能,那么 Spring Cloud 基本可以满足你的全部需求。而 Dubbo、Motan 基本上只提供了最基础的 RPC 框架的功能,其他微服务组件都需要自己去实现。不过由于 Spring Cloud 的 RPC 通信采用了 HTTP 协议,相比 Dubbo 和 Motan 所采用的私有协议来说,在高并发的通信场景下,性能相对要差一些,所以对性能有苛刻要求的情况下,可以考虑 Dubbo 和 Motan。

跨语言 RPC

而跨语言平台的开源 RPC 框架主要有以下几种。

  • gRPC:Google 于 2015 年对外开源的跨语言 RPC 框架,支持常用的 C++、Java、Python、Go、Ruby、PHP、Android Java、Objective-C 等多种语言。
  • Thrift:最初是由 Facebook 开发的内部系统跨语言的 RPC 框架,2007 年贡献给了 Apache 基金,成为 Apache 开源项目之一,支持常用的 C++、Java、PHP、Python、Ruby、Erlang 等多种语言。

从成熟度上来讲,Thrift 因为诞生的时间要早于 gRPC,所以使用的范围要高于 gRPC,在 HBase、Hadoop、Scribe、Cassandra 等许多开源组件中都得到了广泛地应用。而且 Thrift 支持多达 25 种语言,这要比 gRPC 支持的语言更多,所以如果遇到 gRPC 不支持的语言场景下,选择 Thrift 更合适。

但 gRPC 作为后起之秀,因为采用了 HTTP/2 作为通信协议、ProtoBuf 作为数据序列化格式,在移动端设备的应用以及对传输带宽比较敏感的场景下具有很大的优势,而且开发文档丰富,根据 ProtoBuf 文件生成的代码要比 Thrift 更简洁一些,从使用难易程度上更占优势,所以如果使用的语言平台 gRPC 支持的话,建议还是采用 gRPC 比较好。

Service Mesh

img

Service Mesh 的实现原理

Service Mesh 实现的关键就在于两点:

一个是上面提到的轻量级的网络代理也叫 SideCar,它的作用就是转发服务之间的调用;

一个是基于 SideCar 的服务治理也被叫作 Control Plane,它的作用是向 SideCar 发送各种指令,以完成各种服务治理功能。下面我就来详细讲解这两点是如何实现的。

参考资料

MySQL 架构

大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。

Server 层包括连接器、查询缓存、解析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。

存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。

MySQL 查询流程

SQL 语句在 MySQL 中是如何执行的?

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

  1. 连接器 - 客户端和 MySQL 服务器建立连接;连接器负责跟客户端建立连接获取权限维持和管理连接
  2. 查询缓存 - MySQL 服务器首先检查查询缓存,如果命中缓存,则立刻返回结果。否则进入下一阶段。MySQL 缓存弊大于利,因为失效非常频繁——任何更新都会清空查询缓存。
  3. 分析器 - MySQL 服务器进行 SQL 解析:语法分析词法分析
  4. 优化器 - MySQL 服务器用优化器生成对应的执行计划根据策略选择最优索引
  5. 执行器 - MySQL 服务器根据执行计划,调用存储引擎的 API 来执行查询
  6. 返回结果 - MySQL 服务器将结果返回给客户端,同时缓存查询结果。

连接器

使用 MySQL 第一步自然是要连接数据库。连接器负责跟客户端建立连接、获取权限、维持和管理连接

MySQL 客户端/服务端通信是半双工模式:即任一时刻,要么是服务端向客户端发送数据,要么是客户端向服务器发送数据。客户端用一个单独的数据包将查询请求发送给服务器,所以当查询语句很长的时候,需要设置 max_allowed_packet 参数。但是需要注意的是,如果查询实在是太大,服务端会拒绝接收更多数据并抛出异常。

MySQL 客户端连接命令形式为:mysql -h<主机> -P<端口> -u<用户名> -p<密码>。如果没有显式指定密码,会要求输入密码才能访问。

连接完成后,如果没有后续的动作,这个连接就处于空闲状态可以执行 show processlist 命令查看当前有多少个客户端连接客户端如果空闲太久,连接器就会自动将它断开。客户端连接维持时间是由参数 wait_timeout 控制的,默认值是 8 小时。如果在连接被断开之后,客户端再次发送请求的话,就会收到一个错误提醒: Lost connection to MySQL server during query。这时候如果你要继续,就需要重连,然后再执行请求了。

建立连接的过程通常是比较复杂的,建议在使用中要尽量减少建立连接的动作,也就是尽量使用长连接。为了在程序中提高数据库连接的服用了,一般会使用数据库连接池来维护管理。

但是全部使用长连接后,你可能会发现,有些时候 MySQL 占用内存涨得特别快,这是因为 MySQL 在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是 MySQL 异常重启了。

怎么解决这个问题呢?你可以考虑以下两种方案。

  • 定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
  • 如果你用的是 MySQL 5.7 或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。

查询缓存

不建议使用数据库缓存,因为往往弊大于利

解析一个查询语句前,如果查询缓存是打开的,那么 MySQL 会检查这个查询语句是否命中查询缓存中的数据。如果当前查询恰好命中查询缓存,在检查一次用户权限后直接返回缓存中的结果。这种情况下,查询不会被解析,也不会生成执行计划,更不会执行。

MySQL 将缓存存放在一个引用表(不要理解成table,可以认为是类似于HashMap的数据结构),通过一个哈希值索引,这个哈希值通过查询本身、当前要查询的数据库、客户端协议版本号等一些可能影响结果的信息计算得来。所以两个查询在任何字符上的不同(例如:空格、注释),都会导致缓存不会命中。

如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、mysql 库中的系统表,其查询结果都不会被缓存。比如函数NOW()或者CURRENT_DATE()会因为不同的查询时间,返回不同的查询结果,再比如包含CURRENT_USER或者CONNECION_ID()的查询语句会因为不同的用户而返回不同的结果,将这样的查询结果缓存起来没有任何的意义。

不建议使用数据库缓存,因为往往弊大于利。查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。除非你的业务就是有一张静态表,很长时间才会更新一次。比如,一个系统配置表,那这张表上的查询才适合使用查询缓存。

好在 MySQL 也提供了这种“按需使用”的方式。你可以将参数 query_cache_type 设置成 DEMAND,这样对于默认的 SQL 语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用 SQL_CACHE 显式指定,像下面这个语句一样:

1
select SQL_CACHE * from T where ID=10;

注意:MySQL 8.0 版本直接将查询缓存的整块功能删掉了。

分析器

如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL 需要知道你要做什么,因此需要对 SQL 语句做解析。MySQL 通过关键字对 SQL 语句进行解析,并生成一颗对应的语法解析树。这个过程中,分析器主要通过语法规则来验证和解析。比如 SQL 中是否使用了错误的关键字或者关键字的顺序是否正确等等。预处理则会根据 MySQL 规则进一步检查解析树是否合法。比如检查要查询的数据表和数据列是否存在等等。

  • 分析器先会先做“词法分析”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。MySQL 从你输入的”select”这个关键字识别出来,这是一个查询语句。它也要把字符串“T”识别成“表名 T”,把字符串“ID”识别成“列 ID”。
  • 接下来,要做“语法分析”。根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。如果你的语句不对,就会收到“You have an error in your SQL syntax”的错误提醒,比如下面这个语句 select 少打了开头的字母“s”。

优化器

经过了分析器,MySQL 就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。

经过前面的步骤生成的语法树被认为是合法的了,并且由优化器将其转化成执行计划。多数情况下,一条查询可以有很多种执行方式,最后都返回相应的结果。优化器的作用就是找到这其中最好的执行计划。

MySQL 使用基于成本的优化器,它尝试预测一个查询使用某种执行计划时的成本,并选择其中成本最小的一个。在 MySQL 可以通过查询当前会话的 last_query_cost 的值来得到其计算当前查询的成本。

1
2
3
4
5
6
7
8
9
mysql> select * from t_message limit 10;
...省略结果集

mysql> show status like 'last_query_cost';
+-----------------+-------------+
| Variable_name | Value |
+-----------------+-------------+
| Last_query_cost | 6391.799000 |
+-----------------+-------------+

示例中的结果表示优化器认为大概需要做 6391 个数据页的随机查找才能完成上面的查询。这个结果是根据一些列的统计信息计算得来的,这些统计信息包括:每张表或者索引的页面个数、索引的基数、索引和数据行的长度、索引的分布情况等等。

有非常多的原因会导致 MySQL 选择错误的执行计划,比如统计信息不准确、不会考虑不受其控制的操作成本(用户自定义函数、存储过程)、MySQL 认为的最优跟我们想的不一样(我们希望执行时间尽可能短,但 MySQL 值选择它认为成本小的,但成本小并不意味着执行时间短)等等。

MySQL 的查询优化器是一个非常复杂的部件,它使用了非常多的优化策略来生成一个最优的执行计划:

  • 重新定义表的关联顺序(多张表关联查询时,并不一定按照 SQL 中指定的顺序进行,但有一些技巧可以指定关联顺序)
  • 优化MIN()MAX()函数(找某列的最小值,如果该列有索引,只需要查找 B+Tree 索引最左端,反之则可以找到最大值,具体原理见下文)
  • 提前终止查询(比如:使用 Limit 时,查找到满足数量的结果集后会立即终止查询)
  • 优化排序(在老版本 MySQL 会使用两次传输排序,即先读取行指针和需要排序的字段在内存中对其排序,然后再根据排序结果去读取数据行,而新版本采用的是单次传输排序,也就是一次读取所有的数据行,然后根据给定的列排序。对于 I/O 密集型应用,效率会高很多)

随着 MySQL 的不断发展,优化器使用的优化策略也在不断的进化,这里仅仅介绍几个非常常用且容易理解的优化策略,其他的优化策略,大家自行查阅吧。

执行器

在完成解析和优化阶段以后,MySQL 会生成对应的执行计划,查询执行引擎根据执行计划给出的指令逐步执行得出结果。整个执行过程的大部分操作均是通过调用存储引擎实现的接口来完成,这些接口被称为 handler API。查询过程中的每一张表由一个 handler 实例表示。实际上,MySQL 在查询优化阶段就为每一张表创建了一个 handler 实例,优化器可以根据这些实例的接口来获取表的相关信息,包括表的所有列名、索引统计信息等。存储引擎接口提供了非常丰富的功能,但其底层仅有几十个接口,这些接口像搭积木一样完成了一次查询的大部分操作。

返回结果

查询过程的最后一个阶段就是将结果返回给客户端。即使查询不到数据,MySQL 仍然会返回这个查询的相关信息,比如该查询影响到的行数以及执行时间等等。

如果查询缓存被打开且这个查询可以被缓存,MySQL 也会将结果存放到缓存中。

结果集返回客户端是一个增量且逐步返回的过程。有可能 MySQL 在生成第一条结果时,就开始向客户端逐步返回结果集了。这样服务端就无须存储太多结果而消耗过多内存,也可以让客户端第一时间获得返回结果。需要注意的是,结果集中的每一行都会以一个数据包发送,再通过 TCP 协议进行传输,在传输过程中,可能对 MySQL 的数据包进行缓存然后批量发送。

MySQL 更新流程

MySQL 更新过程和 MySQL 查询过程类似,也会将流程走一遍。不一样的是:更新流程还涉及两个重要的日志模块,: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. 执行器先找引擎取主键所在的行记录。如果这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
  2. 执行器拿到引擎给的行数据,设置数值,得到新的一行数据,再调用引擎接口写入这行新数据。
  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,与原库的值不同。
  1. 先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。

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

参考资料

MySQL 索引

索引是提高 MySQL 查询性能的一个重要途径,但过多的索引可能会导致过高的磁盘使用率以及过高的内存占用,从而影响应用程序的整体性能。应当尽量避免事后才想起添加索引,因为事后可能需要监控大量的 SQL 才能定位到问题所在,而且添加索引的时间肯定是远大于初始添加索引所需要的时间,可见索引的添加也是非常有技术含量的。

接下来将向你展示一系列创建高性能索引的策略,以及每条策略其背后的工作原理。但在此之前,先了解与索引相关的一些算法和数据结构,将有助于更好的理解后文的内容。

索引简介

“索引”是数据库为了提高查找效率的一种数据结构

日常生活中,我们可以通过检索目录,来快速定位书本中的内容。索引和数据表,就好比目录和书,想要高效查询数据表,索引至关重要。在数据量小且负载较低时,不恰当的索引对于性能的影响可能还不明显;但随着数据量逐渐增大,性能则会急剧下降。因此,设置合理的索引是数据库查询性能优化的最有效手段

索引的优缺点

B 树是最常见的索引,按照顺序存储数据,所以 MySQL 可以用来做 ORDER BYGROUP BY 操作。因为数据是有序的,所以 B 树也就会将相关的列值都存储在一起。最后,因为索引中存储了实际的列值,所以某些查询只使用索引就能够完成全部查询。

✔️️️️ 索引的优点:

  • 索引大大减少了服务器需要扫描的数据量,从而加快检索速度。
  • 索引可以帮助服务器避免排序和临时表
  • 索引可以将随机 I/O 变为顺序 I/O
  • 支持行级锁的数据库,如 InnoDB 会在访问行的时候加锁。使用索引可以减少访问的行数,从而减少锁的竞争,提高并发
  • 唯一索引可以确保每一行数据的唯一性,通过使用索引,可以在查询的过程中使用优化隐藏器,提高系统的性能。

❌ 索引的缺点:

  • 创建和维护索引要耗费时间,这会随着数据量的增加而增加。
  • 索引需要占用额外的物理空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,如果要建立组合索引那么需要的空间就会更大。
  • 写操作(INSERT/UPDATE/DELETE)时很可能需要更新索引,导致数据库的写操作性能降低

基于以上,可以归纳出索引的基本使用规则:

  • 索引不是越多越好,不要为所有列都创建索引
  • 要尽量避免冗余和重复索引
  • 要考虑删除未使用的索引
  • 尽量的扩展索引,不要新建索引
  • 频繁作为 WHERE 过滤条件的列应该考虑添加索引

何时使用索引

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

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

❌ 什么情况不适用索引?

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

索引的分类

MySQL 索引可以从以下四个维度来分类:

  • 按【数据结构】分类:B+tree索引、Hash索引、Full-text索引
  • 按【物理存储】分类:聚簇索引、二级索引(辅助索引)
  • 按【字段特性】分类:主键索引、普通索引、前缀索引
  • 按【字段个数】分类:单列索引、联合索引(复合索引、组合索引)

索引的数据结构

在 MySQL 中,索引是在存储引擎层而不是服务器层实现的,所以,并没有统一的索引标准。不同存储引擎的索引的数据结构也不相同。下面是 MySQL 常用存储引擎对一些主要索引数据结构的支持:

索引数据结构/存储引擎 InnoDB 引擎 MyISAM 引擎 Memory 引擎
B+Tree 索引 ✔️️️️️ ✔️️️️️ ✔️️️️️
Hash 索引 ✔️️️️️
Full Text 索引 ✔️️️️️ ✔️️️️️

下面,我们将逐一探讨各种可能作为索引的数据结构,了解其特性、利弊、应用场景。相信通过这样的对比,可以让读者更加明确 MySQL 中为什么选择某些数据结构作为索引,而放弃了另外一些数据结构,依据是什么。

有序数组

“数组”用连续的内存空间来存储数据,并且支持随机访问

有序数组可以使用二分查找法,其时间复杂度为 O(log n),无论是等值查询还是范围查询,都非常高效。

但有序数组有两个重要限制:

  • 数组的空间大小固定,如果要扩容只能采用复制数组的方式,比较低效。
  • 插入、删除操作开销较大,时间复杂度为 O(n) (要保证数组有序)。

这意味着,如果直接使用有序数组作为索引,为了保证数组有序,其更新操作代价高昂。正因为如此,几乎没有数据库会采用有序数组作为索引。

哈希索引

哈希表是一种以键 - 值(key-value)对形式存储数据的结构,我们只要输入待查找的值即 key,就可以找到其对应的值即 Value。

“哈希表”使用哈希函数组织数据,以支持快速插入和搜索的数据结构。哈希表的本质是一个数组,其思路是:使用哈希函数将 Key 转换为数组下标,利用数组的随机访问特性,使得我们能在 O(1) 的时间代价内完成检索。

img

有两种不同类型的哈希表:哈希集合哈希映射

  • 哈希集合是集合数据结构的实现之一,用于存储非重复值。
  • 哈希映射是映射数据结构的实现之一,用于存储键值对。

哈希索引基于哈希表实现,只适用于等值查询。对于每一行数据,哈希索引都会将所有的索引列计算一个哈希码(hashcode),哈希码是一个较小的值。哈希索引将所有的哈希码存储在索引中,同时在哈希表中保存指向每个数据行的指针。

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

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

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

❌ 哈希索引的缺点

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

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

B+ 树索引

B 树是最常见的索引,按照顺序存储数据,所以 MySQL 可以用来做 ORDER BYGROUP BY 操作。因为数据是有序的,所以 B 树也就会将相关的列值都存储在一起。最后,因为索引中存储了实际的列值,所以某些查询只使用索引就能够完成全部查询。

通常我们所说的索引是指 B-Tree 索引,它是目前关系型数据库中查找数据最为常用和有效的索引,大多数存储引擎都支持这种索引。使用 B-Tree 这个术语,是因为 MySQL 在CREATE TABLE或其它语句中使用了这个关键字,但实际上不同的存储引擎可能使用不同的数据结构,比如 InnoDB 就是使用的 B+Tree。

B+Tree 中的 B 是指balance,意为平衡。需要注意的是,B+树索引并不能找到一个给定键值的具体行,它找到的只是被查找数据行所在的页,接着数据库会把页读入到内存,再在内存中进行查找,最后得到要查找的数据。

二叉搜索树

二叉搜索树的特点是:每个节点的左儿子小于父节点,父节点又小于右儿子。其查询时间复杂度是 $$O(log(N))$$。

当然为了维持 $$O(log(N))$$ 的查询复杂度,你就需要保持这棵树是平衡二叉树。为了做这个保证,更新的时间复杂度也是 $$O(log(N))$$。

随着数据库中数据的增加,索引本身大小随之增加,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘 I/O 消耗,相对于内存存取,I/O 存取的消耗要高几个数量级。可以想象一下一棵几百万节点的二叉树的深度是多少?如果将这么大深度的一颗二叉树放磁盘上,每读取一个节点,需要一次磁盘的 I/O 读取,整个查找的耗时显然是不能够接受的。那么如何减少查找过程中的 I/O 存取次数?

一种行之有效的解决方法是减少树的深度,将二叉树变为 N 叉树(多路搜索树),而 B+ 树就是一种多路搜索树

B+ 树

B+ 树索引适用于全键值查找键值范围查找键前缀查找,其中键前缀查找只适用于最左前缀查找。

理解 B+Tree 时,只需要理解其最重要的两个特征即可:

  • 第一,所有的关键字(可以理解为数据)都存储在叶子节点,非叶子节点并不存储真正的数据,所有记录节点都是按键值大小顺序存放在同一层叶子节点上。
  • 其次,所有的叶子节点由指针连接。如下图为简化了的 B+Tree。

img

聚簇索引和非聚簇索引

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

  • 主键索引又被称为“聚簇索引(clustered index)”,其叶子节点存的是整行数据
    • 聚簇表示数据行和相邻的键值紧凑地存储在一起,因为数据紧凑,所以访问快。
    • 因为无法同时把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引
    • InnoDB 的聚簇索引实际是在同一个结构中保存了 B 树的索引和数据行。
  • 非主键索引又被称为“二级索引(secondary index)”,其叶子节点存的是主键的值。数据存储在一个位置,索引存储在另一个位置,索引中包含指向数据存储位置的指针。可以有多个,小于 249 个。

聚簇索引和非聚簇索引的查询有什么区别

  • 如果语句是 select * from T where ID=500,即聚簇索引查询方式,则只需要搜索主键(ID)索引树;
  • 如果语句是 select * from T where k=5,即非聚簇索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表

也就是说,基于非聚簇索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。

显然,主键长度越小,非聚簇索引的叶子节点就越小,非聚簇索引占用的空间也就越小。

在创建表时,InnoDB 存储引擎会根据不同的场景选择不同的列作为索引:

  • 如果有主键,默认会使用主键作为聚簇索引的索引键(key);
  • 如果没有主键,就选择第一个不包含 NULL 值的唯一列作为聚簇索引的索引键(key);
  • 在上面两个都没有的情况下,InnoDB 将自动生成一个隐式自增 id 列作为聚簇索引的索引键(key);

自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT。从性能和存储空间方面考量,自增主键往往是更合理的选择。有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的:

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

由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。

这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。

全文索引

MyISAM 存储引擎支持全文索引,用于查找文本中的关键词,而不是直接比较是否相等。查找条件使用 MATCH AGAINST,而不是普通的 WHERE。

全文索引一般使用倒排索引实现,它记录着关键词到其所在文档的映射。

InnoDB 存储引擎在 MySQL 5.6.4 版本中也开始支持全文索引。

全文索引主要用来查找文本中的关键字,而不是直接与索引中的值相比较。

全文索引跟其它索引大不相同,它更像是一个搜索引擎,而不是简单的 WHERE 语句的参数匹配。全文索引配合 match against 操作使用,而不是一般的 WHERE 语句加 LIKE。它可以在 CREATE TABLEALTER TABLECREATE INDEX 使用,不过目前只有 charvarchartext 列上可以创建全文索引。值得一提的是,在数据量较大时候,先将数据放入一个没有全局索引的表中,然后再用 CREATE INDEX 创建全文索引,要比先为一张表建立全文索引然后再将数据写入的速度快很多。

1
2
3
4
5
CREATE TABLE `table` (
`content` text CHARACTER NULL,
...
FULLTEXT (content)
)

空间数据索引

MyISAM 存储引擎支持空间数据索引(R-Tree),可以用于地理数据存储。空间数据索引会从所有维度来索引数据,可以有效地使用任意维度来进行组合查询。

必须使用 GIS 相关的函数来维护数据。

主键索引

主键索引:一种特殊的唯一索引,不允许有空值。一个表只能有一个主键(在 InnoDB 中本质上即聚簇索引),一般是在建表的时候同时创建主键索引。

1
2
3
4
5
CREATE TABLE `user` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
# ...
PRIMARY KEY (`id`)
);

唯一索引

“唯一索引”确保索引中的值是唯一的,不允许有重复值,如果是组合索引,则列值的组合必须唯一

在 MySQL 中,可以使用 CREATE UNIQUE INDEX 语句来创建唯一索引。

直接创建唯一索引:

1
CREATE UNIQUE INDEX `uniq_name` ON `user`(`name`);

创建表时,添加唯一索引示例:

1
2
3
4
5
6
CREATE TABLE `user` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_name`(`name`)
);

修改表时,添加唯一索引示例:

1
2
ALTER TABLE `user`
ADD UNIQUE `uniq_name`(`name`);

普通索引

普通索引是最基本的索引,没有任何限制。

直接创建索引:

1
CREATE INDEX `idx_name` ON `user`(`name`);

创建表时,添加索引示例:

1
2
3
4
5
6
CREATE TABLE `user` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
KEY `idx_name`(`name`)
);

修改表时,添加索引示例:

1
2
ALTER TABLE `user`
ADD INDEX `idx_name`(`name`);

前缀索引

有时候需要索引很长的字符列,这使得存储索引占用大量空间,且导致查询变慢。这种情况下,可以使用前缀索引。

“前缀索引”是指索引开始的部分字符。对于 BLOB/TEXT 这种文本类型的列,必须使用前缀索引,因为数据库往往不允许索引这些列的完整长度。

✔️️️️ 前缀索引的优点是:

  • 可以大大节约索引空间,从而提高索引效率

❌ 前缀索引的缺点是:

  • 会降低索引的区分度
  • 此外,**order by 无法使用前缀索引,无法把前缀索引用作覆盖索引**。

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

直接创建前缀索引:

1
CREATE INDEX `idx_name` ON `user`(`name`(10));

创建表时,添加前缀索引示例:

1
2
3
4
5
6
CREATE TABLE `user` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
KEY `idx_name`(`name`(10))
);

修改表时,添加前缀索引示例:

1
2
ALTER TABLE `user`
ADD INDEX `idx_name`(`name`(10));

那么,如何确定前缀索引合适的长度呢?

可以使用下面这个语句,算出这个列上有多少个不同的值:

1
select count(distinct email) as L from SUser;

然后,依次选取不同长度的前缀来看这个值,比如我们要看一下 4~7 个字节的前缀索引,可以用这个语句:

1
2
3
4
5
6
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
2
3
4
5
6
7
8
9
10
create table T (
ID int primary key,
k int NOT NULL DEFAULT 0,
s varchar(16) NOT NULL DEFAULT '',
index k(k))
engine=InnoDB;

insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg');

select * from T where k between 3 and 5

需要执行几次树的搜索操作,会扫描多少行?

  1. 在 k 索引树上找到 k=3 的记录,取得 ID = 300;
  2. 再到 ID 索引树查到 ID=300 对应的 R3;
  3. 在 k 索引树取下一个值 k=5,取得 ID=500;
  4. 再回到 ID 索引树查到 ID=500 对应的 R4;
  5. 在 k 索引树取下一个值 k=6,不满足条件,循环结束。

在这个过程中,回到聚簇索引树搜索的过程,称为“回表”。可以看到,这个查询过程读了 k 索引树的 3 条记录(步骤 1、3 和 5),回表了两次(步骤 2 和 4)。

如果执行的语句是 select ID from T where k between 3 and 5,这时只需要查 ID 的值,而 ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要回表。索引包含所有需要查询的字段的值,称为覆盖索引。

由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段

最左匹配原则

不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这里的最左,可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符

如果是联合索引,那么 key 也由多个列组成,同时,索引只能用于查找 key 是否存在(相等),遇到范围查询 (><BETWEENLIKE) 就不能进一步匹配了,后续退化为线性查找。因此,列的排列顺序决定了可命中索引的列数

应该将选择性高的列或基数大的列优先排在多列索引最前列。但有时,也需要考虑 WHERE 子句中的排序、分组和范围条件等因素,这些因素也会对查询性能造成较大影响。

“索引的选择性”是指不重复的索引值和记录总数的比值,最大值为 1,此时每个记录都有唯一的索引与其对应。索引的选择性越高,查询效率越高。如果存在多条命中前缀索引的情况,就需要依次扫描,直到最终找到正确记录。

例如下面显示的结果中 customer_id 的选择性比 staff_id 更高,因此最好把 customer_id 列放在多列索引的前面。

1
2
3
4
SELECT COUNT(DISTINCT staff_id)/COUNT(*) AS staff_id_selectivity,
COUNT(DISTINCT customer_id)/COUNT(*) AS customer_id_selectivity,
COUNT(*)
FROM payment;
1
2
3
   staff_id_selectivity: 0.0001
customer_id_selectivity: 0.0373
COUNT(*): 16049

使用索引来排序

MySQL 有两种方式可以生成排序结果:通过排序操作;或者按索引顺序扫描。

索引最好既满足排序,又用于查找行。这样,就可以通过命中覆盖索引直接将结果查出来,也就不再需要排序了。

这样整个查询语句的执行流程就变成了:

  1. 从索引 (city,name,age) 找到第一个满足 city=’杭州’条件的记录,取出其中的 city、name 和 age 这三个字段的值,作为结果集的一部分直接返回;
  2. 从索引 (city,name,age) 取下一个记录,同样取出这三个字段的值,作为结果集的一部分直接返回;
  3. 重复执行步骤 2,直到查到第 1000 条记录,或者是不满足 city=’杭州’条件时循环结束。

= 和 in 可以乱序

不需要考虑 =IN 等的顺序,MySQL 会自动优化这些条件的顺序,以匹配尽可能多的索引列。

【示例】如有索引 (a, b, c, d),查询条件 c > 3 and b = 2 and a = 1 and d < 4a = 1 and c > 3 and b = 2 and d < 4 等顺序都是可以的,MySQL 会自动优化为 a = 1 and b = 2 and c > 3 and d < 4,依次命中 a、b、c、d。

索引失效的场景

创建了索引,并非一定有效。比如不满足前缀索引、最左前缀匹配原则、查询条件涉及函数计算等情况都无法使用索引。此外,即使 SQL 本身符合索引的使用条件,MySQL 也会通过评估各种查询方式的代价,来决定是否走索引,以及走哪个索引。

对索引使用左模糊匹配

使用左或者左右模糊匹配的时候,也就是 like %xx 或者 like %xx% 这两种方式都会造成索引失效。这是因为:B+ 树索引是按照“索引值”有序存储的,只能根据前缀进行比较。

对索引使用函数或表达式

查询语句中,如果对索引字段使用“函数”或“表达式”,会导致索引失效

因为索引树存储的是索引字段的原始值,因此无法索引经过函数计算或表达式计算后的值。

❌ 错误示例:

1
2
SELECT actor_id FROM actor WHERE actor_id + 1 = 5;
SELECT ... WHERE TO_DAYS(current_date) - TO_DAYS(date_col) <= 10;

对索引隐式类型转换

查询语句中,如果对索引字段进行隐式类型转换,会导致索引失效。由于隐式类型转换是通过 CAST 函数实现的,等同于对索引列使用了函数,所以会导致索引失效。

联合索引不遵循最左匹配原则

联合索引如果不遵循最左匹配原则,就会导致索引失效。原因是,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序。

索引列判空

索引列与 NULL 或者 NOT NULL 进行判断的时候也会失效。这是因为索引并不存储空值,所以最好在设计数据表的时候就将字段设置为 NOT NULL 约束,比如你可以将 INT 类型的字段,默认值设置为 0。将字符类型的默认值设置为空字符串 (’’)。

WHERE 子句中的 OR 前后条件存在非索引列

在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。

比如下面的 SQL 语句,comment_id 是主键,而 comment_text 没有进行索引,因为 OR 的含义就是两个只要满足一个即可,因此只有一个条件列进行了索引是没有意义的,只要有条件列没有进行索引,就会进行全表扫描,因此索引的条件列也会失效:

1
EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id = 900001 OR comment_text = '462eed7ac6e791292a79'

运行结果:

1
2
3
4
5
+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| 1 | SIMPLE | product_comment | NULL | ALL | PRIMARY | NULL | NULL | NULL | 996663 | 10.00 | Using where |
+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

如果我们把 comment_text 创建了索引会是怎样的呢?

1
2
3
4
5
+----+-------------+-----------------+------------+-------------+----------------------+----------------------+---------+------+------+----------+------------------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------------+------------+-------------+----------------------+----------------------+---------+------+------+----------+------------------------------------------------+
| 1 | SIMPLE | product_comment | NULL | index_merge | PRIMARY,comment_text | PRIMARY,comment_text | 4,767 | NULL | 2 | 100.00 | Using union(PRIMARY,comment_text); Using where |
+----+-------------+-----------------+------------+-------------+----------------------+----------------------+---------+------+------+----------+------------------------------------------------+

你能看到这里使用到了 index merge,简单来说 index merge 就是对 comment_id 和 comment_text 分别进行了扫描,然后将这两个结果集进行了合并。这样做的好处就是避免了全表扫描。

参考资料

Java 并发之分工工具

对于简单的并行任务,你可以通过“线程池 + Future”的方案来解决;如果任务之间有聚合关系,无论是 AND 聚合还是 OR 聚合,都可以通过 CompletableFuture 来解决;而批量的并行任务,则可以通过 CompletionService 来解决。

FutureTask

FutureTask 有两个构造函数:

1
2
FutureTask(Callable<V> callable);
FutureTask(Runnable runnable, V result);

FutureTask 实现了 RunnableFuture 接口。由于实现了 Runnable 接口,所以可以将 FutureTask 对象作为任务提交给 ThreadPoolExecutor 去执行,也可以直接被 Thread 执行;又因为实现了 Future 接口,所以也能用来获得任务的执行结果。

下面,通过一组示例来展示 FutureTask 如何分别交给线程池、线程执行。

::: tabs#创建 FutureTask 示例

@tab FutureTask 交给线程池执行

【示例】FutureTask 交给线程池执行

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
public class FutureTaskDemo {

public static void main(String[] args) throws ExecutionException, InterruptedException {
// 创建 FutureTask
Task task = new Task();
FutureTask<String> f1 = new FutureTask<>(task);
FutureTask<String> f2 = new FutureTask<>(task);

// 创建线程池
ExecutorService executor = Executors.newCachedThreadPool();
executor.submit(f1);
executor.submit(f2);
System.out.println(f1.get());
System.out.println(f2.get());
executor.shutdown();
}

static class Task implements Callable<String> {

@Override
public String call() {
return Thread.currentThread().getName() + " 执行成功!";
}

}

}
// 输出
// pool-1-thread-1 执行成功!
// pool-1-thread-2 执行成功!

@tab FutureTask 交给线程执行

【示例】FutureTask 交给线程执行

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
public class FutureTaskDemo2 {

public static void main(String[] args) throws InterruptedException, ExecutionException {

// 创建 FutureTask
Task task = new Task();
FutureTask<String> f1 = new FutureTask<>(task);
FutureTask<String> f2 = new FutureTask<>(task);

// 创建线程
new Thread(f1).start();
new Thread(f2).start();
System.out.println(f1.get());
System.out.println(f2.get());
}

static class Task implements Callable<String> {

@Override
public String call() {
return Thread.currentThread().getName() + " 执行成功!";
}

}

}
// 输出
// Thread-0 执行成功!
// Thread-1 执行成功!

@tab 用 FutureTask 完成并行计算

【示例】用 FutureTask 完成并行计算

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
public class FutureTaskDemo3 {

public static void main(String[] args) throws InterruptedException, ExecutionException {

// 创建一个线程池来执行任务
ExecutorService executor = Executors.newFixedThreadPool(2);

// 创建两个 Callable 对象
Callable<Integer> t1 = () -> {
int result = 0;
for (int i = 1; i <= 100; i++) {
result += i;
}
return result;
};
Callable<Integer> t2 = () -> {
int result = 0;
for (int i = 101; i <= 200; i++) {
result += i;
}
return result;
};

// 创建两个 FutureTask 对象
FutureTask<Integer> f1 = new FutureTask<>(t1);
FutureTask<Integer> f2 = new FutureTask<>(t2);

// 提交任务到线程池执行
executor.execute(f1);
executor.execute(f2);

// 获取任务的结果
Integer value1 = f1.get();
Integer value2 = f2.get();
System.out.println("total = " + value1 + value2);

// 关闭线程池
executor.shutdown();
}

}

:::

CompletableFuture

JDK8 提供了 CompletableFuture 来支持异步编程。

CompletableFuture 提供了四个静态方法来创建一个异步操作。

1
2
3
4
5
6
7
// 使用默认线程池
public static CompletableFuture<Void> runAsync(Runnable runnable) { // 省略 }
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) { // 省略 }

// 使用自定义线程池
public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor) { // 省略 }
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor) { // 省略 }

上面的 4 个静态方法中,有 2 个 runAsync 方法,2 个 supplyAsync 方法,它们的区别是:

  • runAsync 方法没有返回值。
  • supplyAsync 方法有返回值。

默认情况下 CompletableFuture 会使用公共的 ForkJoinPool 线程池,这个线程池默认创建的线程数是 CPU 的核数(也可以通过 JVM option: -Djava.util.concurrent.ForkJoinPool.common.parallelism 来设置 ForkJoinPool 线程池的线程数)。如果所有 CompletableFuture 共享一个线程池,那么一旦有任务执行一些很慢的 I/O 操作,就会导致线程池中所有线程都阻塞在 I/O 操作上,从而造成线程饥饿,进而影响整个系统的性能。所以,强烈建议你要根据不同的业务类型创建不同的线程池,以避免互相干扰

CompletionStage

CompletionStage 接口可以清晰地描述任务之间的时序关系,如串行关系、并行关系、汇聚关系等。

串行关系

CompletionStage 接口里面描述串行关系,主要是 thenApplythenAcceptthenRunthenCompose 这四个系列的接口。

thenApply 系列函数里参数 fn 的类型是接口 Function<T, R>,这个接口里与 CompletionStage 相关的方法是 R apply(T t),这个方法既能接收参数也支持返回值,所以 thenApply 系列方法返回的是CompletionStage<R>

thenAccept 系列方法里参数 consumer 的类型是接口 Consumer<T>,这个接口里与 CompletionStage 相关的方法是 void accept(T t),这个方法虽然支持参数,但却不支持回值,所以 thenAccept 系列方法返回的是CompletionStage<Void>

thenRun 系列方法里 action 的参数是 Runnable,所以 action 既不能接收参数也不支持返回值,所以 thenRun 系列方法返回的也是 CompletionStage<Void>

这些方法里面 Async 代表的是异步执行 fnconsumer 或者 action。其中,需要你注意的是 thenCompose 系列方法,这个系列的方法会新创建出一个子流程,最终结果和 thenApply 系列是相同的。

1
2
3
4
5
6
7
8
CompletionStage<R> thenApply(fn);
CompletionStage<R> thenApplyAsync(fn);
CompletionStage<Void> thenAccept(consumer);
CompletionStage<Void> thenAcceptAsync(consumer);
CompletionStage<Void> thenRun(action);
CompletionStage<Void> thenRunAsync(action);
CompletionStage<R> thenCompose(fn);
CompletionStage<R> thenComposeAsync(fn);

描述 AND 汇聚关系

CompletionStage 接口里面描述 AND 汇聚关系,主要是 thenCombinethenAcceptBothrunAfterBoth 系列的接口,这些接口的区别也是源自 fnconsumeraction 这三个核心参数不同。

1
2
3
4
5
6
CompletionStage<R> thenCombine(other, fn);
CompletionStage<R> thenCombineAsync(other, fn);
CompletionStage<Void> thenAcceptBoth(other, consumer);
CompletionStage<Void> thenAcceptBothAsync(other, consumer);
CompletionStage<Void> runAfterBoth(other, action);
CompletionStage<Void> runAfterBothAsync(other, action);

描述 OR 汇聚关系

CompletionStage 接口里面描述 OR 汇聚关系,主要是 applyToEitheracceptEitherrunAfterEither 系列的接口,这些接口的区别也是源自 fnconsumeraction 这三个核心参数不同。

1
2
3
4
5
6
CompletionStage applyToEither(other, fn);
CompletionStage applyToEitherAsync(other, fn);
CompletionStage acceptEither(other, consumer);
CompletionStage acceptEitherAsync(other, consumer);
CompletionStage runAfterEither(other, action);
CompletionStage runAfterEitherAsync(other, action);

下面的示例代码展示了如何使用 applyToEither() 方法来描述一个 OR 汇聚关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> {
int t = getRandom(5, 10);
sleep(t, TimeUnit.SECONDS);
return String.valueOf(t);
});

CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> {
int t = getRandom(5, 10);
sleep(t, TimeUnit.SECONDS);
return String.valueOf(t);
});

CompletableFuture<String> f3 = f1.applyToEither(f2, s -> s);
System.out.println(f3.join());

异常处理

虽然上面我们提到的 fnconsumeraction 它们的核心方法都不允许抛出可检查异常,但是却无法限制它们抛出运行时异常,例如下面的代码,执行 7/0 就会出现除零错误这个运行时异常。非异步编程里面,我们可以使用 try {} catch {} 来捕获并处理异常,那在异步编程里面,异常该如何处理呢?

1
2
3
CompletableFuture<Integer> f = CompletableFuture.supplyAsync(() -> (7 / 0))
.thenApply(r -> r * 10);
System.out.println(f.join());

CompletionStage 接口给我们提供的方案非常简单,比 try {} catch {} 还要简单,下面是相关的方法,使用这些方法进行异常处理和串行操作是一样的,都支持链式编程方式。

1
2
3
4
5
CompletionStage exceptionally(fn);
CompletionStage<R> whenComplete(consumer);
CompletionStage<R> whenCompleteAsync(consumer);
CompletionStage<R> handle(fn);
CompletionStage<R> handleAsync(fn);

下面的示例代码展示了如何使用 exceptionally() 方法来处理异常,exceptionally() 的使用非常类似于 try {} catch {} 中的 catch {},但是由于支持链式编程方式,所以相对更简单。既然有 try {} catch {},那就一定还有 try {} catch {}whenComplete()handle() 系列方法就类似于 try {} catch {} 中的 finally {},无论是否发生异常都会执行 whenComplete() 中的回调函数 consumerhandle() 中的回调函数 fnwhenComplete()handle() 的区别在于 whenComplete() 不支持返回结果,而 handle() 是支持返回结果的。

1
2
3
4
CompletableFuture<Integer> f = CompletableFuture.supplyAsync(() -> 7 / 0)
.thenApply(r -> r * 10)
.exceptionally(e -> 0);
System.out.println(f.join());

CompletionService

CompletionService 接口的实现类是 ExecutorCompletionService,这个实现类的构造方法有两个,分别是:

  1. ExecutorCompletionService(Executor executor)
  2. ExecutorCompletionService(Executor executor, BlockingQueue<Future<V>> completionQueue)

这两个构造方法都需要传入一个线程池,如果不指定 completionQueue,那么默认会使用无界的 LinkedBlockingQueue。任务执行结果的 Future 对象就是加入到 completionQueue 中。

下面的示例代码完整地展示了如何利用 CompletionService 来实现高性能的询价系统。其中,我们没有指定 completionQueue,因此默认使用无界的 LinkedBlockingQueue。之后通过 CompletionService 接口提供的 submit() 方法提交了三个询价操作,这三个询价操作将会被 CompletionService 异步执行。最后,我们通过 CompletionService 接口提供的 take() 方法获取一个 Future 对象(前面我们提到过,加入到阻塞队列中的是任务执行结果的 Future 对象),调用 Future 对象的 get() 方法就能返回询价操作的执行结果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 创建线程池
ExecutorService executor = Executors.newFixedThreadPool(3);
// 创建 CompletionService
CompletionService<Integer> cs = new ExecutorCompletionService<>(executor);
// 异步向电商 S1 询价
cs.submit(()->getPriceByS1());
// 异步向电商 S2 询价
cs.submit(()->getPriceByS2());
// 异步向电商 S3 询价
cs.submit(()->getPriceByS3());
// 将询价结果异步保存到数据库
for (int i=0; i<3; i++) {
Integer r = cs.take().get();
executor.execute(()->save(r));
}

CompletionService 接口提供的方法有 5 个,这 5 个方法的方法签名如下所示。

其中,submit() 相关的方法有两个。一个方法参数是Callable<V> task,前面利用 CompletionService 实现询价系统的示例代码中,我们提交任务就是用的它。另外一个方法有两个参数,分别是Runnable taskV result,这个方法类似于 ThreadPoolExecutor 的 <T> Future<T> submit(Runnable task, T result) ,这个方法在 《23 | Future:如何用多线程实现最优的“烧水泡茶”程序?》 中我们已详细介绍过,这里不再赘述。

CompletionService 接口其余的 3 个方法,都是和阻塞队列相关的,take()、poll() 都是从阻塞队列中获取并移除一个元素;它们的区别在于如果阻塞队列是空的,那么调用 take() 方法的线程会被阻塞,而 poll() 方法会返回 null 值。 poll(long timeout, TimeUnit unit) 方法支持以超时的方式获取并移除阻塞队列头部的一个元素,如果等待了 timeout unit 时间,阻塞队列还是空的,那么该方法会返回 null 值。

1
2
3
4
5
Future<V> submit(Callable<V> task);
Future<V> submit(Runnable task, V result);
Future<V> take() throws InterruptedException;
Future<V> poll();
Future<V> poll(long timeout, TimeUnit unit) throws InterruptedException;

当需要批量提交异步任务的时候建议你使用 CompletionService。CompletionService 将线程池 Executor 和阻塞队列 BlockingQueue 的功能融合在了一起,能够让批量异步任务的管理更简单。除此之外,CompletionService 能够让异步任务的执行结果有序化,先执行完的先进入阻塞队列,利用这个特性,你可以轻松实现后续处理的有序性,避免无谓的等待,同时还可以快速实现诸如 Forking Cluster 这样的需求。

CompletionService 的实现类 ExecutorCompletionService,需要你自己创建线程池,虽看上去有些啰嗦,但好处是你可以让多个 ExecutorCompletionService 的线程池隔离,这种隔离性能避免几个特别耗时的任务拖垮整个应用的风险。

ForkJoinPool

Fork/Join 是一个并行计算的框架,主要就是用来支持分治任务模型的,这个计算框架里的** Fork 对应的是分治任务模型里的任务分解,Join 对应的是结果合并。Fork/Join 计算框架主要包含两部分,一部分是分治任务的线程池 ForkJoinPool,另一部分是分治任务 ForkJoinTask**。这两部分的关系类似于 ThreadPoolExecutor 和 Runnable 的关系,都可以理解为提交任务到线程池,只不过分治任务有自己独特类型 ForkJoinTask。

ForkJoinTask 是一个抽象类,它的方法有很多,最核心的是 fork() 方法和 join() 方法,其中 fork() 方法会异步地执行一个子任务,而 join() 方法则会阻塞当前线程来等待子任务的执行结果。ForkJoinTask 有两个子类——RecursiveAction 和 RecursiveTask,通过名字你就应该能知道,它们都是用递归的方式来处理分治任务的。这两个子类都定义了抽象方法 compute(),不过区别是 RecursiveAction 定义的 compute() 没有返回值,而 RecursiveTask 定义的 compute() 方法是有返回值的。这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。

Fork/Join 并行计算的核心组件是 ForkJoinPool,所以下面我们就来简单介绍一下 ForkJoinPool 的工作原理。

ForkJoinPool 本质上也是一个生产者 - 消费者的实现,但是更加智能,你可以参考下面的 ForkJoinPool 工作原理图来理解其原理。ThreadPoolExecutor 内部只有一个任务队列,而 ForkJoinPool 内部有多个任务队列,当我们通过 ForkJoinPool 的 invoke() 或者 submit() 方法提交任务时,ForkJoinPool 根据一定的路由规则把任务提交到一个任务队列中,如果任务在执行过程中会创建出子任务,那么子任务会提交到工作线程对应的任务队列中。

如果工作线程对应的任务队列空了,是不是就没活儿干了呢?不是的,ForkJoinPool 支持一种叫做“任务窃取”的机制,如果工作线程空闲了,那它可以“窃取”其他工作任务队列里的任务,例如下图中,线程 T2 对应的任务队列已经空了,它可以“窃取”线程 T1 对应的任务队列的任务。如此一来,所有的工作线程都不会闲下来了。

ForkJoinPool 中的任务队列采用的是双端队列,工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费,这样能避免很多不必要的数据竞争。我们这里介绍的仅仅是简化后的原理,ForkJoinPool 的实现远比我们这里介绍的复杂,如果你感兴趣,建议去看它的源码。

img

【示例】模拟 MapReduce 统计单词数量

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

static void main(String[] args) {
String[] fc = { "hello world",
"hello me",
"hello fork",
"hello join",
"fork join in world" };
//创建 ForkJoin 线程池
ForkJoinPool fjp = new ForkJoinPool(3);
//创建任务
MR mr = new MR(fc, 0, fc.length);
//启动任务
Map<String, Long> result = fjp.invoke(mr);
//输出结果
result.forEach((k, v) -> System.out.println(k + ":" + v));
}

//MR 模拟类
static class MR extends RecursiveTask<Map<String, Long>> {

private String[] fc;
private int start, end;

//构造函数
MR(String[] fc, int fr, int to) {
this.fc = fc;
this.start = fr;
this.end = to;
}

@Override
protected Map<String, Long> compute() {
if (end - start == 1) {
return calc(fc[start]);
} else {
int mid = (start + end) / 2;
MR mr1 = new MR(fc, start, mid);
mr1.fork();
MR mr2 = new MR(fc, mid, end);
//计算子任务,并返回合并的结果
return merge(mr2.compute(),
mr1.join());
}
}

//合并结果
private Map<String, Long> merge(Map<String, Long> r1, Map<String, Long> r2) {
Map<String, Long> result = new HashMap<>();
result.putAll(r1);
//合并结果
r2.forEach((k, v) -> {
Long c = result.get(k);
if (c != null) { result.put(k, c + v); } else { result.put(k, v); }
});
return result;
}

//统计单词数量
private Map<String, Long> calc(String line) {
Map<String, Long> result = new HashMap<>();
//分割单词
String[] words = line.split("\\s+");
//统计单词数量
for (String w : words) {
Long v = result.get(w);
if (v != null) { result.put(w, v + 1); } else { result.put(w, 1L); }
}
return result;
}
}

参考资料

Redis 面试

Redis 简介

【基础】什么是 Redis?

::: details 要点

什么是 Redis?

Redis 是一个开源的、数据存于内存中的 K-V 数据库。由于,Redis 的读写操作都是在内存中完成,因此其读写速度非常快

  • 高性能 - 由于,Redis 的读写操作都是在内存中完成,因此性能极高。
  • 高并发 - Redis 单机 QPS 能达到 10w+,将近是 Mysql 的 10 倍。

Redis 常被用于缓存,消息队列、分布式锁等场景

Redis 有什么功能和特性?

Redis 的功能和特性:

  • Redis 支持多种数据类型。如:String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理空间)、Stream(流)。
  • Redis 的读写采用“单线程”模型,因此,其操作天然就具有原子性。需要注意的是,Redis 6.0 后在其网络模块中引入了多线程 I/O 机制。
  • Redis 支持两种持久化策略:RDB 和 AOF。
  • Redis 有多种高可用方案:主从复制模式、哨兵模式、集群模式。
  • Redis 支持很多丰富的特性,如:事务Lua 脚本发布订阅过期删除内存淘汰等等。

图来自 Redis Explained

:::

【基础】Redis 有哪些应用场景?

::: details 要点

Redis 常见应用场景如下:

  • 缓存 - 将热点数据放到内存中,设置内存的最大使用量以及过期淘汰策略来保证缓存的命中率。
  • 计数器 - Redis 这种内存数据库能支持计数器频繁的读写操作。
  • 应用限流 - 限制一个网站访问流量。
  • 消息队列 - 使用 List 数据类型,它是双向链表。
  • 查找表 - 使用 HASH 数据类型。
  • 聚合运算 - 使用 SET 类型,例如求两个用户的共同好友。
  • 排行榜 - 使用 ZSET 数据类型。
  • 分布式 Session - 多个应用服务器的 Session 都存储到 Redis 中来保证 Session 的一致性。
  • 分布式锁 - 除了可以使用 SETNX 实现分布式锁之外,还可以使用官方提供的 RedLock 分布式锁实现。

:::

【基础】Redis 有哪些里程碑版本?

::: details 要点

Redis 里程碑版本如下:

  • Redis 1.0(2010 年) - Redis 1.0 发布,采用单机架构,一般作为业务应用的缓存。但是 Redis 的数据是存在内存中的,重启 Redis 时,数据会全部丢失,流量直接打到数据库。
  • Redis 2.8(2013 年)
    • 持久化 - Redis 引入了 RDB 内存快照来持久化数据。它还支持 AOF(仅追加文件),其中每个写入命令都写入 AOF 文件。
    • 复制 - 添加了复制功能以提高可用性。主实例处理实时读写请求,而副本同步主实例的数据。
    • 哨兵 - 引入了 Sentinel 来实时监控 Redis 实例。Sentinel 是一个旨在帮助管理 Redis 实例的系统。它执行以下四个任务:监控、通知、自动故障转移和共享配置。
  • Redis 3.0(2015 年) - 官方提供了 redis-cluster。redis-cluster 是一种分布式数据库解决方案,通过分片管理数据。数据被分成 16384 个槽,每个节点负责槽的一部分。
  • Redis 5.0(2017 年) - 新增 Stream 数据类型。
  • Redis 6.0(2020 年) - 在网络模块中引入了多线程 I/O。Redis 模型分为网络模块和主处理模块。特别注意:Redis 不再完全是单线程架构。

:::

【基础】对比一下 Redis 和 Memcached?

::: details 要点

  • Redis 和 Memcached 有什么相同点?
  • Redis 和 Memcached 有什么差异?
  • 分布式缓存技术选型,选 Redis 还是 Memcached,为什么?

Redis 与 Memcached 的共性

  • 都是内存数据库,因此性能都很高。
  • 都有过期策略。

因为以上两点,所以常被作为缓存使用。

Redis 与 Memcached 的差异

核心差异对比:

Memcached Redis
数据类型 只支持 String 类型 支持多种数据类型:String、Hash、List、Set、ZSet 等
持久化 不支持持久化,一旦重启或宕机就会丢失数据 支持两种持久化策略:RDB 和 AOF
分布式 本身不支持分布式,只能通过在客户端使用像一致性哈希这样的分布式算法来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点 支持分布式
线程模型 采用多线程+IO 多路复用。在 100k 以上的数据中,Memcached 性能要高于 Redis 读写采用单线程+IO 多路复用。因此存储小数据时比 Memcached 性能更高
其他功能 不支持 支持发布订阅模型、Lua 脚本、事务等功能

通过以上分析,可以看出,Redis 在很多方面都占有优势。因此,绝大多数情况下,优先选择 Redis 作为分布式缓存。

扩展《脚踏两只船的困惑 - Memcached 与 Redis》

:::

【基础】Redis 有哪些 Java 客户端?各有什么优劣?

::: details 要点

Redis 的主流 Java 客户端有三种,对比如下:

客户端 线程安全 自动重连 编程模型 适用场景
Jedis 同步 简单应用、快速开发
Lettuce 同步/异步/响应式 高并发、Spring Boot 项目
Redisson 同步/异步 分布式系统、高级功能需求

推荐选择

  • 基础需求 → Jedis(简单直接)。
  • 高并发/Spring 项目 → Lettuce(默认选择)。
  • 分布式锁/队列等 → Redisson(功能强大)。

:::

::: details 细节

Jedis

✅ 优点

  • 简单易用:API 直观,适合快速上手。
  • 广泛使用:社区支持丰富,文档齐全。
  • 性能良好:常规操作高效。
  • 功能全面:支持字符串、哈希、列表等基础数据结构。

❌ 缺点

  • 非线程安全:需为每个线程创建独立实例。
  • 无自动重连:网络异常需手动处理。
  • 同步阻塞:高并发时可能成为性能瓶颈。

Lettuce

✅ 优点

  • 线程安全:多线程共享同一连接。
  • 高性能:基于 Netty 实现,支持高并发。
  • 自动重连:网络中断后自动恢复。
  • 多编程模型:支持同步、异步、响应式(如 Reactive API)。

❌ 缺点

  • API 较复杂:学习成本高于 Jedis。
  • 资源消耗:异步模式可能占用更多内存/CPU。

Redisson

✅ 优点

  • 分布式支持:内置分布式锁、队列、缓存等高级功能。
  • 线程安全:天然适配多线程场景。
  • 集群友好:完善支持 Redis 集群模式。
  • 稳定性高:企业级应用验证。

❌ 缺点

  • 学习曲线陡峭:需掌握分布式概念。
  • 依赖兼容性:可能与其他库冲突需调优。

:::

Redis 内存管理

【中级】Redis 支持哪些过期删除策略?

::: details 要点

  • Redis 支持哪些过期删除策略?
  • 常见的过期策略有哪些,Redis 的选择考量是什么?

Redis 采用的过期策略是:定期删除+惰性删除

  • 定时删除 - 在设置 key 的过期时间的同时,创建一个定时器,让定时器在 key 的过期时间来临时,立即执行 key 的删除操作。
    • 优点 - 保证过期 key 被尽可能快的删除,释放内存。
    • 缺点 - 如果过期 key 较多,可能会占用相当一部分的 CPU,从而影响服务器的吞吐量和响应时延
  • 惰性删除 - 放任 key 过期不管,但是每次访问 key 时,都检查 key 是否过期,如果过期的话,就删除该 key ;如果没有过期,就返回该 key。
    • 优点 - 占用 CPU 最少。程序只会在读写键时,对当前键进行过期检查,因此不会有额外的 CPU 开销。
    • 缺点 - 过期的 key 可能因为没有被访问,而一直无法释放,造成内存的浪费,有内存泄漏的风险
  • 定期删除 - 每隔一段时间,程序就对数据库进行一次检查,删除里面的过期 key。至于要删除多少过期 key ,以及要检查多少个数据库,则由算法决定。定期删除是前两种策略的一种折中方案。定期删除策略的难点是删除操作执行的时长和频率。
    • 执行太频或执行时间过长,就会出现和定时删除相同的问题;
    • 执行太少或执行时间过短,就会出现和惰性删除相同的问题;

:::

【中级】Redis 有哪些内存淘汰策略?

::: details 要点

  • Redis 内存不足时,怎么办?
  • Redis 有哪些内存淘汰策略?
  • 如何选择内存淘汰策略?

Redis 内存淘汰要点

  • 失效时间 - 作为一种定期清理无效数据的重要机制,在 Redis 提供的诸多命令中,EXPIREEXPIREATPEXPIREPEXPIREAT 以及 SETEXPSETEX 均可以用来设置一条键值对的失效时间。而一条键值对一旦被关联了失效时间就会在到期后自动删除(或者说变得无法访问更为准确)。
  • 最大缓存 - Redis 允许通过 maxmemory 参数来设置内存最大值。当内存达设定的阀值,就会触发内存淘汰
  • 内存淘汰 - 内存淘汰是为了更好的利用内存——清理部分缓存,以此换取内存的利用率,即尽量保证 Redis 缓存中存储的是热点数据。

Redis 内存淘汰策略

  • 不淘汰
    • noeviction - 当内存使用达到阈值的时候,所有引起申请内存的命令会报错。这是 Redis 默认的策略。
  • 在过期键中进行淘汰
    • volatile-random - 在设置了过期时间的键空间中,随机移除某个 key。
    • volatile-ttl - 在设置了过期时间的键空间中,具有更早过期时间的 key 优先移除。
    • volatile-lru - 在设置了过期时间的键空间中,优先移除最近未使用的 key。
    • volatile-lfu (Redis 4.0 新增)- 淘汰所有设置了过期时间的键值中,最少使用的键值。
  • 在所有键中进行淘汰
    • allkeys-random - 在主键空间中,随机移除某个 key。
    • allkeys-lru - 在主键空间中,优先移除最近未使用的 key。
    • allkeys-lfu (Redis 4.0 新增) - 淘汰整个键值中最少使用的键值。

如何选择内存淘汰策略

  • 如果数据呈现正态分布,也就是一部分数据访问频率高,一部分数据访问频率低,则使用 allkeys-lruallkeys-lfu
  • 如果数据呈现平均分布,也就是所有的数据访问频率都相同,则使用 allkeys-random
  • 若 Redis 既用于缓存,也用于持久化存储时,适用 volatile-lruvolatile-lfuvolatile-random。但是,这种情况下,也可以部署两个 Redis 集群来达到同样目的。
  • 为 key 设置过期时间实际上会消耗更多的内存。因此,如果条件允许,建议使用 allkeys-lruallkeys-lfu,从而更高效的使用内存。

:::

【中级】Redis 持久化时,对过期键会如何处理?

::: details 要点

RDB 持久化

  • RDB 文件生成阶段 - 从内存状态持久化成 RDB(文件)的时候,会对 key 进行过期检查,过期的键“不会”被保存到新的 RDB 文件中,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。
  • RDB 加载阶段 - RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况:
    • 如果 Redis 是“主服务器”运行模式的话,在载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键“不会”被载入到数据库中。所以过期键不会对载入 RDB 文件的主服务器造成影响;
    • 如果 Redis 是“从服务器”运行模式的话,在载入 RDB 文件时,不论键是否过期都会被载入到数据库中。但由于主从服务器在进行数据同步时,从服务器的数据会被清空。所以一般来说,过期键对载入 RDB 文件的从服务器也不会造成影响。

AOF 持久化

  • AOF 文件写入阶段 - 当 Redis 以 AOF 模式持久化时,如果数据库某个过期键还没被删除,那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值
  • AOF 重写阶段 - 执行 AOF 重写时,会对 Redis 中的键值对进行检查,已过期的键不会被保存到重写后的 AOF 文件中,因此不会对 AOF 重写造成任何影响。

:::

【中级】Redis 主从复制时,对过期键会如何处理?

::: details 要点

当 Redis 运行在主从模式下时,从库不会进行过期扫描,从库对过期的处理是被动的。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。

从库的过期键处理依靠主服务器控制,主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。

:::

【中级】Redis 中的内存碎片化是什么?如何进行优化?

::: details 要点

Redis 内存碎片化是指已分配的内存无法被有效利用,导致内存浪费的现象。

可以通过 Redis 的 INFO memory 命令查看:

1
mem_fragmentation_ratio: 1.86  # 大于 1.5 表示碎片较多

内存碎片的原因

  • 内存分配器机制 - Redis 使用 Jemalloc 或 glibc 的 malloc 等内存分配器,这些分配器为了性能不会总是精确分配请求的大小,可能分配稍大的块。
  • 键值对频繁修改 - 当键值对被频繁修改(特别是大小变化时),旧的内存空间可能无法重用。例如:字符串值从 128B 改为 256B,原空间不够需要新分配。
  • 键过期/删除 - 删除键释放的内存块可能无法与相邻空闲块合并,这些碎片空间可能无法满足新的大内存请求。
  • 不同大小的数据混合存储 - Redis 存储各种大小的键值对,导致内存中出现大小不一的空闲块。

内存碎片的影响

  • 内存分配策略:不同的分配器 (Jemalloc/libc 等)碎片率不同
  • 工作负载模式:频繁修改和删除操作会增加碎片
  • 数据大小分布:大小差异大的数据混合存储更容易产生碎片

内存碎片的解决

  • 重启 Redis(会丢失数据)
  • 使用 MEMORY PURGE 命令(需要特定分配器支持)
  • 配置合理的 maxmemory 和淘汰策略
  • 对于高碎片环境,可考虑使用 Redis 4.0+ 的主动碎片整理功能

:::

Redis 持久化

【中级】Redis 如何保证数据不丢失?

::: details 要点

  • Redis 如何保证数据不丢失?
  • Redis 有几种持久化方式?

为了追求性能,Redis 的读写都是在内存中完成的。一旦重启,内存中的数据就会清空,为了保证数据不丢失,Redis 支持持久化机制。

Redis 有三种持久化方式

  • RDB 快照
  • AOF 日志
  • 混合持久化

RDB

  • RDB 的实现原理是什么?
  • 生成 RDB 快照时,Redis 可以响应请求吗?

有两个 Redis 命令可以用于生成 RDB 文件:SAVEBGSAVE

SAVE 命令由服务器进程直接执行保存操作,直到 RDB 创建完成为止。所以该命令“会阻塞”服务器,在阻塞期间,服务器不能响应任何命令请求。

BGSAVE 命令会“派生”(fork)一个子进程,由子进程负责创建 RDB 文件,服务器进程继续处理命令请求,所以该命令“不会阻塞”服务器

🔔 【注意】

BGSAVE 命令的实现采用的是写时复制技术(Copy-On-Write,缩写为 CoW)。

BGSAVE 命令执行期间,SAVEBGSAVEBGREWRITEAOF 三个命令会被拒绝,以免与当前的 BGSAVE 操作产生竞态条件,降低性能。

AOF

  • AOF 的实现原理是什么?
  • 为什么先执行命令,再把数据写入日志呢?

Redis 命令请求会先保存到 AOF 缓冲区,再定期写入并同步到 AOF 文件

AOF 的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。

  • 命令追加 - 当 Redis 服务器开启 AOF 功能时,服务器在执行完一个写命令后,会以 Redis 命令协议格式将被执行的写命令追加到 AOF 缓冲区的末尾。
  • 文件写入文件同步
    • Redis 的服务器进程就是一个事件循环,这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复。而时间事件则负责执行想 serverCron 这样的定时运行的函数。
    • 因为服务器在处理文件事件时可能会执行写命令,这些写命令会被追加到 AOF 缓冲区,服务器每次结束事件循环前,都会根据 appendfsync 选项来判断 AOF 缓冲区内容是否需要写入和同步到 AOF 文件中。

先执行命令,再把数据写入 AOF 日志有两个好处:

  • 避免额外的检查开销
  • 不会阻塞当前写操作命令的执行

当然,这样做也会有弊端:

  • 数据可能会丢失:
  • 可能阻塞其他操作:

混合持久化

Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。

混合持久化的工作机制

  • 触发时机:在 AOF 重写过程中启用。
  • 执行流程
    1. 子进程将共享内存数据以 RDB 格式写入 AOF 文件(全量数据)。
    2. 主线程将操作命令记录到重写缓冲区,再以 AOF 格式追加到 AOF 文件(增量数据)。
    3. 替换旧 AOF 文件,新文件包含 RDB(前半部分) + AOF(后半部分)

混合持久化的优点

  • 重启速度快:优先加载 RDB 部分(全量数据恢复快)。
  • 数据丢失少:后续加载 AOF 部分(增量数据补充)。

混合持久化的缺点

  • 可读性差:AOF 文件包含二进制 RDB 数据,不易阅读。
  • 兼容性差:仅支持 Redis 4.0+ 版本,旧版本无法识别。

:::

【中级】AOF 的缓冲区回写策略有几种?

::: details 要点

Redis 命令请求会先保存到 AOF 缓冲区,再定期写入并同步到 AOF 文件

appendfsync 不同选项决定了不同的持久化行为:

  • always - 将 AOF 缓冲区中所有内容写入并同步到 AOF 文件。这种方式是最数据最安全的,但也是性能最差的。
  • no - 将 AOF 缓冲区所有内容写入到 AOF 文件,但并不对 AOF 文件进行同步,何时同步由操作系统决定。这种方式是数据最不安全的,一旦出现故障,未来得及同步的所有数据都会丢失。
  • everysec - appendfsync 默认选项。将 AOF 缓冲区所有内容写入到 AOF 文件,如果上次同步 AOF 文件的时间距离现在超过一秒钟,那么再次对 AOF 文件进行同步,这个同步操作是有一个线程专门负责执行的。这张方式是前面两种的这种方案——性能足够好,且即使出现故障,仅丢失一秒钟内的数据。

appendfsync 选项的不同值对 AOF 持久化功能的安全性、以及 Redis 服务器的性能有很大的影响。

:::

【中级】AOF 的重写机制是怎样的?

::: details 要点

  • AOF 日志过大时,怎么办?
  • AOF 重写流程是怎样的?
  • AOF 重写时,可以处理请求吗?

知识点

当 AOF 日志过大时,恢复过程就会很久。为了避免此问题,Redis 提供了 AOF 重写机制,即 AOF 日志大小超过所设阈值后,启动 AOF 重写,压缩 AOF 文件。

AOF 重写机制是,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到新的 AOF 日志中,等到全部记录完成后,就使用新的 AOF 日志替换现有的 AOF 日志。

作为一种辅助性功能,显然 Redis 并不想在 AOF 重写时阻塞 Redis 服务接收其他命令。因此,Redis 决定通过 BGREWRITEAOF 命令创建一个子进程,然后由子进程负责对 AOF 文件进行重写,这与 BGSAVE 原理类似。

  • 在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区。当 AOF 重写子进程开始工作后,Redis 每执行完一个写命令,会同时将这个命令发送给 AOF 缓冲区和 AOF 重写缓冲区。
  • 由于彼此不是在同一个进程中工作,AOF 重写不影响 AOF 写入和同步。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新旧两个 AOF 文件所保存的数据库状态一致。
  • 最后,服务器用新的 AOF 文件替换就的 AOF 文件,以此来完成 AOF 重写操作。

:::

Redis 批处理

【中级】Redis 支持事务吗?

::: details 要点

  • Redis 支持事务吗?
  • Redis 事务如何工作?

知识点

Redis 支持事务。MULTIEXECDISCARDWATCH 是 Redis 事务相关的命令。

MULTI 命令用于开启一个事务,它总是返回 OK。MULTI 执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当 EXEC 命令被调用时,所有队列中的命令才会被执行。

EXEC 命令负责触发并执行事务中的所有命令。

  • 如果客户端在使用 MULTI 开启了一个事务之后,却因为断线而没有成功执行 EXEC ,那么事务中的所有命令都不会被执行。
  • 另一方面,如果客户端成功在开启事务之后执行 EXEC ,那么事务中的所有命令都会被执行。

当执行 DISCARD 命令时,事务会被放弃,事务队列会被清空,并且客户端会从事务状态中退出。

WATCH 命令可以为 Redis 事务提供 check-and-set (CAS)行为。WATCH 的键会被监视,并会发觉这些键是否被改动过了。 如果有至少一个被监视的键在 EXEC 执行之前被修改了,那么整个事务都会被取消,EXEC 返回 nil-reply 来表示事务已经失败。

WATCH 可以用于创建 Redis 没有内置的原子操作。

举个例子,以下代码实现了原创的 ZPOP 命令,它可以原子地弹出有序集合中分值(score)最小的元素:

1
2
3
4
5
WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC

:::

【中级】Redis 事务是严格意义的事务吗?

::: details 要点

  • Redis 事务是严格意义的事务吗?
  • Redis 事务为什么不支持回滚?

ACID 是数据库事务正确执行的四个基本要素。

  • 原子性(Atomicity)
    • 事务被视为不可分割的最小单元,事务中的所有操作要么全部提交成功,要么全部失败回滚
    • 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
  • 一致性(Consistency)
    • 数据库在事务执行前后都保持一致性状态。
    • 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
  • 隔离性(Isolation)
    • 一个事务所做的修改在最终提交以前,对其它事务是不可见的。
  • 持久性(Durability)
    • 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
    • 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。

一个支持事务(Transaction)中的数据库系统,必需要具有这四种特性,否则在事务过程(Transaction processing)当中无法保证数据的正确性。

Redis 仅支持“非严格”的事务。所谓“非严格”是指:

  • Redis 事务保证全部执行命令 - Redis 事务中的多个命令会被打包到事务队列中,然后按先进先出(FIFO)的顺序执行。事务在执行过程中不会被中断,当事务队列中的所有命令都被执行完毕之后,事务才会结束。
  • Redis 事务不支持回滚 - 如果命令执行失败不会回滚,而是会继续执行下去。

Redis 官方的 事务特性文档 给出的不支持回滚的理由是:

  • Redis 命令只会因为错误的语法而失败,或是命令用在了错误类型的键上面。
  • 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

:::

【中级】Redis Pipeline 能保证原子性吗?

::: details 要点

先说结论:Redis Pipeline 不保证原子性

Redis Pipeline(管道)是一种客户端技术,用于将多个 Redis 命令批量发送到服务器,减少网络往返时间(RTT),提高吞吐量。

  • 传统模式:客户端发送一条命令 → 等待响应 → 再发送下一条(高延迟)。
  • Pipeline 模式:客户端一次性发送多条命令 → 服务器按顺序执行 → 一次性返回所有结果(低延迟)。

核心特性

特性 说明
批量发送 客户端打包多条命令,一次性发送,减少网络开销
非原子性 Pipeline 只是批量发送,不保证所有命令连续执行(可能被其他客户端命令打断)
高性能 相比单条命令模式,吞吐量可提升 5~10 倍
无回滚 如果某条命令失败,不会影响其他命令的执行

注意事项

  • 命令数量控制:避免单次 Pipeline 发送过多命令(建议每批 ≤ 1 万条),否则可能阻塞 Redis。
  • 集群模式限制:Redis Cluster 要求 Pipeline 的所有 Key 必须在同一个 Slot(可用 {hash_tag} 确保,如 user:{123}:name)。
  • 错误处理:Pipeline 返回的是一个列表,需逐条检查命令是否成功。
  • 与事务的区别:Pipeline 不保证原子性。

适用场景

适合

  • 批量写入(如日志上报、缓存预热)
  • 批量查询(如获取多个 Key 的值)
  • 对原子性无要求的高并发场景

不适合

  • 需要事务保证原子性的操作(改用 MULTI/EXEC
  • 命令之间有依赖关系(如后一条命令依赖前一条的结果)

:::

【中级】Redis Lua 脚本有什么用?

::: details 要点

Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。Redis 的 Lua 脚本提供了一种原子性执行多个命令的方式。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。

并且,Lua 脚本中支持一些简单的逻辑处理比如使用命令读取值并在 Lua 脚本中进行处理,这同样是 pipeline 所不具备的。

不过, Lua 脚本依然存在下面这些缺陷:

  • 如果 Lua 脚本运行时出错并中途结束,之后的操作不会进行,但是之前已经发生的写操作不会撤销,所以即使使用了 Lua 脚本,也不能实现类似数据库回滚的原子性。
  • Redis Cluster 下 Lua 脚本的原子操作也无法保证,原因同样是无法保证所有的 key 都在同一个 hash slot(哈希槽)上。

另外,Redis 7.0 新增了 Redis functions 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。

:::

Redis 高可用

【中级】Redis 如何实现主从复制?

::: details 要点

  • Redis 复制的工作原理?Redis 旧版复制和新版复制有何不同?
  • Redis 主从节点间如何复制数据?
  • Redis 的数据一致性是强一致性吗?

(1)旧版复制基于 SYNC 命令实现。分为同步(sync)和命令传播(command propagate)两个操作。这种方式存在缺陷:不能高效处理断线重连后的复制情况。

(2)新版复制基于 PSYNC 命令实现。同步操作分为了两块:

  • 完整重同步(full resychronization) 用于初次复制;
  • 部分重同步(partial resychronization) 用于断线后重复制。
    • 主从服务器的复制偏移量(replication offset)
    • 主服务器的复制积压缓冲区(replication backlog)
    • 服务器的运行 ID

(3)Redis 集群主从节点复制的工作流程:

  1. 设置主从服务器
  2. 主从服务器建立 TCP 连接。
  3. 发送 PING 检查通信状态。
  4. 身份验证。
  5. 发送端口信息。
  6. 同步。
  7. 命令传播。

(4)由于主从复制是异步的,具体来说,在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。

:::

【中级】Redis 哨兵是如何工作的?

::: details 要点

  • Redis 哨兵的功能?
  • Redis 哨兵的原理?
  • Redis 哨兵如何选举 Leader?
  • Redis 如何实现故障转移?

(1)Redis 主从复制模式无法自动故障转移,也就是说,一旦主服务器宕机,需要手动恢复。为了解决此问题,Redis 增加了哨兵模式(Sentinel)。

(2)由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器,以及这些主服务器的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

:::

【中级】Redis 集群是如何工作的?

::: details 要点

集群架构

  • 节点类型
    • 主节点 (master):负责处理槽
    • 从节点 (slave):复制主节点,主节点下线时可接管
  • 节点通信:使用 Gossip 协议 (PING/PONG/MEET/FAIL/PUBLISH 消息)
  • 数据分区
    • 哈希槽分配:CRC16(key) mod 16384
    • 槽指派命令:CLUSTER ADDSLOTS
    • 所有槽必须分配,否则集群下线

关键机制

  • 请求路由
    • 计算 key 所属槽
    • MOVED 错误重定向(永久重定向)
    • ASK 错误重定向(临时重定向,迁移过程中使用)
  • 故障转移
    • 故障检测:PING 消息+半数以上主节点确认为 FAIL 状态
    • 选主流程:基于 Raft 协议,需要获得 N/2+1 票
    • 转移步骤:从节点升级为主节点并接管槽
  • 重新分区
    • 在线迁移槽和键值对
    • 使用 redis-trib 工具操作

使用限制

  • 功能限制
    • 批量操作/事务/Pipeline/lua 仅支持相同 slot 的 key
    • 不支持多数据库(仅 db0)
    • 复制结构只支持一层
  • 规模限制
    • 适合中小规模集群(几个到几十个节点)
    • Gossip 协议在大规模集群中传播效率低

:::

【高级】Redis 中的脑裂问题是如何产生的?

::: details 要点

在 Redis 主从架构中,部署方式一般是“一主多从”,主节点提供写操作,从节点提供读操作。 如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程 A),此时这些数据被旧主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的。

这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会在“从节点”中选举出一个 leader 作为主节点,这时集群就有两个主节点了 —— 脑裂出现了

然后,网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,因为第一次同步是全量同步的方式,此时的从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是集群产生脑裂数据丢失的问题

总结一句话就是:由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。

:::

::: details 细节

分布式系统的脑裂问题(Split-Brain Problem)是一个严重的一致性问题,通常发生在分布式系统中的节点之间失去通信或部分通信时。这个问题的名称源自脑裂的比喻,就像一个分布式系统被分成多个部分的”脑”,每个部分独立运行,而没有协调一致的方式。

脑裂问题通常发生在以下情况下:

  1. 网络分区:当分布式系统中的网络发生问题,导致节点之间无法互相通信或只能部分通信时。这可能是由于网络故障、硬件故障、防火墙配置问题等原因引起的。
  2. 节点故障:当分布式系统的某个节点崩溃或出现故障,但其他节点无法确定该节点的状态,可能导致脑裂问题。

脑裂问题的典型情况是,在网络分区或节点故障后,分布式系统的一部分节点认为另一部分节点已经不可用,因此开始采取某种措施,比如选举新的领袖或切换到备份模式。然而,在某些情况下,网络分区可能会解除,或者节点故障可能会自行修复,导致系统中存在多个独立运行的子系统,每个子系统都认为自己是正确的。

这种情况下,脑裂问题可能导致以下问题:

  1. 数据不一致性:不同子系统可能具有不同的数据状态,这可能会导致数据不一致性和冲突。
  2. 资源冲突:如果不同的子系统尝试访问相同的资源,可能会发生资源冲突和竞争条件。
  3. 性能问题:系统中的资源可能被多次分配,从而浪费了资源并降低了性能。

为了解决脑裂问题,分布式系统通常需要采用一些机制,如投票算法、选举协议、心跳检测等,以确保在出现网络分区或节点故障时,系统能够正确地识别和处理问题,并维护一致性。这些机制可以帮助系统中的节点协同工作,避免脑裂问题的发生。然而,脑裂问题是分布式系统设计和管理中的复杂挑战之一,需要细致的规划和测试来确保系统的可靠性和稳定性。

:::

【高级】如何解决 Redis 中的脑裂问题?

::: details 要点

当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。

在 Redis 的配置文件中有两个参数我们可以设置:

  • min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
  • min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。

我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。

这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了。

即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主库就会被限制接收客户端写请求,客户端也就不能在原主库中写入新数据了

等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。

再来举个例子。

假设我们将 min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主库因为某些原因卡住了 15s,导致哨兵判断主库客观下线,开始进行主从切换。

同时,因为原主库卡住了 15s,没有一个从库能和原主库在 12s 内进行数据复制,原主库也无法接收客户端请求了。

这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。

:::

Redis 架构

【中级】Redis 为什么快?

::: details 要点

根据 Redis 官方 Benchmark 文档的描述,Redis 单机 QPS 能达到 10w+,将近是 Mysql 的 10 倍。

Redis 官方 Benchmark QPS 图

Redis 是单线程模型(Redis 6.0 已经支持多线程模型),为什么还能有这么高的并发?

  • Redis 读写基于内存
  • IO 多路复用 + 读写单线程模型
    • IO 多路复用是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
    • 单线程模型避免了由于并发而产生的线程切换、锁竞争等开销。
    • 由于,Redis 读写基于内存,性能很高,所以 CPU 并不是制约 Redis 性能表现的瓶颈所在。更多情况下是受到内存大小和网络 I/O 的限制,所以 Redis 核心网络模型使用单线程并没有什么问题。
  • 高效的数据结构

图来自 Why is redis so fast?

扩展【视频】Why is Redis so FAST

:::

【中级】Redis 单线程模式是怎样的?

::: details 要点

Redis 单线程模式指的是其核心网络模型为单线程模式。这个模式为 IO 多路复用+单线程读写请求,其中,IO 多路复用使得 Redis 可以同时处理多个客户端连接。

Redis 真的只有单线程吗?

Redis 并非真的只有单线。

  • Redis 的主要工作包括接收客户端请求、解析请求和进行数据读写等操作,是由单线程来执行的,这也是常说 Redis 是单线程程序的原因。
  • Redis 还启动了 3 个线程来执行文件关闭AOF 同步写惰性删除等操作。
  • 此外,Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。

:::

【中级】Redis 6.0 之后为什么引入了多线程?

::: details 要点

随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 IO 的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度。

为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是,对于命令的执行,Redis 仍然使用单线程来处理。

Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上

:::

【中级】什么是 Redis 模块?有什么用?

::: details 要点

Redis 从 4.0 版本开始,支持通过 Module 来扩展其功能以满足特殊的需求。这些 Module 以动态链接库(so 文件)的形式被加载到 Redis 中,这是一种非常灵活的动态扩展功能的实现方式,值得借鉴学习!

我们每个人都可以基于 Redis 去定制化开发自己的 Module,比如实现搜索引擎功能、自定义分布式锁和分布式限流。

目前,被 Redis 官方推荐的 Module 有:

  • RediSearch:用于实现搜索引擎的模块。
  • RedisJSON:用于处理 JSON 数据的模块。
  • RedisGraph:用于实现图形数据库的模块。
  • RedisTimeSeries:用于处理时间序列数据的模块。
  • RedisBloom:用于实现布隆过滤器的模块。
  • RedisAI:用于执行深度学习/机器学习模型并管理其数据的模块。
  • RedisCell:用于实现分布式限流的模块。

关于 Redis 模块的详细介绍,可以查看官方文档:https://redis.io/modules。

:::

【高级】Redis 有哪些巧妙的设计?

::: details 要点

Redis 的巧妙设计体现在:

  • 单线程 + 非阻塞 I/O → 高吞吐、低延迟。
  • 精细优化的数据结构 → 节省内存,提升访问速度。
  • 异步化处理(持久化、删除、复制)→ 减少主线程阻塞。
  • 扩展性设计(模块化、集群)→ 适应不同场景需求。

:::

::: details 细节

Redis 作为高性能的内存数据库,有许多巧妙的设计理念和实现细节,使其在性能、简洁性和功能性之间取得平衡。以下是 Redis 的一些巧妙设计:

单线程模型(核心命令处理)

Redis 采用单线程处理命令(6.0+ 后支持多线程 I/O,但核心逻辑仍单线程),避免了锁竞争和上下文切换的开销,同时利用以下优化:

  • 非阻塞 I/O:基于 epoll/kqueue 实现高效事件驱动模型。
  • 纯内存操作:绝大多数操作在内存中完成,单线程即可高效处理。
  • 原子性保证:单线程天然支持原子操作,简化了事务、Lua 脚本等实现。

巧妙点:牺牲多线程并行性换取无锁设计的简单性和高性能。

高效数据结构实现

Redis 的核心数据结构经过高度优化:

  • **SDS (Simple Dynamic String)**:
    • 预分配内存、惰性释放,减少内存重分配。
    • 二进制安全(可存储任意数据,不像 C 字符串以 \0 结尾)。
  • **压缩列表 (ziplist)**:
    • 对小数据(如短列表、小哈希)使用紧凑存储,节省内存。
  • **快速列表 (quicklist)**:
    • 结合 ziplist 和双向链表,优化 List 的内存和访问效率。
  • **跳跃表 (skiplist)**:
    • 实现 ZSET,支持 O(logN) 范围查询和高效插入。
  • 渐进式 Rehash
    • Hash 扩容时不阻塞服务,分批次迁移数据。

巧妙点:针对不同场景选择最优底层结构,平衡内存和速度。

异步持久化

Redis 提供两种持久化方式:

  • RDB (快照)
    • fork() 子进程生成快照,主进程继续服务。
    • 使用 Copy-On-Write (COW) 机制减少内存开销。
  • AOF (日志追加)
    • 先执行命令再记录日志,避免日志错误影响数据。
    • 支持 AOF Rewrite 压缩日志(类似 RDB 的快照逻辑)。

巧妙点:通过 fork() + COW 实现后台持久化,避免阻塞主线程。

多路复用+零拷贝

  • I/O 多路复用
    • 使用 epoll/kqueue 监听大量连接,避免线程/进程切换。
  • 零拷贝优化
    • 网络发送数据时,直接引用内存缓冲区,减少拷贝(如 sendfile)。

巧妙点:最大化利用系统调用,减少 CPU 和内存开销。

惰性删除 (Lazy Free)

  • DEL 命令不立即释放内存,而是异步回收(避免大 Key 删除卡住主线程)。
  • 适用于 UNLINKFLUSHDB ASYNC 等场景。

巧妙点:用空间换时间,避免同步删除导致服务延迟。

过期键的混合淘汰策略

  • 定期删除:随机抽查部分 Key,清理已过期的。
  • 惰性删除:访问 Key 时检查是否过期,再决定删除。

巧妙点:平衡 CPU 和内存,避免全局扫描影响性能。

模块化设计 (Redis Modules)

  • 支持动态加载模块(如 RedisSearchRedisGraph),扩展功能而不改核心代码。

巧妙点:保持核心精简,通过插件机制扩展能力。

集群分片的无中心化设计

  • Gossip 协议:节点间自动发现和状态同步。
  • **哈希槽 (Hash Slot)**:数据分片到 16384 个槽,而非一致性哈希,简化迁移。

巧妙点:去中心化设计,避免单点瓶颈,支持动态扩缩容。

:::

Redis 优化

【中级】为什么会有慢查询命令?

::: details 要点

一个 Redis 命令的执行可以简化为以下 4 步:

  1. 发送命令
  2. 命令排队
  3. 命令执行
  4. 返回结果

Redis 慢查询统计的是命令执行这一步骤的耗时,慢查询命令也就是那些命令执行时间较长的命令。

Redis 为什么会有慢查询命令呢?

Redis 中的大部分命令都是 O(1) 时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:

  • KEYS *:会返回所有符合规则的 key。
  • HGETALL:会返回一个 Hash 中所有的键值对。
  • LRANGE:会返回 List 中指定范围内的元素。
  • SMEMBERS:返回 Set 中的所有元素。
  • SINTER/SUNION/SDIFF:计算多个 Set 的交集/并集/差集。
  • ……

由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长。不过,这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCANSSCANZSCAN 代替。

除了这些 O(n) 时间复杂度的命令可能会导致慢查询之外,还有一些时间复杂度可能在 O(N) 以上的命令,例如:

  • ZRANGE/ZREVRANGE:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量,m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
  • ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量,m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
  • ……

:::

【中级】如何找到慢查询命令?

::: details 要点

redis.conf 文件中,我们可以使用 slowlog-log-slower-than 参数设置耗时命令的阈值,并使用 slowlog-max-len 参数设置耗时命令的最大记录条数。

当 Redis 服务器检测到执行时间超过 slowlog-log-slower-than阈值的命令时,就会将该命令记录在慢查询日志 (slow log) 中,这点和 MySQL 记录慢查询语句类似。当慢查询日志超过设定的最大记录条数之后,Redis 会把最早的执行命令依次舍弃。

⚠️注意:由于慢查询日志会占用一定内存空间,如果设置最大记录条数过大,可能会导致内存占用过高的问题。

slowlog-log-slower-thanslowlog-max-len的默认配置如下(可以自行修改):

1
2
3
4
5
6
7
8
# The following time is expressed in microseconds, so 1000000 is equivalent
# to one second. Note that a negative number disables the slow log, while
# a value of zero forces the logging of every command.
slowlog-log-slower-than 10000

# There is no limit to this length. Just be aware that it will consume memory.
# You can reclaim memory used by the slow log with SLOWLOG RESET.
slowlog-max-len 128

除了修改配置文件之外,你也可以直接通过 CONFIG 命令直接设置:

1
2
3
4
# 命令执行耗时超过 10000 微妙(即 10 毫秒)就会被记录
CONFIG SET slowlog-log-slower-than 10000
# 只保留最近 128 条耗时命令
CONFIG SET slowlog-max-len 128

获取慢查询日志的内容很简单,直接使用SLOWLOG GET 命令即可。

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> SLOWLOG GET #慢日志查询
1) 1) (integer) 5
2) (integer) 1684326682
3) (integer) 12000
4) 1) "KEYS"
2) "*"
5) "172.17.0.1:61152"
6) ""
// ...

慢查询日志中的每个条目都由以下六个值组成:

  1. 唯一渐进的日志标识符。
  2. 处理记录命令的 Unix 时间戳。
  3. 执行所需的时间量,以微秒为单位。
  4. 组成命令参数的数组。
  5. 客户端 IP 地址和端口。
  6. 客户端名称。

SLOWLOG GET 命令默认返回最近 10 条的的慢查询命令,你也自己可以指定返回的慢查询命令的数量 SLOWLOG GET N

下面是其他比较常用的慢查询相关的命令:

1
2
3
4
5
6
# 返回慢查询命令的数量
127.0.0.1:6379> SLOWLOG LEN
(integer) 128
# 清空慢查询命令
127.0.0.1:6379> SLOWLOG RESET
OK

:::

【中级】Redis 中的 Big Key 问题是什么?如何解决?

::: details 要点

什么是 Redis Big Key?

Big Key 并不是指 key 的值很大,而是 key 对应的 value 很大。

一般而言,下面这两种情况被称为 Big Key:

  • String 类型的值大于 10 KB;
  • Hash、List、Set、ZSet 类型的元素的个数超过 5000 个,或总大小超过 10MB

Big Key 会造成什么问题?

Big Key 会带来以下四种影响:

  • 内存分布不均:集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有 Big Key 的 Redis 节点占用内存多,QPS 也会比较大。
  • 命令阻塞:Redis 单线程模型,操作大 Key 耗时,阻塞其他命令。
  • 网络传输压力:每次获取 Big Key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 客户端超时:由于 Redis 执行命令是单线程处理,然后在操作 Big Key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。

解决方案

(1)开发优化

  • 数据压缩:存储前压缩 Value(如 Gzip、Snappy)。
  • 拆分大对象:将单个大 Key 拆分为多个小 Key(如 user:1000:infouser:1000:basic + user:1000:details)。
  • 优化数据结构
    • 避免巨型 String,改用 Hash、List 等分片存储。
    • 使用 ziplistquicklist 等紧凑结构。

(2)业务调整

  • 精简存储数据:仅保留高频访问字段(如不存用户全部信息,只存 ID + 核心字段)。
  • 逻辑优化:避免业务层生成大 Key(如限制缓存数据大小、分页查询)。

(3)数据分布优化

  • 集群分片:通过 Redis Cluster 分散大 Key 到不同节点。
  • 本地缓存:对冷数据使用本地缓存(如 Caffeine),减少 Redis 压力。

关键点

  • 预防优于治理:在设计和开发阶段规避大 Key。
  • 监控与巡检:通过 redis-cli --bigkeys 或自定义脚本定期检测大 Key。

:::

::: details 细节

如何查找 Redis Big Key?

(1)使用 redis-cli --bigkeys

命令

1
redis-cli -h 127.0.0.1 -p 6379 -a "password" --bigkeys

注意事项

  • 推荐在从节点执行(主节点执行可能阻塞业务)
  • 低峰期执行-i 参数控制扫描间隔(如 -i 0.1 表示每 100ms 扫描一次)

缺点

  • 只能返回每种数据类型最大的 1 个 Key(无法获取 Top N)
  • 对集合类型只统计元素个数,而非实际内存占用

(2)使用 SCAN + 内存分析命令

遍历所有 Key(避免 KEYS * 阻塞 Redis):

1
redis-cli --scan --pattern "*" | while read key; do ...; done

分析 Key 大小

  • StringSTRLEN $key(字节数)
  • 集合类型(List/Hash/Set/ZSet):
    • 方法 1LLEN/HLEN/SCARD/ZCARD(元素个数 × 预估元素大小)
    • 方法 2(Redis 4.0+):MEMORY USAGE $key(精确内存占用)

优点

  • 可自定义筛选条件(如大小 Top 10)
  • 精确计算内存占用

(3)使用 RdbTools 分析 RDB 文件

命令

1
rdb dump.rdb -c memory --bytes 10240 -f redis.csv  # 导出 >10KB 的 Key 到 CSV

适用场景

  • 离线分析,不影响线上 Redis
  • 精准统计所有 Key 的内存分布

缺点:需要 Redis 生成 RDB 快照

总结:3 种方法对比

方法 适用场景 优点 缺点
--bigkeys 快速找出最大 Key 简单易用 结果不全面
SCAN+命令 精确分析内存 可定制化 需脚本支持
RdbTools 离线全面分析 精准无遗漏 依赖 RDB 文件

推荐组合

  • 日常监控用 --bigkeys(低峰期执行)
  • 深度分析用 RdbTools(定期检查 RDB)
  • 排查问题时用 SCAN+MEMORY USAGE(实时精准定位)

:::

【中级】如何解决 Redis 中的热点 key 问题?

::: details 要点

解决 Redis 中的热点 key 问题的方法:

  • 热点 Key 拆分

    • 垂直分片user:123user:123:base + user:123:detail
    • 水平分片product:viewsproduct:views:shard1/shard2
  • 多级缓存:CDN → 本地缓存(Caffeine/Guava) → Redis → DB

  • 读写分离:读请求分流到从节点(配置 replica-read-only yes

  • 流量控制

    • Sentinel / Hystrix 等流控中间件
    • Redis + Lua 限流
    1
    2
    3
    4
    -- 示例:每秒限 100 次访问
    local count = redis.call('INCR', KEYS[1])
    if count == 1 then redis.call('EXPIRE', KEYS[1], 1) end
    return count <= 100
  • 数据预热:定时任务提前加载热点数据到缓存

  • 负载均衡

    • Redis Cluster:分散热点 Key 到不同节点
    • 代理层:Twemproxy / Redis Proxy / Nginx 实现负载均衡

:::

参考资料

Redis 面试之应用篇

缓存

【中级】如何避免缓存雪崩、缓存击穿、缓存穿透?

::: details 要点

  • 缓存击穿:指某个热点数据在缓存中失效,导致大量请求直接访问数据库。此时,由于瞬间的高并发,可能导致数据库崩溃。
  • 缓存穿透:指查询一个不存在的数据,缓存中没有相应的记录,每次请求都会去数据库查询,造成数据库负担加重。
  • 缓存雪崩:指多个缓存数据在同一时间过期,导致大量请求同时访问数据库,从而造成数据库瞬间负载激增。

解决方案

缓存击穿:

  • 使用互斥锁,确保同一时间只有一个请求可以去数据库查询并更新缓存。
  • 热点数据永不过期。

缓存穿透:

  • 使用布隆过滤器,过滤掉不存在的请求,避免直接访问数据库。
  • 对查询结果进行缓存,即使是不存在的数据,也可以缓存一个标识,以减少对数据库的请求。

缓存雪崩:

  • 采用随机过期时间策略,避免多个数据同时过期。
  • 使用双缓存策略,将数据同时存储在两层缓存中,减少数据库直接请求。

:::

【中级】如何保证缓存与数据库的数据一致性?

::: details 要点

一般来说,系统如果不是严格要求缓存和数据库保持一致性的话,尽量不要将读请求和写请求串行化。串行化可以保证一定不会出现数据不一致的情况,但是它会导致系统的吞吐量大幅度下降。

一般来说缓存的更新有两种情况:

  • 先删除缓存,再更新数据库。
  • 先更新数据库,再删除缓存。

为什么是删除缓存,而不是更新缓存呢?

你可以想想当有多个并发的请求更新数据,你并不能保证更新数据库的顺序和更新缓存的顺序一致,那就会出现数据库中和缓存中数据不一致的情况。所以一般来说考虑删除缓存。

:::

【中级】有哪些常见的内存淘汰算法?

::: details 要点

缓存淘汰的类型:

  • 基于空间 - 设置缓存空间大小。
  • 基于容量 - 设置缓存存储记录数。
  • 基于时间
    • TTL(Time To Live,即存活期)缓存数据从创建到过期的时间。
    • TTI(Time To Idle,即空闲期)缓存数据多久没被访问的时间。

缓存淘汰算法:

  • FIFO - 先进先出。在这种淘汰算法中,先进入缓存的会先被淘汰。这种可谓是最简单的了,但是会导致我们命中率很低。试想一下我们如果有个访问频率很高的数据是所有数据第一个访问的,而那些不是很高的是后面再访问的,那这样就会把我们的首个数据但是他的访问频率很高给挤出。
  • LRU - 最近最少使用算法。在这种算法中避免了上面的问题,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。但是这个依然有个问题,如果有个数据在 1 个小时的前 59 分钟访问了 1 万次(可见这是个热点数据), 再后一分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。
  • LFU - 最近最少频率使用。在这种算法中又对上面进行了优化,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。这样就避免了 LRU 不能处理时间段的问题。

这三种缓存淘汰算法,实现复杂度一个比一个高,同样的命中率也是一个比一个好。而我们一般来说选择的方案居中即可,即实现成本不是太高,而命中率也还行的 LRU。

:::

分布式锁

【中级】实现分布式锁需要解决哪些问题?

::: details 要点

分布式锁的解决方案大致有以下几种:

  • 基于数据库实现
  • 基于缓存(Redis,Memcached 等)实现
  • 基于 Zookeeper 实现

分布式锁的实现要点大同小异,仅在实现细节上有所不同。

实现分布式锁需要解决以下目标:

  • 互斥 - 分布式锁必须是独一无二的,表现形式为:向数据存储插入一个唯一的 key,一旦有一个线程插入这个 key,其他线程就不能再插入了。
  • 避免死锁 - 在分布式锁的场景中,部分失败和异步网络这两个问题是同时存在的。如果一个进程获得了锁,但是这个进程与锁服务之间的网络出现了问题,导致无法通信,那么这个情况下,如果锁服务让它一直持有锁,就会导致死锁的发生。
    • 常见的解决思路是引入超时机制,即成功申请锁后,超过一定时间,锁失效(删除 key)。
    • 超时机制解锁了死锁问题,但又引入了一个新问题:如果应用加锁时,对于操作共享资源的时长估计不足,可能会出现:操作尚未执行完,但是锁没了的尴尬情况。为了解决这个问题,需要引入锁续期机制:当持有锁的线程尚未执行完操作前,不断周期性检测锁的超时时间,一旦发现快要过期,就自动为锁续期。
    • ZooKeeper 分布式锁避免死锁采用了另外一种思路—— Watch 机制。
  • 可重入 - 可重入指的是:同一个线程在没有释放锁之前,能否再次获得该锁。其实现方案是:只需在加锁的时候,记录好当前获取锁的节点 + 线程组合的唯一标识,然后在后续的加锁请求时,如果当前请求的节点 + 线程的唯一标识和当前持有锁的相同,那么就直接返回加锁成功;如果不相同,则按正常加锁流程处理。
  • 公平性 - 当多个线程请求同一锁时,它们必须按照请求的顺序来获取锁,即先来先得的原则。锁的公平性的实现也非常简单,对于被阻塞的加锁请求,我们只要先记录好它们的顺序,在锁被释放后,按顺序颁发就可以了。
  • 重试 - 有时候,加锁失败可能只是由于网络波动、请求超时等原因,稍候就可以成功获取锁。为了应对这种情况,加锁操作需要支持重试机制。常见的做法是,设置一个加锁超时时间,在该时间范围内,不断自旋重试加锁操作,超时后再判定加锁失败。
  • 容错 - 分布式锁若存储在单一节点,一旦该节点宕机或失联,就会导致锁失效。将分布式锁存储在多数据库实例中,加锁时并发写入 N 个节点,只要 N / 2 + 1 个节点写入成功即视为加锁成功。代表有:Redis 官方提供的 RedLock。

:::

【中级】Redis 中如何实现分布式锁?

::: details 要点

基础实现

  • 加锁SET key val NX PX 30000NX:仅当 key 不存在时写入,PX:超时时间,毫秒)。
  • 解锁DEL key(直接删除 key 释放锁)。

避免死锁

  • 问题:节点宕机或异常导致锁无法释放。
  • 方案:加锁时设置超时时间PX/EX),到期自动删除。
  • 原子性:使用 SET key val NX PX 替代 setnx + expire,避免组合命令非原子性问题。

超时续期(WatchDog)

  • 问题:业务未执行完,锁已过期。
  • 方案:启动定时任务检测锁剩余时间,自动续期(如 Redisson 的 WatchDog 机制)。

安全解锁

  • 问题:其他节点误删锁。
  • 方案
    • 加锁时写入唯一标识(如 UUID)。
    • 解锁时校验标识,匹配才删除(使用 Lua 脚本保证原子性):
      1
      2
      3
      4
      5
      if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del", KEYS[1])
      else
      return 0
      end

自旋重试

  • 问题:网络波动导致加锁失败。
  • 方案:在超时时间内循环重试加锁(伪代码示例见原内容)。

其他问题

  • 不可重入:同一线程无法重复获取同一把锁(可通过 Redisson 等客户端支持)。
  • 单点问题:主从切换可能导致锁失效(可用 RedLock 算法,但存在争议)。

:::

【中级】Red Lock 分布式锁的原理是什么?

::: details 要点

核心机制

  • 多节点写入:同时向多个 Redis 主节点(≥5 个)加锁
  • 多数派原则:半数以上节点加锁成功才算成功
  • 耗时控制:总加锁时间必须小于锁的过期时间
  • 强制清理:解锁时向所有节点发起请求

注意事项

  • 网络延迟:多节点操作会增加耗时风险
  • 性能代价:相比单节点锁性能更低
  • 实现复杂度:需要精确控制时间和节点状态

:::

【中级】Redisson 分布式锁的原理是什么?

::: details 要点

Redisson 是一个流行的 Redis Java 客户端,它基于 Netty 开发,并提供了丰富的扩展功能,如:分布式计数器分布式集合分布式锁 等。

Redisson 支持的分布式锁有多种:Lock, FairLock, MultiLock, RedLock, ReadWriteLock, Semaphore, PermitExpirableSemaphore, CountDownLatch,可以根据场景需要去选择,非常方便。一般而言,使用 Redis 分布式锁,推荐直接使用 Redisson 提供的 API,功能全面且较为可靠。

Redisson 分布式锁的实现要点

  • 锁的获取:Redisson 使用 Lua 脚本,利用 exists + hexists + hincrby 命令来保证只有一个线程能成功设置键(表示获得锁)。同时,Redisson 会通过 pexpire 命令为锁设置过期时间,防止因宕机等原因导致锁无法释放(即死锁问题)。
  • 锁的续期:为了防止锁在持有过程中过期导致其他线程抢占锁,Redisson 实现了一种叫做 Watch Dog(看门狗) 的锁自动续期的功能。持有锁的线程会定期续期,即更新锁的过期时间,确保任务没有完成时锁不会失效。
  • 锁的释放:锁释放时,Redisson 也是通过 Lua 脚本保证释放操作的原子性。利用 hexists + del 确保只有持有锁的线程才能释放锁,防止误释放锁的情况。Lua 脚本同时利用 publish 命令,广播唤醒其它等待的线程。
  • 可重入锁:Redisson 支持可重入锁,持有锁的线程可以多次获取同一把锁而不会被阻塞。具体是利用 Redis 中的哈希结构,哈希中的 key 为线程 ID,如果重入则 value +1,如果释放则 value -1,减到 0 说明锁被释放了,则 del 锁。

:::

消息队列

【中级】Redis 如何实现消息队列?

::: details 要点

Redis 可以做消息队列吗?

Redis 有哪些实现消息队列的方式?

先说结论:可以是可以,但不建议使用 Redis 来做消息队列。和专业的消息队列相比,还是有很多欠缺的地方。

基于 List 实现消息队列

Redis 2.0 之前,如果想要使用 Redis 来做消息队列的话,只能通过 List 来实现。

通过 RPUSH/LPOP 或者 LPUSH/RPOP即可实现简易版消息队列:

1
2
3
4
5
6
7
8
# 生产者生产消息
> RPUSH myList msg1 msg2
(integer) 2
> RPUSH myList msg3
(integer) 3
# 消费者消费消息
> LPOP myList
"msg1"

不过,通过 RPUSH/LPOP 或者 LPUSH/RPOP这样的方式存在性能问题,我们需要不断轮询去调用 RPOPLPOP 来消费消息。当 List 为空时,大部分的轮询的请求都是无效请求,这种方式大量浪费了系统资源。

因此,Redis 还提供了 BLPOPBRPOP 这种阻塞式读取的命令(带 B-Blocking 的都是阻塞式),并且还支持一个超时参数。如果 List 为空,Redis 服务端不会立刻返回结果,它会等待 List 中有新数据后再返回或者是等待最多一个超时时间后返回空。如果将超时时间设置为 0 时,即可无限等待,直到弹出消息

1
2
3
4
# 超时时间为 10s
# 如果有数据立刻返回,否则最多等待 10 秒
> BRPOP myList 10
null

List 实现消息队列功能太简单,像消息确认机制等功能还需要我们自己实现,最要命的是没有广播机制,消息也只能被消费一次。

基于发布订阅功能实现消息队列

Redis 2.0 引入了发布订阅 (pub/sub) 功能,解决了 List 实现消息队列没有广播机制的问题。

Redis 发布订阅 (pub/sub) 功能

pub/sub 中引入了一个概念叫 channel(频道),发布订阅机制的实现就是基于这个 channel 来做的。

pub/sub 涉及发布者(Publisher)和订阅者(Subscriber,也叫消费者)两个角色:

  • 发布者通过 PUBLISH 投递消息给指定 channel。
  • 订阅者通过SUBSCRIBE订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。

pub/sub 既能单播又能广播,还支持 channel 的简单正则匹配。不过,消息丢失(客户端断开连接或者 Redis 宕机都会导致消息丢失)、消息堆积(发布者发布消息的时候不会管消费者的具体消费能力如何)等问题依然没有一个比较好的解决办法。

基于 Stream 实现消息队列

为此,Redis 5.0 新增加的一个数据结构 Stream 来做消息队列。Stream 支持:

  • 发布 / 订阅模式
  • 按照消费者组进行消费(借鉴了 Kafka 消费者组的概念)
  • 消息持久化( RDB 和 AOF)
  • ACK 机制(通过确认机制来告知已经成功处理了消息)
  • 阻塞式获取消息

Stream 的结构如下:

这是一个有序的消息链表,每个消息都有一个唯一的 ID 和对应的内容。ID 是一个时间戳和序列号的组合,用来保证消息的唯一性和递增性。内容是一个或多个键值对(类似 Hash 基本数据类型),用来存储消息的数据。

这里再对图中涉及到的一些概念,进行简单解释:

  • Consumer Group:消费者组用于组织和管理多个消费者。消费者组本身不处理消息,而是再将消息分发给消费者,由消费者进行真正的消费
  • last_delivered_id:标识消费者组当前消费位置的游标,消费者组中任意一个消费者读取了消息都会使 last_delivered_id 往前移动。
  • pending_ids:记录已经被客户端消费但没有 ack 的消息的 ID。

Stream 使用起来相对要麻烦一些,这里就不演示了。

总的来说,Stream 已经可以满足一个消息队列的基本要求了。不过,Stream 在实际使用中依然会有一些小问题不太好解决比如在 Redis 发生故障恢复后不能保证消息至少被消费一次。

综上,和专业的消息队列相比,使用 Redis 来实现消息队列还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。因此,我们通常建议不要使用 Redis 来做消息队列,你完全可以选择市面上比较成熟的一些消息队列比如 RocketMQ、Kafka。不过,如果你就是想要用 Redis 来做消息队列的话,那我建议你优先考虑 Stream,这是目前相对最优的 Redis 消息队列实现。

相关阅读:Redis 消息队列发展历程 - 阿里开发者 - 2022

:::

延时任务

【中级】如何基于 Redis 实现延时任务?

::: details 要点

基于 Redis 实现延时任务的功能无非就下面两种方案:

  1. Redis 过期事件监听
  2. Redisson 内置的延时队列

Redis 过期事件监听的存在时效性较差、丢消息、多服务实例下消息重复消费等问题,不被推荐使用。

Redisson 内置的延时队列具备下面这些优势:

  1. 减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。
  2. 消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。

:::

参考资料

Redis 面试之数据类型篇

Redis 数据类型

【基础】Redis 支持哪些数据类型?

::: details 要点

  • Redis 支持五种基本数据类型:String(字符串)、Hash(哈希)、List(列表)、Set(集合)、Zset(有序集合)。
  • 随着 Redis 版本升级,又陆续支持以下数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。

扩展What Redis data structures look like

:::

【基础】Redis 基础数据类型的常见命令有哪些?

::: details 要点

String 命令

命令 说明
SET 存储一个字符串值
SETNX 仅当键不存在时,才存储字符串值
GET 获取指定 key 的值
MGET 获取一个或多个指定 key 的值
INCRBY 将 key 中储存的数字加上指定的增量值
DECRBY 将 key 中储存的数字减去指定的减量值

扩展Redis String 类型官方命令文档

Hash 命令

命令 行为
HSET 将指定字段的值设为 value
HGET 获取指定字段的值
HGETALL 获取所有键值对
HMSET 设置多个键值对
HMGET 获取所有指定字段的值
HDEL 删除指定字段
HINCRBY 为指定字段的整数值加上增量
HKEYS 获取所有字段

扩展Redis Hash 类型官方命令文档

List 命令

命令 行为
LPUSH 将给定值推入列表的右端。
RPUSH 将给定值推入列表的右端。
LPOP 从列表的左端弹出一个值,并返回被弹出的值。
RPOP 从列表的右端弹出一个值,并返回被弹出的值。
LRANGE 获取列表在给定范围上的所有值。
LINDEX 获取列表在给定位置上的单个元素。
LREM 从列表的左端弹出一个值,并返回被弹出的值。
LTRIM 只保留指定区间内的元素,删除其他元素。

扩展Redis List 类型官方命令文档

Set 命令

命令 行为
SADD 将给定元素添加到集合。
SMEMBERS 返回集合包含的所有元素。
SISMEMBER 检查给定元素是否存在于集合中。
SREM 如果给定的元素存在于集合中,那么移除这个元素。

扩展Redis Set 类型官方命令文档

Zset 命令

命令 行为
ZADD 将一个带有给定分值的成员添加到有序集合里面
ZRANGE 顺序排序,并返回指定排名区间的成员
ZREVRANGE 反序排序,并返回指定排名区间的成员
ZRANGEBYSCORE 顺序排序,并返回指定排名区间的成员及其分值
ZREVRANGEBYSCORE 反序排序,并返回指定排名区间的成员及其分值
ZREM 移除指定的成员
ZSCORE 返回指定成员的分值
ZCARD 返回所有成员数

扩展Redis ZSet 类型官方命令文档

:::

【中级】Redis 各数据类型的应用场景?

::: details 要点

  • String(字符串) - 缓存对象、分布式 Session、分布式锁、计数器、限流器、分布式 ID 等。
  • Hash(哈希) - 缓存对象、购物车等。
  • List(列表) - 消息队列
  • Set(集合) - 聚合计算(并集、交集、差集),如点赞、共同关注、抽奖活动等。
  • Zset(有序集合) - 排序场景,如排行榜、电话和姓名排序等。
  • BitMap(2.2 版新增) - 二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog(2.8 版新增) - 海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO(3.2 版新增) - 存储地理位置信息的场景,比如滴滴叫车;
  • Stream(5.0 版新增) - 消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息 ID,支持以消费组形式消费数据。

:::

【高级】Redis 基础数据类型的底层实现是怎样的?

::: details 要点

  • String 类型 - String 类型的底层数据结构是 SDS。SDS 是 Redis 针对字符串类型的优化,具有以下特性:
    • 常数复杂度获取字符串长度
    • 杜绝缓冲区溢出
    • 减少修改字符串长度时所需的内存重分配次数
  • List 类型 - 列表对象的编码可以是 ziplist 或者 linkedlist。当列表对象可以同时满足以下两个条件时,列表对象使用 ziplist 编码;否则,使用 linkedlist 编码。
    • 列表对象保存的所有字符串元素的长度都小于 64 字节;
    • 列表对象保存的元素数量小于 512 个;
  • Hash 类型 - 哈希对象的编码可以是 ziplist 或者 hashtable。当哈希对象同时满足以下两个条件时,使用 ziplist 编码;否则,使用 hashtable 编码。
    • 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
    • 哈希对象保存的键值对数量小于 512 个;
  • Set 类型 - 集合对象的编码可以是 intset 或者 hashtable。当集合对象可以同时满足以下两个条件时,集合对象使用 intset 编码;否则,使用 hashtable 编码。
    • 集合对象保存的所有元素都是整数值;
    • 集合对象保存的元素数量不超过 512 个;
  • Zset 类型 - 有序集合的编码可以是 ziplist 或者 skiplist。当有序集合对象可以同时满足以下两个条件时,有序集合对象使用 ziplist 编码;否则,使用 skiplist 编码。
    • 有序集合保存的元素数量小于 128 个;
    • 有序集合保存的所有元素成员的长度都小于 64 字节;

:::

【高级】Redis 为什么用 listpack 替代 ziplist

::: details 要点

**listpack 是 Redis 5.0 引入的优化结构,用来替代 ziplist**,作为 hashlistzset 数据类型的实现编码之一。

二者对比如下:

特性 ziplist listpack
级联更新 可能发生(最坏 O(n) 完全避免(稳定 O(1)
内存占用 预留空间可能浪费 按需分配,更紧凑
安全性 需手动校验边界 内置长度校验,防溢出
版本 Redis 旧版本 Redis 5.0+ 的 Hash、ZSet 等

:::

::: details 细节

ziplist 的缺陷

  • 级联更新问题 - 当修改或删除中间某个元素时,可能引发后续所有节点的内存重分配(因为 ziplist前驱节点长度 定位数据)。最坏情况下时间复杂度从 O(1) 退化到 O(n),影响性能。
  • 内存浪费 - ziplist 为每个节点预留 1~5 字节 存储前驱节点长度(即使实际不需要这么多空间)。对于短小的数据(如小整数),存储开销比例过高。
  • 安全性风险 - ziplist 对内存布局的强依赖可能导致 缓冲区溢出(需严格校验边界)。

listpack 的改进

  • 消除级联更新 - 每个节点 独立存储自身长度(不再依赖前驱节点)。修改任意节点仅影响当前节点,时间复杂度稳定为 O(1)
  • 更紧凑的内存布局 - 节点长度字段采用 变长编码(类似 Protobuf 的 Varint),根据实际需求分配 1~5 字节。存储小整数时,长度字段仅需 1 字节。
  • 更强的安全性 - 每个节点记录 总长度校验字段,避免解析越界。
  • 兼容性与平滑替换 - listpack 的 API 设计兼容 ziplist,Redis 内部可无缝迁移(如 Hash、ZSet 的底层实现)。

:::

【高级】为什么 Zset 用跳表实现而不是红黑树、B+树?

::: details 要点

  • 实现简单性
    • 跳表的实现比红黑树简单得多,代码更易于维护和调试。
    • 红黑树需要处理复杂的旋转和重新平衡操作,而跳表的平衡是通过概率实现的。
  • 范围查询效率
    • 跳表在范围查询(如 zrange)上表现优异,因为它是基于链表的结构,可以线性遍历。
    • 红黑树进行范围查询需要中序遍历,相对复杂。
    • B+树虽然也擅长范围查询,但实现复杂度更高。
  • 并发性能
    • 跳表更容易实现无锁并发操作 (Redis 虽然是单线程,但考虑未来扩展)
    • 红黑树的平衡操作涉及大量指针修改,难以实现高效的并发控制
  • 内存效率
    • 跳表不需要像 B+ 树那样维护严格的树形结构,内存使用更灵活
    • B+树的节点通常设计为填满一定比例,可能造成内存浪费
  • 性能平衡
    • 跳表的查询、插入、删除操作时间复杂度都是 O(logN),与红黑树相当
    • 跳表的实际性能在实践中表现良好,特别是对于内存数据结构
  • Redis 的特殊需求
    • Redis 的 Zset 需要同时支持按 score 和按 member 查询,跳表+哈希表的组合完美满足这一需求
    • Redis 是内存数据库,不需要考虑 B+树针对磁盘 I/O 优化的特性

:::

【高级】跳表的实现原理是什么?

::: details 要点

跳表是一种可以实现二分查找的有序链表,通过多级索引提升查找效率。跳表的查找、插入、删除操作的时间复杂度均为 O(log n),与平衡二叉树(如红黑树)接近。

对于一个有序数组,可以使用高效的二分查找法,其时间复杂度为 O(log n)

但是,即使是有序的链表,也只能使用低效的顺序查找,其时间复杂度为 O(n)

如何提高链表的查找效率呢?

我们可以对链表加一层索引。具体来说,可以每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引索引层。索引节点中通过一个 down 指针,指向下一级结点。通过这样的改造,就可以支持类似二分查找的算法。我们把改造之后的数据结构叫作跳表(Skip list)。

随着数据的不断增长,一级索引层也变得越来越长。此时,我们可以为一级索引再增加一层索引层:二级索引层。

随着数据的膨胀,当二级索引层也变得很长时,我们可以继续为其添加新的索引层。这种链表加多级索引的结构,就是跳表

跳表的时间复杂度

  • 查找:从最高级索引开始逐层下沉,每层最多遍历 3 个节点,时间复杂度为 O(log n)
  • 插入:先查找插入位置(O(log n)),再随机生成索引层级(O(log n)),总时间复杂度为 O(log n)
  • 删除:类似查找过程,删除节点及其索引(O(log n))。

跳表的空间复杂度

  • 索引节点总数为 n/2 + n/4 + n/8 + … ≈ n,空间复杂度为 **O(n)**。
  • 可通过调整索引密度(如每 3 个节点抽 1 个)减少空间占用,但会牺牲部分查找效率。

:::

【高级】Redis 利用什么机制来实现各种数据结构?

::: details 要点

Redis 并没有直接使用这些数据结构来实现键值对数据库, 而是基于这些数据结构创建了一个对象系统, 这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象。

Redis 数据库中的每个键值对的键和值都是一个对象。Redis 共有字符串、列表、哈希、集合、有序集合五种类型的对象, 每种类型的对象至少都有两种或以上的编码方式, 不同的编码可以在不同的使用场景上优化对象的使用效率。

服务器在执行某些命令之前, 会先检查给定键的类型能否执行指定的命令, 而检查一个键的类型就是检查键的值对象的类型。

:::

::: details 细节

对象的类型

Redis 使用对象来表示数据库中的键和值。每次当我们在 Redis 的数据库中新创建一个键值对时, 我们至少会创建两个对象, 一个对象用作键值对的键(键对象), 另一个对象用作键值对的值(值对象)。

Redis 中的每个对象都由一个 redisObject 结构表示, 该结构中和保存数据有关的三个属性分别是 type 属性、 encoding 属性和 ptr 属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct redisObject {

// 类型
unsigned type:4;

// 编码
unsigned encoding:4;

// 指向底层实现数据结构的指针
void *ptr;

// ...

} robj;

对象的 type 属性记录了对象的类型,有以下类型:

对象 对象 type 属性的值 TYPE 命令的输出
字符串对象 REDIS_STRING "string"
列表对象 REDIS_LIST "list"
哈希对象 REDIS_HASH "hash"
集合对象 REDIS_SET "set"
有序集合对象 REDIS_ZSET "zset"

Redis 数据库保存的键值对来说, 键总是一个字符串对象, 而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。

对象的编码

对象的 ptr 指针指向对象的底层实现数据结构, 而这些数据结构由对象的 encoding 属性决定。

encoding 属性记录了对象所使用的编码, 也即是说这个对象使用了什么数据结构作为对象的底层实现。

Redis 中每种类型的对象都至少使用了两种不同的编码,不同的编码可以在不同的使用场景上优化对象的使用效率

Redis 支持的编码如下所示:

类型 编码 对象 OBJECT ENCODING 命令输出
REDIS_STRING REDIS_ENCODING_INT 使用整数值实现的字符串对象。 “int”
REDIS_STRING REDIS_ENCODING_EMBSTR 使用 embstr 编码的简单动态字符串实现的字符串对象。 “embstr”
REDIS_STRING REDIS_ENCODING_RAW 使用简单动态字符串实现的字符串对象。 “raw”
REDIS_LIST REDIS_ENCODING_ZIPLIST 使用压缩列表实现的列表对象。 “ziplist”
REDIS_LIST REDIS_ENCODING_LINKEDLIST 使用双端链表实现的列表对象。 “linkedlist”
REDIS_HASH REDIS_ENCODING_ZIPLIST 使用压缩列表实现的哈希对象。 “ziplist”
REDIS_HASH REDIS_ENCODING_HT 使用字典实现的哈希对象。 “hashtable”
REDIS_SET REDIS_ENCODING_INTSET 使用整数集合实现的集合对象。 “intset”
REDIS_SET REDIS_ENCODING_HT 使用字典实现的集合对象。 “hashtable”
REDIS_ZSET REDIS_ENCODING_ZIPLIST 使用压缩列表实现的有序集合对象。 “ziplist”
REDIS_ZSET REDIS_ENCODING_SKIPLIST 使用跳表和字典实现的有序集合对象。 “skiplist”

内存回收

由于 C 语言不支持内存回收,Redis 内部实现了一套基于引用计数的内存回收机制。

每个对象的引用计数信息由 redisObject 结构的 refcount 属性记录。当对象的引用计数值变为 0 时, 对象所占用的内存会被释放。

对象共享

在 Redis 中, 让多个键共享同一个值对象需要执行以下两个步骤:

  1. 将数据库键的值指针指向一个现有的值对象;
  2. 将被共享的值对象的引用计数增一。

共享对象机制对于节约内存非常有帮助, 数据库中保存的相同值对象越多, 对象共享机制就能节约越多的内存。

Redis 会在初始化服务器时, 共享值为 09999

对象的空转时长

redisObjectlru 属性记录了对象最后一次被命令程序访问的时间。

如果服务器打开了 maxmemory 选项, 并且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru , 那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时, 空转时长较高的那部分键会优先被服务器释放, 从而回收内存。

:::

【中级】如何使用 Redis 实现排行榜?

::: details 要点

各种排行榜,如:内容平台(视频、歌曲、文章)的播放量/收藏量/评分排行榜;电商网站的销售排行榜等等,都可以基于 Redis zset 类型来实现。

:::

::: details 细节

我们以博文点赞排名为例,dunwu 发表了五篇博文,分别获得赞为 200、40、100、50、150。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# article:1 文章获得了 200 个赞
> ZADD user:dunwu:ranking 200 article:1
(integer) 1
# article:2 文章获得了 40 个赞
> ZADD user:dunwu:ranking 40 article:2
(integer) 1
# article:3 文章获得了 100 个赞
> ZADD user:dunwu:ranking 100 article:3
(integer) 1
# article:4 文章获得了 50 个赞
> ZADD user:dunwu:ranking 50 article:4
(integer) 1
# article:5 文章获得了 150 个赞
> ZADD user:dunwu:ranking 150 article:5
(integer) 1

文章 article:4 新增一个赞,可以使用 ZINCRBY 命令(为有序集合 key 中元素 member 的分值加上 increment):

1
2
> ZINCRBY user:dunwu:ranking 1 article:4
"51"

查看某篇文章的赞数,可以使用 ZSCORE 命令(返回有序集合 key 中元素个数):

1
2
> ZSCORE user:dunwu:ranking article:4
"50"

获取 dunwu 文章赞数最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序获取有序集合 key 从 start 下标到 stop 下标的元素):

1
2
3
4
5
6
7
8
# WITHSCORES 表示把 score 也显示出来
> ZREVRANGE user:dunwu:ranking 0 2 WITHSCORES
1) "article:1"
2) "200"
3) "article:5"
4) "150"
5) "article:3"
6) "100"

获取 dunwu 100 赞到 200 赞的文章,可以使用 ZRANGEBYSCORE 命令(返回有序集合中指定分数区间内的成员,分数由低到高排序):

1
2
3
4
5
6
7
> ZRANGEBYSCORE user:dunwu:ranking 100 200 WITHSCORES
1) "article:3"
2) "100"
3) "article:5"
4) "150"
5) "article:1"
6) "200"

:::

【中级】如何使用 Redis 实现百万级网页 UV 计数?

::: details 要点

Redis HyperLogLog 是 Redis 2.8.9 版本新增的数据类型,是一种用于“统计基数”的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,**HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%**(统计结果为 100 万时,实际可能在 99.19 万~100.81 万之间)。

核心优势

  • 极低内存占用:仅需 12 KB 内存,即可统计接近 2^64 个元素的基数(如 UV 统计)。
  • 适合海量数据:相比 Set/Hash(元素越多内存消耗越大),HyperLogLog 在百万级以上数据场景中优势显著。

适用场景

  • 网页 UV 统计:统计独立访客数(如 page1:uv),尤其适合高并发、大数据量场景。
  • 容忍误差的基数统计:如热门活动页面访问量、广告点击去重等。

如果需要精确统计,则需要转用 Set / Hash,并且不得不消耗

基本命令

  • 添加元素PFADD key element [element...]

    1
    PFADD page1:uv user1 user2 user3  # 将用户添加到 UV 统计
  • 获取统计值PFCOUNT key

    1
    PFCOUNT page1:uv  # 返回近似 UV 数(如 100 万)

:::

【中级】如何使用 Redis 实现布隆过滤器?

::: details 要点

布隆过滤器是一种高效的概率数据结构,常用于检测一个元素是否在一个集合中,可以有效减少数据库的查询次数,解决缓存穿透等问题。

可以通过以下两种方式实现布隆过滤器:

使用位图(Bitmap)实现布隆过滤器:

  • 使用 Redis 的位图结构 SETBITGETBIT 操作来实现布隆过滤器。位图本质上是一个比特数组,用于标识元素是否存在。
  • 对于给定的数据,通过多个哈希函数计算位置索引,将位图中的相应位置设置为 1,表示该元素可能存在。

使用 RedisBloom 模块:

  • Redis 提供了一个官方模块 RedisBloom,封装了哈希函数、位图大小等操作,可以直接用于创建和管理布隆过滤器。
  • 使用 BF.ADD 来向布隆过滤器添加元素,使用 BF.EXISTS 来检查某个元素是否可能存在。

:::

::: details 细节

BitMap

Bitmap,即位图,是一串连续的二进制数组(0 和 1),可以通过偏移量(offset)定位元素。由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。例如在一个系统中,不同的用户使用单调递增的用户 ID 表示。40 亿($$2^{32}$$ = $$410241024*1024$$ ≈ 40 亿)用户只需要 512M 内存就能记住某种状态,例如用户是否已登录。

实际上,BitMap 不是真实的数据结构,而是针对 String 实现的一组位操作

由于 STRING 是二进制安全的,并且其最大长度是 512 MB,所以 BitMap 能最大设置 $$2^{32}$$ 个不同的 bit。

【示例】判断用户是否登录

Bitmap 提供了 GETBIT、SETBIT 操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。

只需要一个 key = login_status 表示存储用户登陆状态集合数据,将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT判断对应的用户是否在线。50000 万 用户只需要 6 MB 的空间。

假如我们要判断 ID = 10086 的用户的登陆情况:

第一步,执行以下指令,表示用户已登录。

1
SETBIT login_status 10086 1

第二步,检查该用户是否登陆,返回值 1 表示已登录。

1
GETBIT login_status 10086

第三步,登出,将 offset 对应的 value 设置成 0。

1
SETBIT login_status 10086 0

RedisBloom

RedisBloom 是 Redis 官方提供的模块,是一种简化的布隆过滤器实现。它提供了更高性能更低误判率控制。

RedisBloom 常用命令

  • BF.RESERVE key error_rate capacity:创建布隆过滤器(指定误判率、容量)
  • BF.ADD key item:添加元素
  • BF.EXISTS key item:检查元素是否存在(可能误判)
  • 自动扩容:可动态调整数据结构以适应数据增长

RedisBloom 操作示例

创建

1
BF.RESERVE myBloomFilter 0.01 1000000  # 误判率 1%,容量 100 万

添加元素

1
BF.ADD myBloomFilter "item1"

检查元素

1
2
BF.EXISTS myBloomFilter "item1"  # 返回 1(可能存在)
BF.EXISTS myBloomFilter "item2" # 返回 0(一定不存在)

适用场景

RedisBloom 适合海量数据判断,且允许误判的场景:

  1. 爬虫:URL 去重
  2. 黑名单:反垃圾邮件(可能误杀)
  3. 分布式系统:优化数据查找(如 Hadoop、Cassandra)
  4. 推荐系统:避免重复推荐

核心特点

  • 空间高效:节省存储空间
  • 快速查询:O(1) 时间复杂度
  • 误判率可控:通过参数调整

:::

参考资料

MySQL 存储引擎

::: info 概述

大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。

Server 层包括连接器、查询缓存、解析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。

存储引擎层负责数据的存储和提取。MySQL 的存储引擎采用了插拔式架构,可以根据需要替换。MySQL 内置了 InnoDB、MyISAM、Memory 等多个存储引擎,现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。

:::

存储引擎简介

在文件系统中,MySQL 将每个数据库(也可以成为 schema)保存为数据目录下的一个子目录。创建表示,MySQL 会在数据库子目录下创建一个和表同名的 .frm 文件保存表的定义。因为 MySQL 使用文件系统的目录和文件来保存数据库和表的定义,大小写敏感性和具体平台密切相关。Windows 中大小写不敏感;类 Unix 中大小写敏感。不同的存储引擎保存数据和索引的方式是不同的,但表的定义则是在 MySQL 服务层统一处理的。

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

MySQL 内置的存储引擎

MySQL 内置了以下存储引擎:

  • InnoDB - InnoDB 是 MySQL 5.5 版本以后的默认存储引擎。
    • 优点:支持事务,支持行级锁,支持外键约束等,并发性能不错且支持自动故障恢复
  • MyISAM - MyISAM 是 MySQL 5.5 版本以前的默认存储引擎。
    • 优点:速度快,占用资源少。
    • 缺点:不支持事务,不支持行级锁,不支持外键约束,也不支持自动故障恢复功能。
  • Memory - 使用系统内存作为存储介质,以便得到更快的响应速度。不过,如果 mysqld 进程崩溃,则会导致所有的数据丢失。因此,Memory 引擎常用于临时表。
  • NDB - 也被称为 NDB Cluster 存储引擎,主要用于 MySQL Cluster 分布式集群环境,类似于 Oracle 的 RAC 集群。
  • Archive - Archive 存储引擎有很好的压缩机制,非常适合用于归档数据。
    • Archive 存储引擎只支持 INSERTSELECT 操作。
    • Archive 存储引擎采用 zlib 算法压缩数据,压缩比可达到 1: 10。
  • CSV - 可以将 CSV 文件作为 MySQL 的表来处理,但这种表不支持索引。

如何选择合适的存储引擎

大多数情况下,InnoDB 都是正确的选择,除非需要用到 InnoDB 不具备的特性。

如果应用需要选择 InnoDB 以外的存储引擎,可以考虑以下因素:

  • 事务:如果业务场景是 OLTP,则 InnoDB 是首选存储引擎。如果不需要支持事务,且主要是 SELECT 和 INSERT 操作,MyISAM 是不错的选择。所以,如果 MySQL 部署方式为主备模式,并进行读写分离。那么可以这么做:主节点只支持写操作,默认引擎为 InnoDB;备节点只支持读操作,默认引擎为 MyISAM。
  • 并发:MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。所以,InnoDB 并发性能更高。
  • 外键:InnoDB 支持外键。
  • 备份:InnoDB 支持在线热备份。
  • 崩溃恢复:MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。
  • 其它特性:MyISAM 支持压缩表和空间数据索引。

存储引擎相关操作

查看存储引擎命令

1
2
3
4
5
6
7
8
9
10
11
12
-- 查看支持的存储引擎
SHOW ENGINES;

-- 查看默认的存储引擎
SHOW VARIABLES LIKE 'storage_engine';

-- 查看某表所使用的存储引擎
SHOW CREATE TABLE `table_name`;

-- 查看某数据库中的某表所使用的存储引擎
SHOW TABLE STATUS LIKE 'table_name';
SHOW TABLE STATUS FROM `database_name` WHERE `name` = "table_name";

设置存储引擎命令

1
2
3
4
5
6
7
8
9
10
-- 建表时指定存储引擎,如果不显示指定,默认是 INNODB
CREATE TABLE t1 (i INT) ENGINE = INNODB;
CREATE TABLE t2 (i INT) ENGINE = CSV;
CREATE TABLE t3 (i INT) ENGINE = MEMORY;

-- 修改存储引擎
ALTER TABLE t ENGINE = InnoDB;

-- 修改默认存储引擎,也可以在配置文件 my.cnf 中修改默认引擎
SET default_storage_engine=NDBCLUSTER;

默认情况下,每当 CREATE TABLEALTER TABLE 不能使用默认存储引擎时,都会生成一个警告。为了防止在所需的引擎不可用时出现令人困惑的意外行为,可以启用 NO_ENGINE_SUBSTITUTION SQL 模式。如果所需的引擎不可用,则此设置将产生错误而不是警告,并且不会创建或更改表

InnoDB

InnoDB 简介

InnoDB 是 MySQL 5.5 版本以后的默认存储引擎。只有在需要 InnoDB 不支持的特性时,才考虑使用其它存储引擎。

InnoDB 也使用 B+Tree 作为索引结构,但具体实现方式却与 MyISAM 截然不同。MyISAM 索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。而在 InnoDB 中,表数据文件本身就是按 B+Tree 组织的一个索引结构,这棵树的叶节点 data 域保存了完整的数据记录。这个索引的 key 是数据表的主键,因此InnoDB 表数据文件本身就是主索引

InnoDB 采用 MVCC 来支持高并发,并且实现了四个标准的隔离级别。其默认级别是可重复读(REPEATABLE READ),并且通过间隙锁(next-key locking)防止幻读。

InnoDB 是基于聚簇索引建立的,与其他存储引擎有很大不同。在索引中保存了数据,从而避免直接读取磁盘,因此对查询性能有很大的提升。

内部做了很多优化,包括从磁盘读取数据时采用的可预测性读、能够加快读操作并且自动创建的自适应哈希索引、能够加速插入操作的插入缓冲区等。

支持真正的在线热备份。其它存储引擎不支持在线热备份,要获取一致性视图需要停止对所有表的写入,而在读写混合场景中,停止写入可能也意味着停止读取。

InnoDB 物理文件结构为:

  • .frm 文件:与表相关的元数据信息都存放在 frm 文件,包括表结构的定义信息等。
  • .ibd 文件或 .ibdata 文件: 这两种文件都是存放 InnoDB 数据的文件,之所以有两种文件形式存放 InnoDB 的数据,是因为 InnoDB 的数据存储方式能够通过配置来决定是使用共享表空间存放存储数据,还是用独享表空间存放存储数据。
    • 独享表空间存储方式使用.ibd文件,并且每个表一个.ibd文件
    • 共享表空间存储方式使用.ibdata文件,所有表共同使用一个.ibdata文件(或多个,可自己配置)

InnoDB 存储架构

InnoDB 存储架构分为内存结构和磁盘结构。

InnoDB 内存结构的核心组件有:

  • Buffer Pool
  • Change Buffer
  • Adaptive Hash Index
  • Log Buffer

InnoDB 磁盘结构的核心组件有:

  • Tablespace
  • Doublewrite Buffer
  • redo log
  • undo log

InnoDB 表空间

行(row)

数据库表中的记录都是按行(row)进行存放的,每行记录根据不同的行格式,有不同的存储结构。

后面我们详细介绍 InnoDB 存储引擎的行格式,也是本文重点介绍的内容。

页(page)

记录是按照行来存储的,但是数据库的读取并不以「行」为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。

因此,InnoDB 的数据是按「页」为单位来读写的,也就是说,当需要读一条记录的时候,并不是将这个行记录从磁盘读出来,而是以页为单位,将其整体读入内存。

默认每个页的大小为 16KB,也就是最多能保证 16KB 的连续存储空间。

页是 InnoDB 存储引擎磁盘管理的最小单元,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。

页的类型有很多,常见的有数据页、undo 日志页、溢出页等等。数据表中的行记录是用「数据页」来管理的,数据页的结构这里我就不讲细说了,之前文章有说过,感兴趣的可以去看这篇文章:换一个角度看 B+ 树(opens new window)

总之知道表中的记录存储在「数据页」里面就行。

区(extent)

我们知道 InnoDB 存储引擎是用 B+ 树来组织数据的。

B+ 树中每一层都是通过双向链表连接起来的,如果是以页为单位来分配存储空间,那么链表中相邻的两个页之间的物理位置并不是连续的,可能离得非常远,那么磁盘查询时就会有大量的随机 I/O,随机 I/O 是非常慢的。

解决这个问题也很简单,就是让链表中相邻的页的物理位置也相邻,这样就可以使用顺序 I/O 了,那么在范围查询(扫描叶子节点)的时候性能就会很高。

那具体怎么解决呢?

在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配。每个区的大小为 1MB,对于 16KB 的页来说,连续的 64 个页会被划为一个区,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了

段(segment)

表空间是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为数据段、索引段和回滚段等。

  • 索引段:存放 B + 树的非叶子节点的区的集合;
  • 数据段:存放 B + 树的叶子节点的区的集合;
  • 回滚段:存放的是回滚数据的区的集合。

好了,终于说完表空间的结构了。接下来,就具体讲一下 InnoDB 的行格式了。

之所以要绕一大圈才讲行记录的格式,主要是想让大家知道行记录是存储在哪个文件,以及行记录在这个表空间文件中的哪个区域,有一个从上往下切入的视角,这样理解起来不会觉得很抽象。

InnoDB 内存结构

Buffer Pool

Buffer Pool 用于加速数据的访问和修改,通过将热点数据缓存在内存的方法,最大限度地减少磁盘 IO,加速热点数据的读和写。

Buffer Pool 中数据以页为存储单位,其实现数据结构是以页为单位的单链表

由于内存的空间限制,Buffer Pool 仅能容纳最热点的数据。Buffer Pool 使用最近最少使用算法(Least Recent Used,LRU)算法淘汰非热点数据页。

依据时间局部性原理与空间局部性原理,Buffer Pool 在存储当前活动数据页的时候,会以预读 Read-ahead 的方式缓存目标数据页临近的数据页。

预读机制带来预读失败的问题,InnoDB 采用分代机制解决预读失败问题:将 Buffer Pool 分为 New SubList 和 Old SubList 两部分,将最新读取的数据页置于 Old SubList 头部,Old SubList 中的数据再次被访问到才会置于 New SubList 头部;预读失败的冷数据将更快地从 Old SubList 中淘汰,而不会影响到 New SubList 中原有的热数据。

预读失败问题可以引申到缓冲池污染问题,InnoDB 采用时间窗口(Time Window)机制解决缓冲池污染问题:对于 Old SubList 中的数据页,必须在 Old SubList 中停留到达指定时间之后再次被访问到,才能转移到 New SubList 中,默认窗口大小是 1s。

对于 Buffer Pool 中数据的查询,InnoDB 直接读取返回;对于 Buffer Pool 中数据的修改,InnoDB 直接在 Buffer Pool 中修改,并将修改写入 redo Log 中,当数据页被 LRU 算法淘汰时写入磁盘,若持久化前系统崩溃,则在重启后使用 redo Log 进行恢复。

Change Buffer

Change Buffer 用于加速非热点数据中二级索引的写入操作。由于二级索引数据的不连续性,导致修改二级索引时需要进行频繁的磁盘 IO 消耗大量性能,Change Buffer 缓冲对二级索引的修改操作,同时将写操作录入 redo log 中,在缓冲到一定量或系统较空闲时进行 ibuf merge 操作将修改写入磁盘中。Change Buffer 在系统表空间中有相应的持久化区域。

Change Buffer 大小默认占 Buffer Pool 的 25%,在引擎启动时便初始化完成。其物理结构为一棵名为 ibuf 的 B Tree。Change Buffer 的使用条件为:

  • InnoDB 开启 innodb_change_buffering,且该表当前没有 flush 操作。
  • 仅对二级索引树的叶子节点进行修改,且该索引页不在 Buffer Pool 中。
  • 对于 Unique 二级索引,仅删除操作可以缓冲。

ibuf merge 时机为:

  • 用户使用该二级索引进行查询时。
  • 缓存插入操作时,预估到 page 空间不足可能导致索引页分裂时。
  • 本次缓存操作将导致 ibuf btree 页分裂,且分类后 Change Buffer 大小将超出限制时。
  • master 线程发起 merge 命令时。
  • 用户对该表进行 flush 操作时。

Adaptive Hash Index

自适应哈希索引(Adaptive Hash Index)用于实现对于热数据页的一次查询。使用聚簇索引进行数据页定位的时候需要根据索引树的高度从根节点走到叶子节点,通常需要 3 到 4 次查询才能定位数据。InnoDB 根据对索引使用情况的分析和索引字段的分析,通过自调优 Self-tuning 的方式为索引页建立或者删除哈希索引。

AHI 所作用的目标是频繁查询的数据页和索引页,而由于数据页是聚簇索引的一部分,因此 AHI 是建立在索引之上的索引,对于二级索引,若命中 AHI,则将直接从 AHI 获取二级索引页的记录指针,再根据主键沿着聚簇索引查找数据;若聚簇索引查询同样命中 AHI,则直接返回目标数据页的记录指针,此时就可以根据记录指针直接定位数据页

AHI 的大小为 Buffer Pool 的 1/64,再 MySql 5.7 之后支持分区,以减少对于全局 AHI 锁的竞争,默认分区数为 8。

Log Buffer

Log Buffer 是用于缓冲待写入磁盘日志文件的数据。InnoDB 的所有修改操作都会被写入 redo log、undo log 等日志文件,如果每次都直接写入磁盘,会引发大量 IO。Log Buffer 正是针对此进行了优化:先将修改操作缓冲于此内存区域,然后定期批量 刷新到磁盘。

日志缓冲区大小可以由配置 innodb_log_buffer_size 控制,默认大小为 16MB。

MyISAM

MyISAM 是 MySQL 5.5 版本以前的默认存储引擎。

MyISAM 设计简单,数据以紧密格式存储。对于只读数据,或者表比较小、可以容忍修复操作,则依然可以使用 MyISAM。

MyISAM 引擎使用 B+Tree 作为索引结构,叶节点的 data 域存放的是数据记录的地址

MyISAM 提供了大量的特性,包括:全文索引、压缩表、空间函数等。但是,MyISAM 不支持事务和行级锁。并且 MyISAM 不支持崩溃后的安全恢复。

MyISAM 物理文件结构为:

  • .frm文件:与表相关的元数据信息都存放在 frm 文件,包括表结构的定义信息等。
  • .MYD (MYData) 文件:MyISAM 存储引擎专用,用于存储 MyISAM 表的数据。
  • .MYI (MYIndex)文件:MyISAM 存储引擎专用,用于存储 MyISAM 表的索引相关信息。

InnoDB vs. MyISAM

InnoDB 和 MyISAM 的对比:

对比项 MyISAM InnoDB
外键 不支持 支持
事务 不支持 支持四种事务隔离级别
锁粒度 支持表级锁 支持表级锁、行级锁
索引 采用 B+ 树索引(非聚簇索引) 采用 B+ 树索引(聚簇索引)
表空间
关注点 性能 事务
计数器 维护了计数器,SELECT COUNT(*) 效率为 O(1) 没有维护计数器,需要全表扫描
自动故障恢复 不支持 支持(依赖于 redo log)

参考资料