后端工程师的高阶面经 #################### * 邓明(大明),2023-08-27 * 前 Shopee 高级工程师,Beego PMC,极客时间训练营明星讲师,主讲《初级 Go 工程师训练营》《Go 实战训练营》,曾辅导学员 2000 余人。 * 他长期奋战在互联网一线,擅长中间件设计和实现,如 Web、ORM、微服务框架、网关、分库分表、IM 等,造高并发大流量轮子的经验非常丰富。他还是开源社区的活跃贡献者,游走在多个开源社区之间,是 Beego PMC、Apache Dubbo Committer,热衷带人参与开源。 * https://time.geekbang.org/column/intro/100551601 开篇词 ====== 课程设计 -------- 第一章:微服务架构 微服务架构可以将大型应用拆分为多个小型服务,提高开发效率与性能。这个部分我们将学习最重要的几个服务治理手段,包括服务注册与发现、负载均衡、熔断、降级、限流、优雅调用第三方等。你可以根据具体情况选择不同的服务治理策略,来保证服务的高可用。 第二章:数据库与 MySQL 数据库和 MySQL 是存储数据的技术基础,其性能和稳定性关系到整个系统的效率和可靠性。这部分我们主要了解数据库索引、事务、SQL 优化、不停机数据迁移、分库分表等核心知识点与解决方案,让你能够懂原理、晓优化、重实践。 第三章:消息队列 消息队列和 Kafka 在分布式系统中担任着异步处理、流式计算等重要的角色,是构建高性能、可靠的分布式系统的必要工具。这部分我会带你了解消息队列的高可用和高性能原理以及实践中常见的问题,如积压、重复消费、消息可靠性等。让你上能理论压众人,下能实践解忧愁。 第四章:缓存 所谓缓存用得好,性能没烦恼。缓存可以大大提高系统的访问速度,减轻数据库访问压力。这部分内容基本涵盖了最热门的缓存模式、缓存击穿、雪崩、穿透等问题的解决方案,我将带你深入 Redis 的高可用和高性能原理,让你成为一个精通各种缓存奇技淫巧的人。 第五章:NoSQL 随着这些年行业技术栈演进,NoSQL 已经变得日益重要。这一模块我们会在掌握了基本的 NoSQL 概念和原理的基础上,对 MongoDB 和 ElasticSearch 常见的面试热点进行探讨,包括性能调优、高可用和高性能方案,帮助你更加全面地准备后端技术面试。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/opjBmy.png .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/OWDpEM.png 微服务架构面试思路图 01微服务架构 (10讲) =================== 01|服务注册与发现:AP和CP ------------------------ * 关键词: 注册数据、分组、心跳、换节点、客户端容错、体量小 面试准备 ^^^^^^^^ * 你们用了什么中间件作为注册中心以及该中间件的优缺点。确保自己在回答“你为什么用某个中间价作为注册中心”的时候,能够综合这些优缺点来回答。 * 注册中心的集群规模。 * 读写 QPS(每秒查询率)。 * 机器性能,如 CPU 和内存大小。 * 最好准备一个注册中心出故障之后你排查和后续优化的案例。在讨论使用注册中心的注意事项,或者遇到过什么 Bug 的时候可以用这个案例。 基本模型 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/KJNixG.png 注册数据主要是定位信息。非主要数据取决于微服务框架的功能特性,例如常见的分组功能 高可用 ^^^^^^ .. note:: 服务注册与发现的整个模型比较简单,不过要在实践中做到高可用还是很不容易的。 服务端崩溃检测 """""""""""""" * 注册中心怎么判断服务端已经崩溃了:心跳一断就认证断还是多次心跳后认为断? * 影响到可用性的一个关键点是注册中心需要尽快发现服务端宕机。在基本模型里面,如果服务端突然宕机,那么服务端是来不及通知注册中心的。所以注册中心需要有一种检测机制,判断服务端有没有崩溃。在服务端崩溃的情况下,要及时通知客户端,不然客户端就会继续把请求发送到已经崩溃的节点上。 * 这种检测就是利用心跳来进行的。当注册中心发现和服务端的心跳失败了,那么它就应该认为服务端可能已经崩溃了,就立刻通知客户端停止使用该服务端。但是这种失败可能是偶发性的失败,比如说因为网络偶尔不稳定造成的。所以注册中心要继续保持心跳。如果几次心跳都失败了,那么就可以认为服务端已经彻底不可用了。但是如果心跳再次恢复了,那么注册中心就要再次告诉客户端这个服务端是可用的。 * 如果不考虑重试间隔的话,就难以避开偶发性的失败。比如说注册中心和服务端之间网络抖动,那么第一次心跳失败之后,你立刻重试多半也是失败的,因为此时网络很可能还是不稳定。所以比较好的策略是立刻重试几次,如果都失败了就再间隔一段时间继续重试。所有的重试机制实际上也是要谨慎考虑重试次数和重试间隔的,确保在业务可以接受的范围内重试成功。不过再怎么样,从服务端崩溃到客户端知道,中间总是存在一个时间误差的,这时候就需要客户端来做容错了。 * 优缺点:心跳间隔太短容易在网络抖动时,就已经多次心跳失败,导致认为服务崩溃;还有就是会造成心跳本身就就把服务器打垮。心跳间隔太长会导致服务崩溃发现时间太长。 客户端容错 """""""""" * 关键词:换节点,failover * 客户端容错是指尽量在注册中心或者服务端节点出现问题的时候,依旧保证请求能够发送到正确的服务端节点上。 * 客户端容错第一个要考虑的是如果某个服务端节点崩溃了该怎么办。在服务端节点崩溃之后,到注册中心发现,再到客户端收到通知,是存在一段延时的(时延等于服务端和注册中心心跳间隔+通信时间,后者忽略不计)。在这段延时内,客户端发送请求给这个服务端节点都会失败。 * 这个时候需要客户端来做一些容错。一般的策略是客户端在发现调不通之后,应该尝试换另外一个节点进行重试。如果客户端上的服务发现组件或者负载均衡器能够根据调用结果来做一些容错的话,那么它们应该要尝试将这个节点挪出可用节点列表,在短时间内不要再使用这个节点了。后面再考虑将这个节点挪回去。 注册中心选型 """""""""""" * 要考虑的因素非常多。比如说中间件成熟度、社区活跃度、性能等因素。 * 注册中心更加关注 CAP 中选 CP 还是选 AP 的问题。 总结 ^^^^ .. note:: 把整个模型想成是一个三角形,而解决高可用问题的关键就是这个三角形任何一条边出问题了该怎么办。 思考题 """""" * 注册中心崩溃,你的系统会怎样? * 启动备份注册中心,而且是异构的备份中心。这里你们能做到自动切换吗?还是依赖于人手动切换? * 兜底节点:就是人手动配置一些固定 IP,万一注册中心崩了就用这个。这个缺陷就是 IP 需要人来维护,比如说万一某个IP 不可用了。 * 选择 AP 和 CP 标准: * 小规模集群选 CP,大规模集群选 AP。 * 优先选用 CP,直到发现公司的体量上来了,CP 的注册中心撑不住读写压力了,就换 AP。 02|负载均衡:调用结果,缓存机制是怎么影响负载均衡 ----------------------------------------------- 前置知识 ^^^^^^^^ 静态负载均衡算法 """""""""""""""" * 轮询和加权轮询和平滑的加权轮询、随机和加权随机,哈希和一致性哈希 * 平滑的加权轮询:: 1. 对每一个节点,执行 currrentWeight = currrentWeight + weight。 2. 挑选最大 currrentWeight 的节点作为目标节点。 3. 将目标节点的 currrentWeight 修改为 currrentWeight= currrentWeight - sum(weight)。 * 一致性哈希负载均衡(Redis Cluster):引入了一个哈希环的概念,服务端节点会落在环的某些位置上。客户端根据请求参数,计算一个哈希值。这个哈希值会落在哈希环的某个位置。从这个位置出发,顺时针查找,遇到的第一个服务端节点就是目标节点。更复杂的情况下,可以增加虚拟结点。 动态负载均衡算法 """""""""""""""" * 最少连接数:缺陷在于,连接数并不能代表节点的实际负载,尤其是在连接多路复用的情况下。 * 最少活跃数:活跃请求数量也不能真正代表服务端节点的负载。比如,服务端节点 1 虽然只有最少的 10 个请求,但是有可能这 10 个请求都是大请求 * 最快响应时间:响应时间和前面的两个指标比起来,是一种综合性的指标,所以用响应时间来代表服务端节点负载要更加准确。响应时间选取上可用:平均值、99线、999线、中位数等;另外还要考虑衰减问题。 * 最少连接数、最少活跃请求数和最快响应时间,都可以看作是选择了单一的指标来代表一个节点的负载。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/xPQb6i.png 这种算法还有一个问题,就是它们都是客户端来采集数据的。那么不同的客户端就可能采集到不同的数据。如图所示,因为客户端 1 本身并不知道客户端 2 上还有 30 个连接,因此它选择了服务端节点 1。而实际上它应该选择服务端节点 2。 * 解决方案是让服务端上报指标,而不是客户端采集。总体上有两种思路。 * 第一种思路是服务端在返回响应的时候顺便把服务端上的一些信息一并返回。这种思路需要微服务框架支持从服务端往客户端回传链路元数据。 * 第二种思路是从观测平台上查询。例如通过查询 Prometheus 来获得各种指标数据。 * 不过目前业界很少用这种复杂的负载均衡算法 * 在实际工作中也可以利用这个思路来设计自己的负载均衡算法。比如说在 CPU 密集型的应用里面,你可以设计一个负载均衡算法,每次筛选 CPU 负载最低的节点。难点则是你需要考虑怎么采集到所有服务端节点的 CPU 负载数据。 面试准备 ^^^^^^^^ 重点算法要点 """""""""""" * 轮询和加权轮询:对应的平滑加权轮询算是一个小亮点。 * 一致性哈希负载均衡:这个可以结合 Redis 之类的使用了一致性哈希算法的中间件一起理解。 * 最快响应时间算法:这个算法体现了采集指标随着时间准确性衰减的特性,后面在服务治理的部分你会再次接触到类似的东西。 可能遇到的问题 """""""""""""" * 如果公司有 Nginx 之类的网关,或者微服务网关,那么用的是什么负载均衡算法? * 如果公司用客户端负载均衡的话,用的是什么负载均衡算法? * 有没有出过和负载均衡相关的事故,如果有,那么是什么原因导致的,怎么解决的这个事故,它体现了负载均衡算法的什么缺陷? 亮点方案 ^^^^^^^^ * 要结合自己公司的实际情况,说明自己用的是什么负载均衡算法。 * 示例:一般来说,加权类的算法都要考虑权重的设置和调整。我们公司用的是轮询来作为负载均衡。不过因为轮询没有实际查询服务端节点的负载,所以难免会出现偶发性的负载不均衡的问题。比如说我们之前发现线上的响应时间总体来说是非常均匀的,但是每隔一段时间就会出现响应时间特别慢的情况。而且时间间隔是不固定的,慢的程度也不一样,所以就很奇怪。后来我们经过排查之后,发现是因为当一个**大请求**落到一个节点的时候,它会占据大量的内存和 CPU。如果这时候再有请求打到同一个节点上,这部分请求的响应时间就会非常慢。 * 可以从业务拆分或者隔离的角度回答:(业务拆分)这个大请求其实是一个大的批量请求。后来我们限制一批最多只能取 100 个就解决了这个问题。(隔离角度)我们稍微魔改了一下负载均衡算法,不再是单纯的轮询了。我们每天计算一批大客户,这部分大客户的请求会在负载均衡里面被打到专门的几个节点上。虽然大客户的请求依旧很慢,但是至少别的客户不会再受到他们的影响了。 * 负载均衡算法有些时候用得好,是能够解决一些技术问题的,比如说缓存。 调用结果对负载均衡的影响 """""""""""""""""""""""" * 加权类的负载均衡,实际上在工作中我们可以考虑根据调用结果来动态调整这个权重。 * 关键词:成加败减 * 权重代表节点的处理能力,当然在一些场景下它也代表节点的可用性或者重要性。所以权重根据节点的实际情况来设置值就可以。权重的要点在于体现不同节点的差异性,它的绝对值并不重要。一般来说为了进一步提高可用性,加权类的负载均衡算法都会考虑根据调用结果来动态调整权重。如果调用成功了,那么就增加权重;如果调用失败了,那么就减少权重。 * 这里调用成功与否是一种非业务相关的概念,也就是说即便拿到了一个失败的响应,但是本身也算是调用成功了。调用失败了大多数时候是指网络错误、超时等。而在实际落地的时候,也可以考虑如果是网络引起的失败,那么权重下调就多一点,因为这一类的错误意味着问题更加严重。如果是超时这种,那么权重就下调少一点,因为这种错误是比较容易恢复过来的。 * 权重的调整要设置好上限和下限。 * 关键词:上下限。 * 调整权重的算法都要考虑安全问题,即权重的调整应该有上限和下限。比如说一般下限不能为 0,因为一个节点的权重为 0 的话,它可能永远也不会被选中,又或者和 0 的数学运算会出现问题导致负载均衡失败。上限一般不超过初始权重的几倍,比如说两倍或者三倍,防止该节点一直被连续选中。当然,如果在实现的时候使用了 uint 或者 Int8 之类的数字,还要进一步考虑溢出的问题。之前挺多公司因为没有控制上下限而引起了线上故障。 * 可以尝试将话题引导到服务注册与发现中,关键词是可用性。 * 这种根据调用结果来调整权重的方式,有点类似于在服务中将暂时调用不通的节点挪出可用节点列表,本质上都是为了进一步提高系统的可用性。 哈希一致性结合本地缓存 """""""""""""""""""""" * 问题:正常情况下,如果你使用本地缓存,那么同一个 key 对应的请求,可能会被打到不同的节点上。这就会造成两个问题,一个是严重的缓存未命中,一个是不同节点都要缓存同样的数据,导致内存浪费和极其严重的数据一致性问题。 * 解决:把类似的请求都让同一个节点来处理。比如说对某个用户数据的请求都打到同一个节点上。显然适合的负载均衡算法就两个:哈希或者一致性哈希。 * 考虑节点可能上线、下线的情况,那么一致性哈希负载均衡就是最优选择 * 一致性哈希方案:在性能非常苛刻的时候,我们会考虑使用本地缓存。但是使用本地缓存的数据一致性问题会非常严重,而我们可以尝试将一致性哈希负载均衡算法和本地缓存结合在一起,以提高缓存命中率,并且降低本地缓存的总体内存消耗。比如说针对用户的本地缓存,我们可以使用用户 ID 来计算哈希值,那么可以确保同一个用户的本地缓存必然在同一个节点上。不过即便是采用了一致性哈希负载均衡算法,依旧不能彻底解决数据一致性的问题,只能缓解一下。 * 不能彻底解决数据一致性的问题的原因 * 关键词是应用发布。 * 当整个集群的节点数量发生变化的时候,就难免会导致同样的数据缓存在多个节点上。 * 例如在用户这个例子中,假如最开始有一个请求需要 user_id 为 1 的昵称小明,这个请求最开始会命中老节点。但是此时还没有查询到数据。紧接着扩容。此时又来了一个请求,那么它会被导去新节点。这一个请求会将 user_id 为 1 的昵称改为小刚。如果这时候第一个请求从老节点的缓存上读出了数据,那么它拿到的就还是老的数据。而应用发布是引起节点数量变化最常见的原因。毕竟应用发布可以看作先下线一个节点,再上线一个节点。 * 但总的来说,在本地缓存结合了一致性哈希负载均衡算法之后数据一致性的问题已经被大大缓解了。 面试思路总结 ^^^^^^^^^^^^ * 几个关键词,分别是大请求、成加败减、上下限、可用性、应用发布 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/EM5MR8.jpg * 轮询比随机使用得多的原因:随机其实是不均衡的,可能会出现多次命中同一个服务端节点的情况,导致该服务端节点负载过高,严重的还有可能会产生服务雪崩。 * 对于大请求的处理:在负载均衡里面会检测一下是不是大请求。如果是大请求,就发送到几台专门的机器上。 03|熔断:熔断-恢复-熔断-恢复,抖来抖去怎么办 ------------------------------------------ 前置知识 ^^^^^^^^ * 一是阈值如何选择;二是超过阈值之后,要求响应时间超过一段时间之后才触发熔断。这主要是出于两个考虑,一个是响应时间可能是偶发性地突然增长;另外一个则是防止抖动。 * 所谓抖动就是服务频繁地在正常 - 熔断两个状态之间切换。 * 熔断:在微服务架构里面是指当微服务本身出现问题的时候,它会拒绝新的请求,直到微服务恢复。 面试准备 ^^^^^^^^ 可能遇到的问题 """""""""""""" * 怎么判断微服务出现故障的?比如说错误率、响应时间等等。 * 怎么判断微服务已经从故障中恢复过来的? * 在判断微服务已经恢复过来之后,有没有采取什么措施来防止抖动的问题? 普通方案 ^^^^^^^^ * 这是一个高可用的微服务系统,为了保证它的可用性,我采取了限流、降级、熔断等措施。 * 假如说你准备用响应时间来作为指标,那么你可以这么回答,关键词是持续超过阈值。 * 为了保障微服务的可用性,我在我的核心服务里面接入了熔断。针对不同的服务,我设计了不同的微服务熔断策略。 * 比如说最简单的熔断策略就是根据响应时间来进行。当响应时间超过阈值一段时间之后就会触发熔断。我一般会根据业务情况来选择这个阈值,例如,如果产品经理要求响应时间是 1s,那么我会把阈值设定在 1.2s。如果响应时间超过 1.2s,并且持续三十秒,就会触发熔断。在触发熔断的情况下,新请求会被拒绝,而已有的请求还是会被继续处理,直到服务恢复正常。 * 面试官可能的问题: * 这阈值还可以怎么确定?那么你就回答还可以根据观测到的响应时间数据来确定。 * 这个持续三十秒是如何计算出来的?这个问题其实可以坦白回答是基于个人经验,然后你解释一下过长或者过短的弊端就可以了。 * 为什么多了 0.2s?那么你可以解释是留了余地,防止偶发性的响应时间变长的情况。 * 怎么判断服务已经恢复正常了?那么你可以回答等待一段固定的时间,然后尝试逐渐放开流量。 缓存崩溃方案 """""""""""" * 我还设计过一个很有趣的熔断方案。我的一个接口并发很高,对缓存的依赖度非常严重。所以我的熔断策略是要是缓存不可用,比如说 Redis 崩溃了,那么我就会触发熔断。这里如果我不熔断的话,请求会因为 Redis 崩溃而全部落到 MySQL 上,基本上会压垮 MySQL。 * 在触发熔断之后,我会额外开启一个线程(如果是 Go 就换成 Goroutine)持续不断地 ping Redis。如果 Redis 恢复了,那么我就会退出熔断状态,新来的请求就不会被拒绝了。 * 引导的点。 * 缓存问题:在这里我提到了 Redis 失效,这种情况类似于缓存雪崩,那么你很自然地就可以把话题引导到如何处理缓存击穿、穿透、雪崩这些经典问题上。 * 高可用 MySQL:我在这里使用的是熔断来保护 MySQL,类似地,你也可以考虑用限流来保护 MySQL。 * 抖动问题 * 退出熔断状态后,逐步放开流量,防止服务出现抖动问题。 * 我这种逐步放开流量的方案其实还是有缺陷的,还有一些更加高级的做法,但是需要负载均衡来配合。 亮点方案 ^^^^^^^^ * 上面的方案,服务端还是收到了 100% 的流量,只不过只有部分流量会被放过去并且被正常处理。 * 可以直接让客户端来控制这个流量 整体流程 """""""" * 服务端在触发熔断的时候,会返回一个代表熔断的错误。 * 客户端在收到这个错误之后,就会把这个服务端节点暂时挪出可用节点列表。后续所有的新请求都不会再打到这个触发了熔断的服务端节点上了。 * 客户端在等待一段时间后,逐步放开流量。 * 如果服务端正常处理了新来的请求,那么客户端就加大流量。 * 如果服务端再次返回了熔断响应,那么客户端就会再一次将这个节点挪出可用列表。 * 如此循环,直到服务端完全恢复正常,客户端也正常发送请求到该服务端节点。 整体思路 """""""" * 关键词是负载均衡 * 利用负载均衡来控制流量。如果一个服务端节点触发了熔断,那么客户端在做负载均衡的时候就可以将这个节点挪出可用列表,后续请求会发给别的节点。在经过一段时间之后,客户端可以尝试发请求给该节点。如果该节点正确处理了,那客户端就可以加大流量。否则客户端就要再一次等待一段时间。 * 万一所有可用节点都触发熔断了,应该怎么办? * 需要兜底方案:比如说如果因为某些原因数据库出问题,导致某个服务所有的节点都触发了熔断,那么客户端就完全没有可用节点了。不过这个问题本身熔断解决不了,负载均衡也解决不了,只能通过监控告警之后人手工介入处理了。 面试思路总结 ^^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/1FEXEM.png 评论 ^^^^ * 现在的主流是根据错误率来判断要不要熔断 04|降级:为什么每次大促的时候总是要把退款之类的服务停掉 ------------------------------------------------------ 前置知识 ^^^^^^^^ * 降级的典型应用:如在双十一之类的大促高峰,平台是会关闭一些服务的,比如退款服务。它是一种手动的跨服务降级 * 这种降级的好处有两方面。一方面是腾出了服务器资源,可以给订单服务或者支付服务;另外一方面是减少了对公共组件的压力,比如说减少了对数据库的写入压力。 * 降级和熔断非常像。在一些场景下,你既可以用熔断,也可以用降级。比如说在响应时间超过阈值之后:选择熔断,完全不提供服务;考虑降级,提供有损服务。 跨服务降级 """""""""" * 当资源不够的时候可以暂停某些服务,将腾出来的资源给其他更加重要、更加核心的服务使用。我这里提到的大促暂停退款服务就是跨服务降级的例子。这种策略的要点是必须知道一个服务比另外一个服务更有业务价值,或者更加重要。 * 跨服务降级的措施是很粗暴的,常见的做法有三个:: 1. 整个服务停掉,例如前面提到的停掉退款服务。 2. 停掉服务的部分节点,例如十个节点,停掉其中五个节点,这五个节点被挪作他用。 3. 停止访问某些资源。 例如日志中心压力很大的时候,发信号给某些不重要的服务,让它们停止上传日志,只在本地保存日志。 本服务提供有损服务 """""""""""""""""" * 如各大 App 的首页都会有降级的策略。在没有触发降级的时候,App 首页是针对你个人用户画像的个性化推荐。而在触发了降级之后,则可能是使用榜单数据,或者使用一个运营提前配置好的静态页面。这种策略的要点是你得知道你的服务调用者能够接受什么程度的有损。 常见的降级思路:: 1. 返回默认值,这算是最简单的一种状况。 2. 禁用可观测性组件 正常来说在业务里面都充斥了各种各样的埋点。这些埋点本身其实是会带来消耗的,所以在性能达到瓶颈的时候,就可以考虑停用,或者降低采样率。 3. 同步转异步 即正常情况下,服务收到请求之后会立刻处理。 在降级下,服务在收到请求之后只会返回一个代表“已接收”的响应。 后续服务会异步地开启线程来处理,或者依赖于定时任务来处理。 4. 简化流程 如果你处理一个请求需要很多步骤,后续如果有一些步骤不关键的话,可以考虑不执行,或者异步执行。 例如在内容生产平台,一般新内容要被推送到推荐系统里面。 那么在降级的情况下你可以不推,而后可以考虑异步推送过去,也可以考虑等系统恢复之后再推送过去。 面试准备 ^^^^^^^^ * 两个方案:读写服务降级写服务和快慢路径降级慢路径。 * A 系统是我们公司的核心系统,而我的主要职责是保障该系统的高可用。为了达到这一个目标,我综合运用了熔断、降级、隔离等措施。 可能遇到的问题 """""""""""""" * 你是否了解服务治理? * 如何提高系统的可用性? * 如果系统负载很高该怎么办? * 依赖的下游服务或者下游中间件崩溃了怎么办? 基本思路 ^^^^^^^^ * 我在公司也用了降级来保护我维护的服务。举例来说,正常情况下我的服务都会全量采集各种监控指标。那么在系统触及性能瓶颈的时候,我就会调整采集的比率。甚至在关键的时候,我会直接停用掉所有的指标采集,将资源集中在提供服务上。 亮点方案 ^^^^^^^^ 读写服务降级写服务 """""""""""""""""" * 某个服务是同时提供了读服务和写服务,并且读服务明显比写服务更加重要 * 我在公司维护了一个服务,它的接口可以分成两类:一类是给 B 端商家使用的录入数据的接口,另外一类是给 C 端用户展示这些录入的数据。所以从重要性上来说,读服务要比写服务重要得多,而且读服务也是一个高并发的服务。 * 于是我接入了一个跨服务的降级策略。当我发现读服务的响应时间超过了阈值的时候,或者响应时间开始显著上升的时候,我就会将针对 B 端商家用户的服务临时停掉,腾出来的资源都给 C 端用户使用。对于 B 端用户来说,他们这个阶段是没有办法修改已经录入的数据的。但是这并不是一个特别大的问题。当 C 端接口的响应时间恢复正常之后,会自动恢复 B 端商家接口,商家又可以修改或者录入数据了。 * 虽然整体来说写服务 QPS 占比很低,但是对于数据库来说,一次写请求对性能的压力要远比一次读请求大。所以暂停了写服务之后,数据库的负载能够减轻不少。 * 类似的场景 * 在内容生产平台,作者生产内容,C 端用户查看生产的内容。那么在资源不足的情况下可以考虑停掉内容生产端的服务,只保留 C 端用户查看内容的功能。 * 如果你的用户分成普通用户和 VIP 用户,那么你也可以考虑停掉给普通用户的服务。甚至,如果一个服务既提供给普通用户,也提供给 VIP 用户,你可以考虑将普通用户请求拒绝掉,只服务 VIP 用户。 * 在理论层面上拔高一下 * 这个方案就是典型的跨服务降级。跨服务降级可以在大部分合并部署的服务里面使用,一般的原则就是 B、C 端合并部署降级 B 端;付费服务和非付费服务降级非付费服务。当然也可以根据自己的业务价值,将这些部署在同一个节点上的服务分成三六九等。而后在触发降级的时候从不重要的服务开始降级,将资源调配给重要服务。 * 判断一个服务的业务价值最简单的方法就是问产品经理,产品经理自然是清楚什么东西带来了多少业务价值。又或者根据公司的主要营收来源确定服务的业务价值,越是能赚钱的就越重要。唯一的例外是跟合规相关的。比如说内容审核,它不仅不赚钱,还是一块巨大的成本支出。但是不管怎么降级,内容审核是绝对不敢降级的,不然就等着被请去喝茶交代问题吧。 * 微服务框架 * 关键词就是跨节点。 * 不过这种跨服务降级都是只能降级处在同一个节点的不同服务。而如果服务本身就分布在不同节点上的话,是比较难设计这种降级方案的。比如说大促时关闭退款服务这种,就需要人手工介入。 * 从理论上来说,网关其实是可以考虑支持这种跨节点的服务降级的。假如说我们有 A、B 两个服务,A 比 B 更加有业务价值。那么在 A 服务所需资源不足的时候,网关可以考虑停掉 B 的一部分节点,而后在这些节点上部署 A 服务。对于 B 服务来说,它只剩下一部分节点,所以也算是被降级了。很可惜,大部分网关的降级设计都没考虑过这种跨服务降级的功能。 * 微服务框架做得就更差了。大部分微服务框架提供的降级功能都是针对本服务的,比如说在触发降级的时候返回一个默认值。 快慢路径降级慢路径 """""""""""""""""" * 我还用过另外一个降级方案。正常来说在我的业务里面,就是查询缓存,如果缓存有数据,那么就直接返回。如果缓存没有,那么就需要去数据库查询。如果此时系统的并发非常高,那么我就会采取降级策略,将请求标记为降级请求。降级请求只会查询缓存,而不会查询数据库。如果缓存没有,那就直接返回错误。这样能够有效防止因为少部分请求缓存未命中而占据大量系统资源,导致系统吞吐量下降和响应时间显著升高。 * 这种思路其实可以在很多微服务里面应用。如果一个服务可以分成快路径和慢路径两种逻辑,那么在降级之前就可以先走快路径,再走慢路径。而触发了降级之后,就只允许走快路径。在前面的例子里面,从缓存里加载数据就是快路径,从数据库里面加载数据就是慢路径。 * 慢路径还可以是发起服务调用或者复杂计算。比如说一个服务快路径是直接查询缓存,而慢路径可能是发起很多微服务调用,拿到所有响应之后一起计算,算出来一个结果并缓存起来。那么在降级的时候,可以有效提高吞吐量。不过这种吞吐量是有损的,毕竟部分请求如果没有在缓存中找到数据,那么就会直接返回失败响应。 面试思路总结 ^^^^^^^^^^^^ * 从系统整体上看,你可以考虑跨服务降级,例如大促的时候关闭退款服务。针对单一服务,也可以考虑提供有损服务。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/bF3i5e.png 评论 ^^^^ * 可以降级的例子: * 订单表数据太大了,把历史数据归档(比如每年归一次档),然后限制大家只能查近一年的订单,如果要查看更久之前的,要先提申请 * 只能熔断的例子: * 服务依赖的数据库无法正常使用,导致很多上游服务处理各种阻塞,此时要果断熔断,直接让服务返回错误,避免上游服务大量的积压业务消息,或有大量的未释放的资源,导致上游服务不健康 05|限流:别说算法了,就问你“阈值”怎么算 ------------------------------------- * 和熔断、降级比起来,限流要更加复杂一些。 * 一个核心问题就是限流需要确定一个流量阈值,这个阈值该怎么算? 前置知识 ^^^^^^^^ * 解决异常突发流量打崩系统的问题。例如常见的某个攻击者攻击你维护的系统,那么限流就能极大程度上保护住你的系统。 算法 """" * 静态算法包含令牌桶、漏桶、固定窗口和滑动窗口。这些算法就是要求研发人员提前设置好阈值。在算法运行期间它是不会管服务器的真实负载的。 * 动态算法也叫做自适应限流算法,典型的是 BBR 算法。这一类算法利用一系列指标来判定是否应该减少流量或者放大流量。动态算法和 TCP 的拥塞控制是非常接近的,只不过 TCP 控制的是报文流量,而微服务控制的是请求流量。 常见的算法 """""""""" * 令牌桶: 系统会以一个恒定的速率产生令牌,这些令牌会放到一个桶里面,每个请求只有拿到了令牌才会被执行。 * 漏桶: 是指当请求以不均匀的速度到达服务器之后,限流器会以固定的速率转交给业务逻辑。 * 固定窗口是指在一个固定时间段,只允许执行固定数量的请求。比如说在一秒钟之内只能执行 100 个请求。 * 滑动窗口类似于固定窗口,也是指在一个固定时间段内,只允许执行固定数量的请求。区别就在于,滑动窗口是平滑地挪动窗口,而不像固定窗口那样突然地挪动窗口。 限流对象 ^^^^^^^^ * 从单机或者集群的角度看,可以分为单机限流或者集群限流。集群限流一般需要借助 Redis 之类的中间件来记录流量和阈值。换句话说,就是你需要用 Redis 等工具来实现前面提到的限流算法。 * 针对业务对象限流,这一类限流对象就非常多样:: 1. VIP 用户不限流而普通用户限流。 2. 针对 IP 限流。 用户登录或者参与秒杀都可以使用这种限流, 比方说设置一秒钟最多只能有 50 个请求,即便考虑到公共 IP 的问题,正常的用户手速也是没那么快的。 3. 针对业务 ID 限流,例如针对用户 ID 进行限流。 限流后的做法 """""""""""" * 同步阻塞等待一段时间。如果是偶发性地触发了限流,那么稍微阻塞等待一会儿,后面就有极大的概率能得到处理。比如说限流设置为一秒钟 100 个请求,恰好来了 101 个请求。多出来的一个请求只需要等一秒钟,下一秒钟就会被处理。但是要注意控制住超时,也就是说你不能让人无限期地等待下去。 * 同步转异步。这里我们又一次看到了这个手段,它是指如果一个请求没被限流,那就直接同步处理;而如果被限流了,那么这个请求就会被存储起来,等到业务低峰期的时候再处理。这个其实跟降级差不多。 * 调整负载均衡算法。如果某个请求被限流了,那么就相当于告诉负载均衡器,应该尽可能少给这个节点发送请求。我在熔断里面给你讲过类似的方案。不过在熔断里面是负载均衡器后续不再发请求,而在限流这里还是会发送请求,只是会降低转发请求到该节点的概率。调整节点的权重就能达成这种效果。 面试准备 ^^^^^^^^ * 动态算法,比如 BBR,就不作硬性的要求了。这主要是因为 BBR 的原理和实现都很有难度,大多数微服务框架都没提供 BBR 的限流器实现。 * 手写限流算法,漏桶、令牌桶、滑动窗口和固定窗口这几个算法你都要能写出来,至少要能把基本思路写清楚。 可能遇到的问题 """""""""""""" * 限流的阈值是多少,为什么设定成这个阈值? * 被限流的请求会被怎么处理,是直接拒绝还是阻塞直到超时,还是转为异步处理? 建议 """" * 面试限流的最好策略就是为自己打造一个掌握了高可用微服务架构的人设。而限流就是你在提高系统可用性时的一个具体策略。 * 如果你前面和面试官已经聊到了熔断和降级,那么就可以直接把话题引导到限流上。 * 在讨论对外的 API,如 HTTP 接口或者公共 API 时,可以强调使用限流来保护系统。 * 在讨论 TCP 拥塞控制时,你可以提起在服务治理上限流也借鉴了 TCP 拥塞控制的一些思想。 * 在讨论 Redis 或者类似产品的时候,你可以提你用 Redis 实现过集群限流。 基本思路 ^^^^^^^^ .. note:: 先阐述限流的总体目标,然后回答前置知识里面的三个点:算法、限流对象和限流后的做法,最后再把话题引到计算阈值上。 * 限流是为了保证系统可用性,防止系统因为流量过大而崩溃的一种服务治理手段。 * 从算法上来说,有令牌桶、漏桶、固定窗口和滑动窗口算法。还有动态限流算法,或者说自适应限流算法,比较有名的就是参考了 TCP 拥塞控制算法 BBR 衍生出来的算法,比如说 B 站开源的 Kratos 框架就有一个实现。这些算法之间比较重要的一个区别是能否处理小规模的突发流量。 * 从限流对象上来说,可以是集群限流或者单机限流,也可以是针对具体业务来做限流。比如说在登录的时候,我们经常针对 IP 进行限流。又或者在一些增值服务里面,非付费用户也会被限流。 * 触发限流之后,具体的措施也可以非常灵活。被限流的请求可以同步阻塞一段时间,也可以考虑同步转异步。如果负载均衡算法灵活的话,也可以做一些调整,减少发到该节点的概率。 * 用好限流的一个重要前提是能够设置准确的阈值,例如每秒钟限制在 100 个请求还是限制在 200 个请求。如果阈值过低,那么系统资源就容易闲置浪费;如果阈值太高,那么系统可能撑不住那么多流量,导致崩溃。 真实案例 """""""" * 关键词是 IP 限流。 * 我在我们公司的登录接口里面就引入了限流机制。正常情况下,一个用户在一秒钟内最多点击一次登录,所以针对每一个 IP,我限制它最多只能在一秒内提交 50 次登录请求。这个 50 充分考虑到了公共 IP 的问题,正常用户是不可能触发这个阈值的。这个限流虽然很简单,但是能够有效防范一些攻击。不过限流再怎么防范,还是会出现系统撑不住流量的情况。 算法问题 """""""" * 我在 GitHub 上有一个开源仓库,里面放了我为 gRPC 实现的各种限流算法,包括基于 Redis 实现的集群限流版本。 * 如果你是跨语言面试,比如你是 Python 转 Go,你就可以强调一下,这个原本在我司是 Python 写的,后来我用 Go 又写了一遍。如果你有进行一些改进,你可以把你具体改进的内容表述出来。 突发流量 """""""" * 固定窗口和滑动窗口则有另外一个类似的问题,就是毛刺问题。 * 假如一个窗口大小是一分钟 1000 个请求,你预计这 1000 个请求会均匀分散在这一分钟内。那么有没有可能第一秒钟就来了 1000 个请求?当然可能。那当下这一秒系统有没有可能崩溃?自然也是可能的。 * 所以固定窗口和滑动窗口的窗口时间不能太长。比如说以秒为单位是合适的,但是以分钟作为单位就是不合适的。 漏桶算法非常均匀,但是令牌桶相比之下就没那么均匀。令牌桶本身允许积攒一部分令牌,所以如果有偶发的突发流量,那么这一部分请求也能得到正常处理。但是要小心令牌桶的容量,不能设置太大。不然积攒的令牌太多的话就起不到限流效果了。例如容量设置为 1000,那么要是积攒了 1000 个令牌之后真的突然来了 1000 个请求,它们都能拿到令牌,那么系统可能撑不住这突如其来的 1000 个请求。 请求大小 """""""" * 关键词是请求大小。 * 限流和负载均衡有点儿像,基本没有考虑请求的资源消耗问题。所以负载均衡不管怎么样,都会有偶发性负载不均衡的问题,限流也是如此。例如即便我将一个实例限制在每秒 100 个请求,但是万一这个 100 个请求都是消耗资源很多的请求,那么最终这个实例也可能会承受不住负载而崩溃。动态限流算法一定程度上能够缓解这个问题,但是也无法根治,因为一个请求只有到它被执行的时候,我们才知道它是不是大请求。 计算阈值 ^^^^^^^^ * 总体上思路有四个:看服务的观测数据、压测、借鉴、手动计算。 业务性能数据 """""""""""" * 关键词是。 * 我们公司有完善的监控,所以我可以通过观测到的性能数据来确定阈值。比如说观察线上的数据,如果在业务高峰期整个集群的 QPS 都没超过 1000,那么就可以考虑将阈值设定在 1200,多出来的 200 就是余量。 * 不过这种方式有一个要求,就是服务必须先上线,有了线上的观测数据才能确定阈值。并且,整个阈值很有可能是偏低的。因为业务巅峰并不意味着是集群性能的瓶颈。如果集群本身可以承受每秒 3000 个请求,但是因为业务量不够,每秒只有 1000 个请求,那么我这里预估出来的阈值是显著低于集群真实瓶颈 QPS 的。 压测 """" * 关键词是压测。 * 不过我个人觉得,最好的方式应该是在线上执行全链路压测,测试出瓶颈。即便不能做全链路压测,也可以考虑模拟线上环境进行压测,再差也应该在测试环境做一个压力测试。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/brDv5F.png 大部分性能测试的结果类似于图片里展示的这样: * 从理论上来说,你可以选择 A、B、C 当中的任何一个点作为你的限流的阈值。 * A 是性能最好的点。A 之前 QPS 虽然在上升,但是响应时间稳定不变。在这个时候资源利用率也在提升,所以选择 A 你可以得到最好的性能和较高的资源利用率。 * B 是系统快要崩溃的临界点。很多人会选择这个点作为限流的阈值。这个点响应时间已经比较长了,但是系统还能撑住。选择这个点意味着能撑住更高的并发,但是性能不是最好的,吞吐量也不是最高的。 * C 是吞吐量最高的点。实际上,有些时候你压测出来的 B 和 C 可能对应到同一个 QPS 的值。选择这个点作为限流阈值,你可以得到最好的吞吐量。 .. note:: 口诀就是性能 A、并发 B、吞吐量 C。 * 综合来说,如果是性能苛刻的服务,我会选择 A 点。如果是追求最高并发的服务,我会选择 B 点,如果是追求吞吐量的服务,我会选择 C 点。 * 面试官多半会杠你,压力测试特别难,或者有些服务根本测不了,那你怎么办。这个时候,你需要说点正确但没用的废话,关键词压测是基操。你在表述的时候语气要委婉,态度要坚决。 * 一般我会认为一家公司应该把压测作为提高系统性能和可用性的一个关键措施,毕竟没有压测数据,性能优化和可用性改进也不知道怎么下手。所以我还是比较建议尽可能把压测搞起来,反正压测这个东西是迟早要有的。 借鉴 """" * 顺着面试官的话往下说,讨论真的做不了压测的时候怎么确定阈值。关键词就是借鉴。 * 不过如果真的做不了,或者来不及,或者没资源,那么还可以考虑参考类似服务的阈值。比如说如果 A、B 服务是紧密相关的,也就是通常调用了 A 服务就会调用 B 服务,那么可以用 A 已经确定的阈值作为 B 的阈值。又或者 A 服务到 B 服务之间有一个转化关系。比如说创建订单到支付,会有一个转化率,假如说是 90%,如果创建订单的接口阈值是 100,那么支付的接口就可以设置为 90。 手动计算 """""""" * 全新的业务呢?也就是说,你都没得借鉴。这个时候就只剩下最后一招了——手动计算。 * 实在没办法了,就只能手动计算了。也就是沿着整条调用链路统计出现了多少次数据库查询、多少次微服务调用、多少次第三方中间件访问,如 Redis,Kafka 等。举一个最简单的例子,假如说一个非常简单的服务,整个链路只有一次数据库查询,这是一个会回表的数据库查询,根据公司的平均数据这一次查询会耗时 10ms,那么再增加 10 ms 作为 CPU 计算耗时。也就是说这一个接口预期的响应时间是 20ms。如果一个实例是 4 核,那么就可以简单用 1000ms÷20ms×4=200 得到阈值。 * 手动计算准确度是很差的。比如说垃圾回收类型语言,还要刨除垃圾回收的开销,相当于 200 打个折扣。折扣多大又取决于你的垃圾回收频率和消耗。 * 最好还是把阈值做成可以动态调整的。那么在最开始上线的时候就可以把阈值设置得比较小。后面通过观测发现系统还很健康,就可以继续上调阈值。 面试思路总结 ^^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/XWEsz7.png 评论 ^^^^ * 令牌桶可以用来保护自己,主要用来对调用者频率进行限流;漏桶算法则通常用于保护他人,也就是保护他所调用的系统。 06|隔离:怎么保证尊贵的VIP用户体验不受损 --------------------------------------- * 隔离在实际中的应用要比限流这种措施少很多。尤其是在中小型公司,很多时候是用不到隔离的。 * 但隔离依旧是构建高可用和高性能的微服务架构中的一环,因为在出现故障的时候,隔离可以把影响限制在一个可以忍受的范围内。 前置知识 ^^^^^^^^ * 隔离是通过资源划分,在不同服务之间建立边界,防止相互影响的一种治理措施。 目的 """" * 使用隔离策略主要是为了达到 3 个目的。 * 提升可用性,也就是说防止被影响或防止影响别人。这部分也叫做故障隔离。 * 提升性能,这是隔离和熔断、降级、限流不同的地方,一些隔离方案能够提高系统性能,而且有时候甚至能做到数量级提升。 * 提升安全性,也就是为安全性比较高的系统提供单独的集群、使用更加严苛的权限控制、迎合当地的数据合规要求等。 措施 """" * 一般的原则是核心与核心隔离,核心与非核心隔离。 * 机房隔离:一些公司的金融支付业务,个人隐私类的往往会有独立的机房。机房隔离和多活看起来有点儿像,但是从概念上来说差异还是挺大的。这里的隔离指的是不同服务分散在不同的机房,而多活强调的是同一个服务在不同的城市、不同的机房里面有副本。 * 实例隔离:某个服务独享某个实例的全部资源。 * 连接池隔离和线程池隔离:给核心服务单独的连接池和线程池。 * 第三方依赖隔离 面试准备 ^^^^^^^^ 可能遇到的问题 """""""""""""" * 数据库方面:你们公司有几个物理上的数据库(包括主从集群),有没有业务是独享某一个物理数据库的。 * 你们公司有没有准备多个 Redis 实例或者多个集群。另外理论上来说开启了持久化功能或者被用作消息队列的 Redis 最好是一个独立的集群,防止影响正常将 Redis 用作缓存的业务。 * 其他类似的中间件,包括消息队列、Elasticsearch 等,是否针对不同业务启用了不同的集群。 * 对核心业务、热点业务在资源配置上有没有什么特别之处。 * 在业务上,有没有针对高价值用户做什么资源倾斜。 * 在具体的系统上,有没有使用连接池隔离、线程池隔离等机制。 * 因为缺乏隔离机制引起的事故报告。 技巧 """" * 隔离最佳的面试策略是把隔离作为你构建高可用和高性能微服务的手段之一,和熔断、降级、限流合并在一起作为一个方案。 * 连接池和线程池相关的问题,你可以把隔离作为例子,证明你在连接池和线程池的使用上是很有心得体会的。 * 如何处理热点?你可以回答隔离,一方面可以提升性能,另一方面可以防止热点被别的业务影响,同时也可以防止别的业务影响到热点。 * 某个第三方中间件,比如 Redis 崩溃之后怎么办?那这个时候你可以强调给核心业务不同的 Redis 集群,能够一定程度上缓解这个问题,毕竟只要核心业务的 Redis 没有崩溃,不重要的业务的 Redis 崩溃也不是那么难以接受。 基本思路 ^^^^^^^^ * 关键词是 BC 端隔离。 * 之前为了保障我们 C 端用户的服务体验,我在我们的服务上利用微服务框架的分组功能做了一个简单的隔离。我们的服务本身部署了八个实例,我将其中三台实例分组为 B 端。于是商家过来的请求就只会落在这三台机器上,而 C 端用户的请求就可以落到八台中的任意一台。我这么做的核心目的是限制住 B 端使用的资源,但是 C 端就没有做任何限制。 * 关键词是大对象 * 之前我在公司的时候就遇到过一个事故。当时我们的服务原本运行得很好,结果突然之间 Redis 就卡住了,导致我们的 Redis 请求大部分超时,请求都落到了数据库上,数据库负载猛增,导致数据库查询也超时。后来运维排查,确认了 Redis 在那段时间因为别的业务上线了一个新功能,这个功能会批量计算数据,产生的结果会存储在 Redis。但是这个结果非常庞大,所以在这个功能运行的时候,Redis 就相当于在频繁操作大对象。 * 也不仅仅是我们,所有使用那个 Redis 的业务都受到了影响。后来我们再使用 Redis 的时候,就分成了核心与非核心。核心 Redis 有更加严格的接入机制和代码 review 机制,而非核心的就比较随意。不仅如此,我们还为高并发的服务设计了数据库限流,防止再来一次 Redis 失效导致 MySQL 被打崩的事故。 * 关键词就是贵且浪费。 * 隔离本身并不是没有代价的。一方面,隔离往往会带来资源浪费。例如为核心业务准备一个独立的 Redis 集群,它的效果确实很好,性能很好,可用性也很好。但是代价就是需要更多钱, Redis 本身需要钱,维护它也需要钱。另外一方面,隔离还容易引起资源不均衡的问题。比如说在连接池隔离里面,可能两个连接池其中一个已经满负荷了,另外一个还是非常轻松。当然,公司有钱的话就没有什么缺点了。 亮点方案 ^^^^^^^^ * 两个亮点方案,一是慢任务隔离,二是制作库与线上库分离。 慢任务隔离 """""""""" * 本质上就是线程池隔离。 * 之前我们遇到过一个 Bug,就是我们的定时任务总不能及时得到调度。后来我们加上监控之后,发现是因为存在少数执行很慢的任务,将线程池中的线程都占满了。所以我后来引入了线程池隔离机制,核心就是让慢任务在一个专门的线程池里面执行。 * 我准备了两个线程池,一个线程池专门执行慢任务,一个是执行快任务。而当任务开始执行的时候,先在快任务线程池里执行一些简单的逻辑,确定任务规模,这一步也就是为了识别慢任务。比如说根据要处理的数据量的大小,分出慢任务。如果是快任务,就继续执行。否则,转交给慢任务线程池。 * 如何识别慢任务,关键词是时长数据量。 * 这种方案的关键是如何识别慢任务。最简单的做法就是如果运行时间超过了一个阈值,那么就转交给慢任务线程池。这在识别循环处理数据里面比较好用。只需要在每次进入循环之前检测一下执行时长就可以了。而其他情况比较难,因为你没办法无侵入式地中断当前执行的代码,然后查看执行时长。 * 另外一种方案是根据要处理的数据量来判断。比如说任务是找到数据库里面符合条件的数据,然后逐条处理。那么可以先统计一下数据库有多少行是符合条件的。如果数据量很多,就转交给慢任务处理。 制作库与线上库分离 """""""""""""""""" * 在我们的业务里面,采用了制作库和线上库分离的方案来保证业务的可用性和性能。大体来说,作者在 B 端写作,操作的都是制作库,这个过程 C 端读者是没有任何感知的。当作者点击发布之后,就会开始同步给审核,审核通过之后就会同步给线上库。在同步给线上库的时候,我们还会直接同步到缓存,这样作者的关注者阅读文章的时候就会直接命中缓存。 * 后面如果作者要修改文章,修改的也是 B 端制作库,等他修改完毕,就会再次提交审核。审核完成之前,C 端用户看到的都是历史版本,这样 B 端和 C 端隔离保证了两边的用户体验。同时拆成两个数据库之后,C 端线上库几乎都是读流量,性能很好。 面试思路总结 ^^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/ySjzq2.png 面试技巧:最佳的策略是讲完优点讲缺点,讲完缺点讲改进 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/iTHYyd.png 07|超时控制:怎么保证用户一定能在1s内拿到响应 -------------------------------------------- 前置知识 ^^^^^^^^ * 超时控制的目标或者说好处。 * 超时控制的形态。 * 如何确定超时时间?这会是一个面试热点。 * 超时之后能不能中断业务? * 谁来监听超时时间? 超时控制目标 """""""""""" * 超时控制有两个目标 * 一是确保客户端能在预期的时间内拿到响应。这其实是用户体验一个重要理念“坏响应也比没响应好”的体现。 * 二是及时释放资源。这其中影响最大的是线程和连接两种资源。及时释放资源是提高系统可用性的有效做法,现实中经常遇到的一类事故就是因为缺乏超时控制引起了连接泄露、线程泄露。 * 释放线程:在超时的情况下,客户端收到了超时响应之后就可以继续往后执行,等执行完毕,这个线程就可以被用于执行别的业务。而如果没有超时控制,那么这个线程就会被一直占有。而像 Go 这种语言,协程会被一直占有。 * 释放连接:连接可以是 RPC 连接,也可以是数据库连接。类似的道理,如果没有拿到响应,客户端会一直占据这个连接。 超时控制形态 """""""""""" * 调用超时控制,比如说你在调用下游接口的时候,为这一次调用设置一个超时时间。 * 链路超时控制,是指整条调用链路被一个超时时间控制。比如说你的业务有一条链路是 A 调用 B,B 调用 C。如果链路超时时间是 1s,首先 A 调用 B 的超时时间是 1s,如果 B 收到请求的时候已经过去了 200ms,那么 B 调用 C 的超时时间就不能超过 800ms。 确定超时时间 """""""""""" * 常见的 4 种确定超时时间的方式是根据用户体验来确定、根据被调用接口的响应时间来确定、根据压测结果来确定、根据代码来确定。 * 根据用户体验:一般的做法就是根据用户体验来决定超时时间。比如说产品经理认为这个用户最多只能在这里等待 300ms,那么你的超时时间就最多设置为 300ms。 * 根据响应时间:一般情况下,你可以选择使用 99 线或者 999 线来作为超时时间。 超时中断业务 """""""""""" * 所谓的中断业务是指,当调用一个服务超时之后,这个服务还会继续执行吗? * 正常在实践中,我们是不会写这种手动检测的繁琐代码的。所以经常出现一个问题,就是客户端虽然超时了,但是实际上服务端已经执行成功了。 * 实例:用户第一次提交注册的时候拿到了超时响应,但是实际上他注册成功了,数据库写入了注册信息。所以当他第二次尝试重试的时候,立刻遇到了重复手机号码的错误。 面试准备 ^^^^^^^^ * 你所在公司的核心业务,尤其是 App 首页之类的,公司层面上的性能要求是什么?也就是说响应时间必须控制在多少以内,然后进一步了解有没有采用链路超时控制。 * 你自己维护的服务调用下游的时候有没有设置超时时间,超时时间都是多长? * 数据库查询有没有设置超时时间? * 跟任何第三方中间件打交道的代码有没有设置超时时间?例如查询 Redis,发送消息到 Kafka 等。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/sYiUk8.png 基本思路 ^^^^^^^^ 超时控制目标 """""""""""" * 关键词是超时控制目标。 * 我会设置超时时间,一般来说设置超时时间是为了用户体验和及时释放资源。比如说我有一个接口是提供给首页使用的,整个接口要求的超时时间是不超过 100ms。这个 100ms 就是公司规定的,是从用户体验出发确定的超时时间。 * 如果公司没有规定超时时间的话,最好的办法就是从用户体验的角度出发确定超时时间,这个可以考虑咨询一下产品经理。如果这个方式不行的话,就可以考虑根据被调用接口的响应时间,来确定调用者的超时时间。比如说我要调用 A 接口,如果 A 接口的 999 线是 200ms,那么我就可以把我这一次调用的超时时间设置成 200ms。除了 999 线,99 线也可以作为超时时间。如果我要调用的是一个新接口,没有性能数据,那么就可以考虑执行压测,然后根据结果选用 99 线或者 999 线。压测的结果也不仅仅可以用在这里,也可以用在限流那里。实在没办法,我们还可以根据代码里面的复杂操作来计算一个时间。 * 99 线和 999 线究竟选哪个比较好 * 原则上是看公司的可用性要求,要求几个 9 就要几个 9。如果没有硬性规定,那么看 99 线和 999 线相差多不多。不多的话就用 999 线,多的话就用 99 线。 数据库连接 """""""""" * 关键词是数据库连接。 * 正常来说,对任何第三方的调用我都会设置超时时间。如果没有设置超时时间或者超时时间过长,都可能引起资源泄露。比如说早期我们公司就出现过一个事故,某个同事的数据库查询超时时间设置得过长,在数据库性能出现抖动的时候,客户端的所有查询都被长时间阻塞,导致连接池中的连接耗尽。 亮点方案: 链路超时控制 ^^^^^^^^^^^^^^^^^^^^^^ * 链路超时控制和普通超时控制最大的区别是链路超时控制会作用于整条链路上的任何一环。例如在 A 调用 B,B 调用 C 的链路中,如果 A 设置了超时时间 1s,那么 A 调用 B 不能超过 1s。然后当 B 收到请求之后,如果已经过去了 200ms,那么 B 调用 C 的超时时间就不能超过 800ms。因此链路超时的关键是在链路中传递超时时间。 * 怎么传递超时时间:协议头 * 大部分情况下,链路超时时间在网络中传递是放在协议头的。如果是 RPC 协议,那么就放在 RPC 协议头,比如说 Dubbo 的头部;如果是 HTTP 那么就是放在 HTTP 头部。比较特殊的是 gRPC 这种基于 HTTP 的 RPC 协议,它是利用 HTTP 头部作为 RPC 的头部,所以也是放在 HTTP 头部的。至于放的是什么东西,就取决于不同的协议是如何设计的了。 * 超时时间传递的值究竟是什么 * 一般超时时间传递的就两种:剩余超时时间或者超时时间戳。比如说剩余 1s,那么就用毫秒作为单位,数值是 1000。这种做法的缺陷就是服务端收到请求之后,要减去请求在网络中传输的时间。比如说 C 收到请求,剩余超时时间是 500ms,如果它知道 B 到 C 之间请求传输要花去 10ms,那么 C 应该用 500ms 减去 10 ms 作为真实的剩余超时时间。不过现实中比较难知道网络传输花了 10ms 这件事。 * 而传递超时时间戳,那么就会受到时钟同步影响。假如说此时此刻,A 的时钟是 00:00:00,而 B 的时钟是 00:00:01,也就是 A 的时钟比 B 的时钟慢了一秒。那么如果 A 传递的超时时间戳是 00:00:01,那么 B 一收到请求,就会认为这个请求已经超时了。 * 当然,正常来说时钟同步不至于出现那么大的偏差,大多数时钟偏差几乎可以忽略不计。不过在时钟回拨的场景下,还是会有问题。我之前听说不同云服务商之间的时钟同步问题比较严重,可能也需要注意。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/FP6J7A.png 08|调用第三方:下游的接口不稳定性能又差怎么办 -------------------------------------------- .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/MDiumd.png 基本思路 ^^^^^^^^ * 我的系统对可用性要求非常高,为此我综合使用了熔断、限流、降级、超时控制等措施。并且,我这个系统还有一个特别之处,就是它需要和很多第三方平台打交道。所以要想保证系统的可用性,我就需要保证和第三方打交道是高可用的。 * 我在刚接手这个项目的时候,这一块的设计和实现不太行。总体来说可扩展性、可用性、可观测性和可测试性都非常差。为了解决这个问题,全方位提高系统的可扩展性、可用性、可观测性和可测试性,我做了比较大的重构。 * 我重新设计了接口,提供了一个一致性抽象。(这里你可以补充你设计了哪些接口,然后强调一下效果)重构之后,研发效率提高了 30%,并且接入一个全新的第三方,也能对业务方做到完全没感知。 * 我引入客户端治理措施,主要是限流和重试,并且针对一些特殊的第三方接口,我还设计了一些特殊的容错方案。 * 我全方面接入了可观测性平台,包括 Prometheus 和 Skywalking,并且配置了告警。和原来比起来,现在能够做到快速响应故障了。 * 我还进一步提供了测试工具,可以按照业务方的预期返回响应,比如说成功响应、失败响应以及模拟接口超时。针对压测,我也做了一些改进。 亮点方案 ^^^^^^^^ 异步解耦 """""""" * 正常来说我们推送数据都是尽可能实时推过去,但是有些时候业务方推过来的数据太多,又或者第三方崩溃,那么我就会临时将数据存起来。后面第三方恢复过来了,再逐步将数据同步过去。这算是比较典型的同步转异步用法。 * 我们这种容错机制其实完全可以做成利用消息队列来彻底解耦的形式。在这种解耦的架构下,业务方不再是同步调用一个接口,而是把消息丢到消息队列里面。然后我们的服务不断消费消息,调用第三方接口处理业务。等处理完毕再将响应通过消息队列通知业务方。 mock测试 """""""" * 早期为了弄清楚服务的吞吐量和响应时间瓶颈,我搞过一些压测。但这些流量不能真的调用第三方,所以我为了解决压测这个问题,设计了两个东西。 * 一个是模拟第三方的响应时间。不过这种模拟是比较简单的,就是在代码里面睡眠一段时间,这段时间是第三方接口的平均响应时间加上一个随机偏移量计算得出的。另一个是在并发非常高的情况下,会触发我的容错机制。 * 而且我这里留好了接口,万一我们公司要做全链路压测了,我这边也可以根据链路元数据将压测流量转发到 mock 逻辑,而真实业务请求则会发起真实调用。 面试思路总结 ^^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/NXbJhT.png 09|综合服务治理方案:怎么保证微服务应用的高可用 ---------------------------------------------- 准备 ^^^^ * 所有面向前端用户的接口有没有限流之类的措施,防止攻击者伪造大量请求把你的系统搞崩。 * 你所依赖的第三方组件,包括缓存(如 Redis)、数据库(如 MySQL)、消息队列(如 Kafka)是否启用了高可用方案。 * 如果你依赖的某个第三方组件崩溃了,你维护的服务会发生什么事情,整个系统是否还能正常提供服务。 * 你的所有服务是否选择了合适的负载均衡算法,是否有熔断、降级、限流和超时控制等治理措施。 * 你所在公司的上线流程、配置变更流程等和研发息息相关的流程,或者说你认为会对系统可用性产生影响的各种流程。 基本思路 ^^^^^^^^ * 整个思路可以拆解成几个部分,分别是发现问题、计划方案、落地实施、取得效果、后续改进。 发现问题 """""""" * 某某业务是我们公司的核心业务,它的核心困难是需要保证高可用。在我刚入职的时候,这个系统的可用性还是比较低的。比如说我刚入职的第一个月就出了一个比较严重的线上故障,别的业务组突然上线了一个功能,带来了非常多的 Redis 大对象操作,以至于 Redis 响应非常慢,把我们的核心服务搞超时了。 * 后面经过调研,我总结下来,系统可用性不高主要是这三个原因导致的:: 1. 缺乏监控和告警,导致我们难以发现问题,难以定位问题,难以解决问题。 2. 缺乏服务治理,导致某一个服务出现故障的时候,整个系统都不可用了。 3. 缺乏合理的变更流程。我们每次复盘 Bug 时候,都觉得如果有更加合理的变更流程的话,那么大部分事故都是可以避免的。 计划方案 """""""" 针对这些具体的点,我的可用性改进计划分成了几个步骤:: 1. 引入全方位的监控与告警,这一步是为了快速发现问题和定位问题。 2. 引入各种服务治理措施,这一步是为了提高服务本身的可用性,并且降低不同服务相互之间的影响。 3. 为所有第三方依赖引入高可用方案,这一步是为了提高第三方依赖的可用性。 4. 拆分核心业务与非核心业务的共同依赖。这一步是为了进一步提高核心业务的可用性。 5. 规范变更流程,降低因为变更而引入 Bug 的可能性。 落地实施 """""""" * 在第一个步骤里面,就监控来说,既要为业务服务添加监控和告警,又要为第三方依赖增加监控,比如说监控数据库、Redis 和消息队列。而告警则要综合考虑告警频率、告警方式以及告警信息的内容是否足够充足,减少误报和谎报。本身这个东西并不是很难,就是非常琐碎,要一个个链路捋过去,一个个业务查漏补缺。 * 第二个步骤,服务治理包括的范围比较广,我使用过的方案也比较多,比如说限流熔断等等。 * 第三个步骤遇到了比较大的阻力,主要是大部分第三方依赖的高可用方案都需要资金投入。比如说最开始我们使用的 Redis 就是一个单机 Redis,那么后面我尝试引入 Redis Cluster 的时候,就需要部署更多的实例。 * 第四个步骤也是执行得不彻底。现在的策略就是新的核心业务会启用新的第三方依赖集群,比如说 Redis 集群,但是老的核心业务就保持不动。 * 第五个步骤是我在公司站稳脚跟之后跟领导建议过几次,后来领导就制定了新的规范,主要是上线规范,包括上线流程、回滚计划等内容。 取得效果 """""""" * 不再有我的服务崩滑造成服务不可用了。 * 可以强调一下,系统中超出你影响力范围的部分,可用性还是比较差。 * 不过我的服务还依赖于一些同事提供的服务,而他们的服务可用性就还是比较差。我这边只能是说尽量做到容错,比如说提供有损服务。后面要想进一步提高可用性,还是得推动同事去提高可用性。 * 关键词是影响力有限。 * 我也一直在想办法进一步提高可用性,但是整个系统要做到四个九还是非常难的,需要整个公司技术人员一起努力才能达到。我在公司的影响力还局限在我们部门,困难比较多,暂时做不到那么高的可用性。 后续改进 """""""" * 目前我的服务,尤其是一些老服务,相互之间还是在共享一些基础设施。一个出问题就很容易牵连其他服务,所以我还需要进一步将这些老服务解耦。 * 比如说,我一定要让我的全部服务都使用我自己所在组的数据库实例,省得因为别组的同事搞崩了数据库,牵连到我的业务。大家一起用一个东西,出了事别人死不认账,甩锅都甩不出去。 * 讲改进方案有一个好处,就是它还没实施,你就可以随便讲,什么高大上你就讲什么。 亮点方案 ^^^^^^^^ 异步 / 解耦 """"""""""" * 我还全面推行了异步 / 解耦。我将核心业务的逻辑一个个捋过去,再找产品经理确认,最终将所有的核心业务中能够异步执行的都异步执行,能够解耦的都解耦。这样在我的业务里面,需要同步执行的步骤就大大减少了。而后续异步执行的动作,即便失败了也可以引入重试机制,所以整个可用性都大幅度提升了。 * 比如说在某个场景下,整个逻辑可以分成很明显的两部分,必须要同步执行的 A 步骤和可以异步执行的 B 步骤。那么在 A 步骤成功之后,再发一条消息到消息队列。另外一边消费消息,执行 B 步骤。 自动故障处理 """""""""""" * 想进一步提升可用性,那就要么降低出事故的概率,要么提高反应速度。 * 关键词是自动扩容。 * 为了进一步提高整个集群服务的可用性,我跟运维团队进行密切合作,让他们支持了自动扩容。整个设计方案是允许不同的业务方设置不同的扩容条件,满足条件之后运维就会自动扩容。比如说我为我的服务设置了 CPU 90% 的指标。如果我这个服务所有节点的 CPU 使用率都已经超过了 90%,并且持续了一段时间,那么就会触发自动扩容,每次扩容会新增一个节点。 * 其他常见的方案 * 自动修复数据,最常见的就是有一个定时任务比对不同的业务数据,如果数据不一致,就会发出告警,同时触发自动修复动作。 * 自动补发消息,也是通过定时任务等机制来比对业务数据,如果发现某条消息还没发,就会触发告警,同时触发补发消息动作。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/ZXW7Yu.png 评论 ^^^^ * 追求高可用的时候,管理难度大于技术难度;与其说是技术问题,不如说是组织问题。 模拟: 微服务架构思路一图懂 -------------------------- * 在面试前有没有想好自己要打造什么人设?你的回答有没有围绕这个人设进行? * 当你看到一个问题的时候,你能不能瞬间想到可以怎么刷亮点?怎么引导面试官? * 有没有在回答中留下足够的引导话术? * 有没有在我准备的回答基础上,糅合自己的项目经历,打造独一无二的回答? 整体性问题 ^^^^^^^^^^ * ✨ 什么是微服务架构? * ✨ 怎么保证微服务架构的高可用? * ✨ 怎么判定服务是否已经健康? * ✨ 如果服务不健康该怎么办? * ✨ 怎么判定服务已经从不健康状态恢复过来了? * ✨ 听你说你用到了 Redis 作为缓存,如果你的 Redis 崩溃了会怎么样? * ✨ 听你说你用到了 Kafka 作为消息队列,如果你的 Kafka 崩溃了怎么办? * ✨ 现在需要设计一个开放平台,即提供接口给合作伙伴用,你觉得需要考虑一些什么问题? 01|服务注册与发现 ^^^^^^^^^^^^^^^^^ * 🔍 什么是注册中心? * 🔍 服务注册与发现机制的基本模型是怎样的? * 🔍 服务上线与服务下线的步骤是什么? * 🔍 注册中心选型需要考虑哪些因素? * 🔍 你为什么使用 Zookeeper/Nacos/etcd 作为你的注册中心? * 🔍 什么是 CAP? * 🔍 在服务注册与发现里面你觉得应该用 AP 还是 CP? * 🔍 如何保证服务注册与发现的高可用? * 🔍 服务器崩溃,如何检测? * 🔍 客户端容错的措施有哪些? * 🔍 注册中心崩溃了怎么办? * 🔍 注册中心怎么判断服务端已经崩溃了? 02|负载均衡 ^^^^^^^^^^^ * ⚖️ 你了解负载均衡算法吗? * ⚖️ 静态负载均衡算法和动态负载均衡算法的核心区别是什么? * ⚖️ 轮询与随机负载均衡算法有什么区别? * ⚖️ 你了解平滑的加权轮询算法吗? * ⚖️ 如何根据调用结果来调整负载均衡效果? * ⚖️ 为什么有些算法要动态调整节点的权重?权重究竟代表了什么? * ⚖️ 你们公司的算法有没有调整过权重?为什么? * ⚖️ 最快响应时间负载均衡算法有什么缺点? * ⚖️ 如果我现在有一个应用,对内存和 CPU 都非常敏感,你可以针对这个特性设计一个负载均衡算法吗? * ⚖️ 为什么使用轮询、加权轮询、随机之类的负载均衡算法,系统始终会出现偶发性的流量不均衡,以至于某台服务器出故障的情况?怎么解决这一类问题? 03|熔断 ^^^^^^^ * 🔥 为什么说熔断可以提高系统的可用性? * 🔥 如何判断节点的健康状态,需要看哪些指标? * 🔥 触发熔断之后,该熔断多久? * 🔥 响应时间超过多少应该触发熔断? * 🔥 响应时间超过阈值就一定要触发熔断吗? * 🔥 怎么避免偶发性超过阈值的情况? * 🔥 服务熔断后如何恢复? * 🔥 产生抖动的原因,以及如何解决抖动问题? 04|降级 ^^^^^^^ * 🤚 什么时候会用到降级,请举例说明? * 🤚 降级有什么好处? * 🤚 跨服务降级常见的做法是什么? * 🤚 你怎么评估业务服务的重要性?或者说,你怎么知道 A 服务比 B 服务更加重要? * 🤚 请说一说服务内部常见的降级思路。 * 🤚 怎么判断哪些服务需要降级? * 🤚 触发降级之后,应该保持在降级状态多久? * 🤚 服务降级之后如何恢复,如何保证恢复过程中不发生抖动? * 🤚 你们公司的产品首页是如何保证高可用的? 05|限流 ^^^^^^^ * 🏁 限流算法都包括哪些? * 🏁 不同的限流算法怎么选? * 🏁 限流的对象应该如何选择? * 🏁 怎么确定流量的阈值? * 🏁 如何应对突发流量? * 🏁 被限流的请求会被怎么处理? * 🏁 为什么使用了限流,系统还是有可能崩溃? * 🏁 我们有一个功能,对于普通用户来说,一些接口需要限制在每分钟不超过 10 次,整天不能超过 1000 次;VIP 用户不限制。你怎么解决这个问题? 06|隔离 ^^^^^^^ * 🤝 什么是隔离,你用来解决什么问题? * 🤝 你了解哪些隔离策略?你用过哪些? * 🤝 当某个服务崩溃的时候,你有什么办法保证其它服务不受影响? * 🤝 在使用线程池、连接池和协程池的时候,怎么避免业务之间相互影响? 07|超时控制 ^^^^^^^^^^^ * 🕙 为什么要做超时控制? * 🕙 为什么缺乏超时控制有可能引起连接泄露、线程泄露? * 🕙 什么是链路超时控制? * 🕙 如何确定超时时间? * 🕙 怎么在链路中传递超时时间? * 🕙 超时时间传递的是什么? * 🕙 如何计算网络传输时间? * 🕙 什么是时钟同步问题? * 🕙 客户端和服务端谁来监听超时? * 🕙 超时之后能不能中断业务?怎么中断? 08|调用第三方 ^^^^^^^^^^^^^ * 🌊 如何保证调用第三方接口的可用性? * 🌊 如果在出错的时候你会切换不同的第三方,但是如果全部第三方换一遍之后都崩溃了,怎么办? * 🌊 调用第三方接口出错的时候,你是怎么重试的?重试次数和重试间隔你是怎么确定的? * 🌊 你怎么判定第三方服务已经非常不可用,以至于要切换一个新的第三方服务了? * 🌊 对时效性要求不高的接口,你可以怎么优化架构? * 🌊 在压力测试一个接口的时候,如果这个接口依赖了一个第三方接口,你怎么解决? * 🌊 公司业务依赖一个非常关键的第三方依赖,我要怎么保证我在调用第三方的时候不出错? 一图懂 ^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/QjL4xk.png 数据库与MySQL (13讲) ==================== 10| 数据库索引:为什么MySQL用B+树而不用B树 ----------------------------------------- 前置知识 ^^^^^^^^ .. note:: MongoDB 使用的是 B+ 树,不是你们以为的 B 树,参考:https://zhuanlan.zhihu.com/p/519658576 B+ 树 """"" B+ 树是一种多叉树,一棵 m 阶的 B+ 树定义如下:: 1. 每个节点最多有 m 个子女。 2. 除根节点外,每个节点至少有 [m/2] 个子女,根节点至少有两个子女。 3. 有 k 个子女的节点必有 k 个关键字。 这里的关键字你可以直观地理解为就是索引全部列的值。 .. note:: B+树的高度确实对查询性能有一定的影响。然而,给出一个具体的百分比是非常困难的,因为这涉及到多个因素的综合影响。 B+ 树还有两个特征:: 1. 叶子存放了数据,而非叶子节点只是存放了关键字。 2. 叶子节点被链表串联起来了。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/mvBl4b.png B+ 树用于数据库索引有 3 大优势:: 1. B+ 树的高度和二叉树之类的比起来更低,树的高度代表了查询的耗时,所以查询性能更好。 2. B+ 树的叶子节点都被串联起来了,适合范围查询。 3. B+ 树的非叶子节点没有存放数据,所以适合放入内存中。 索引分类 """""""" * 根据叶子节点是否存储数据来划分,可以分成聚簇索引和非聚簇索引。 * 如果某个索引包含某个查询的所有列,那么这个索引就是覆盖索引。 * 如果索引的值必须是唯一的,不能重复,那么这个索引就是唯一索引。 * 如果索引的某个列,只包含该列值的前一部分,那么这个索引就是前缀索引。比如说在一个类型是 varchar(128) 的列上,选择前 64 个字符作为索引。 * 如果某个索引由多个列组成,那么这个索引就是组合索引,也叫做联合索引。 * 全文索引是指用于支持文本模糊查询的索引。 * 哈希索引是指使用哈希算法的索引,但是 MySQL 的 InnoDB 引擎并不支持这种索引。 聚簇索引和非聚簇索引 ++++++++++++++++++++ * 如果索引叶子节点存储的是数据行,那么它就是聚簇索引,否则就是非聚簇索引。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/VajDu0.png 简单来说,某个数据表本身你就可以看作是一棵使用主键搭建起来 B+ 树,这棵树的叶子节点放着表的所有行。而其他索引也是 B+ 树,不同的是它们的叶子节点存放的是主键。 * 【定义】回表(return to table):如果你查询一张表,用到了索引,那么数据库就会先在索引里面找到主键,然后再根据主键去聚簇索引中查找,最终找出数据。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/By3MYq.png 查询的时候先沿着绿色的线条在非聚簇索引中找到主键。然后拿着主键再去下面沿着黄色的线条找到数据行。这个数据行存放在磁盘里,所以触发磁盘 IO 之后能够读取出来。磁盘 IO 是非常慢的,因此回表性能极差,你在实践中要尽可能避免回表。 为了避免回表,可以采取以下几种策略和技术:: 1. 覆盖索引(Covering Index) 创建一个包含查询所需列的索引,这样查询就可以直接从索引中获取所需的数据,而无需回表到磁盘读取数据行。 通过减少磁盘IO的次数,可以显著提高查询性能。 2. 聚簇索引(Clustered Index) 将表按照某个列进行物理排序,使得相邻的行在磁盘上也是相邻存储的。 这样,当根据聚簇索引进行查询时,可以连续读取磁盘上的数据块,减少随机IO的次数,从而提高查询性能。 3. 联合索引(Composite Index) 如果查询中涉及多个列,可以创建一个包含这些列的联合索引。这样,在满足索引的最左前缀原则的情况下,可以直接从索引中获取所需的数据,避免回表操作。 4. 冗余数据(Denormalization) 在某些情况下,为了避免回表,可以将一些关联的数据冗余存储到一起。这样,在查询时就无需回表获取关联数据,而是直接从同一行中获取所有需要的数据。 5. 使用覆盖索引的查询优化器提示(Query Optimizer Hints) 对于特定的查询,可以使用数据库查询优化器提供的提示,明确指示使用覆盖索引或避免回表操作。 覆盖索引 ++++++++ * 如果你查询的列和查询条件字段全部都在某个索引里面,那么数据库可以直接把索引存储的这些列的值给你,而不必回表。那么,这个索引在这个查询下就是一个覆盖索引。所以覆盖索引并不是一个独立的索引,而是某个索引相对于某个查询而言的。 针对这个特性,优化 SQL 性能里面有两种常见的说法:: 1. 只查询需要的列。 2. 针对最频繁的查询来设计覆盖索引。 这两种说法本质上都是为了避免回表。 索引的最左匹配原则 ++++++++++++++++++ * 创建了一个在 A、B、C 三个列上的组合索引 。 * A 是绝对有序的;在 A 确定的情况下,B 是有序的;在 A 和 B 都确定的情况下,C 是有序的。 索引的代价 """""""""" 消耗很多的系统资源:: 1. 索引本身需要存储起来,消耗磁盘空间。 2. 在运行的时候,索引会被加载到内存里面,消耗内存空间。 3. 在增删改的时候,数据库还需要同步维护索引,引入额外的消耗。 准备 ^^^^ 基本思路 """""""" * 从数据结构上来说,在 MySQL 里面索引主要是 B+ 树索引。它的查询性能更好,适合范围查询,也适合放在内存里。 * MySQL 的索引又可以从不同的角度进一步划分。比如说根据叶子节点是否包含数据分成聚簇索引和非聚簇索引,还有包含某个查询的所有列的覆盖索引等等。数据库使用索引遵循最左匹配原则。但是最终数据库会不会用索引,也是一个比较难说的事情,跟查询有关,也跟数据量有关。在实践中,是否使用索引以及使用什么索引,都要以 EXPLAIN 为准。 亮点 ^^^^ 亮点 1:MySQL 为什么使用 B+ 树 """"""""""""""""""""""""""""" * 回答这个问题,你就不能仅仅局限在 B+ 树和 B 树上,你要连带着二叉树、红黑树、跳表一起讨论。总结起来,在用作索引的时候,其他数据结构都有一些难以容忍的缺陷。 * 与 B+ 树相比,平衡二叉树、红黑树在同等数据量下,高度更高,性能更差,而且它们会频繁执行再平衡过程,来保证树形结构平衡。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/VwEJrt.png 数据量与高度的关系 * 与 B+ 树相比,跳表在极端情况下会退化为链表,平衡性差,而数据库查询需要一个可预期的查询时间,并且跳表需要更多的内存。 * 与 B+ 树相比,B 树的数据存储在全部节点中,对范围查询不友好。非叶子节点存储了数据,导致内存中难以放下全部非叶子节点。如果内存放不下非叶子节点,那么就意味着查询非叶子节点的时候都需要磁盘 IO。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/wMMkI9.png 对范围查询友好 亮点 2:为什么数据库不使用索引 """"""""""""""""""""""""""""" 数据库**可能**不使用索引的原因有以下几种:: 1. 使用了 !=、LIKE 之类的查询 2. 字段区分度不大。比如说你的 status 列只有 0 和 1 两个值,那么数据库也有可能不用 3. 使用了特殊表达式,包括数学运算和函数调用 4. 数据量太小,或者 MySQL 觉得全表扫描反而更快的时候 * 虽然很多数据库都支持类似于 FORCE INDEX 、USE INDEX 和 IGNORE INDEX 之类的特性,但是使用这一类功能的时候,要千万注意数据库是怎么支持的。有些数据库是根本不管这些提示,有些则是特定情况下不管。当然最佳实践还是不要用这些东西,逼不得已的时候比如说要优化性能了再考虑使用。 亮点 3:索引与 NULL """""""""""""""""" * MySQL 的索引对 NULL 的支持稍微有点与众不同。首先 MySQL 本身会尽可能使用索引,即便索引的某个列里面有零值,并且 IS NULL 和 IS NOT NULL 都可以使用索引。 * 其次 MySQL 的唯一索引允许有多行的值都是 NULL。也就是说你可以有很多行唯一索引的列的值都是 NULL。但是不管怎么说,使用 NULL 都是一个比较差的实践。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/69udGx.png 11| SQL优化:如何发现SQL中的问题 ------------------------------- 前置知识 ^^^^^^^^ 数据库优化主要包含以下内容:: 1. 硬件资源优化:换更大更强的机器。 2. 操作系统优化:调整操作系统的某些设置。 3. 服务器 / 引擎优化:也就是针对数据库软件本体进行优化,比如说调整事务隔离级别。 在 MySQL 里面还可以针对不同的引擎做优化,比如说调整 InnoDB 引擎的日志刷盘时机。 4. SQL 优化:针对的就是 SQL 本身了。 如果站在数据库的角度,那么 SQL 优化就是为了达到两个目标:: 1. 减少磁盘 IO,这个又可以说是尽量避免全表扫描、尽量使用索引以及尽量使用覆盖索引。 2. 减少内存 CPU 消耗,这一部分主要是尽可能减少排序、分组、去重之类的操作。 EXPLAIN 命令 """""""""""" * EXPALIN 命令的大概用法是 EXPLAIN your_sql,然后数据库就会返回一个执行计划。 执行计划的关键字段:: 1. type:指的是查询到所需行的方式 从好到坏依次是 system > const > eq_ref > ref > range > index > ALL。 system 和 const 都可以理解为数据库只会返回一行数据,所以查询时间是固定的。 eq_ref 和 ref 字面意思是根据索引的值来查找。 range:索引范围扫描。 index:索引全表扫描,也就是扫描整棵索引。 ALL:全表扫描,发起磁盘 IO 的那种全表扫描。 2. possible_keys:候选的索引。 3. key:实际使用的索引。 4. rows:扫描的行数。数据库可能扫描了很多行之后才找到你需要的数据。 5. filtered:查找到你所需的数据占 rows 的比例。 .. note:: 一般优化 SQL 都是在 EXPLAIN 查看执行计划、尝试优化两个步骤之间循环往复,直到发现 SQL 性能达标。 选择索引列 """""""""" * 频繁出现在 WHERE 中的列,主要是为了避免全表扫描。 * 频繁出现在 ORDER BY 的列,这是为了避免数据库在查询出来结果之后再次排序。 * 区分度很高的列。比如每一行的数据都不同的列,并且在创建组合索引的时候,区分度很高的列应该尽可能放到左边。 大表表定义变更 """""""""""""" .. note:: 修改索引或者说表定义变更的核心问题是数据库会加表锁,直到修改完成。所以准备新加一个索引的时候,如果这个表的数据很多,那么在你执行加索引的命令的时候,整张表可能都会被锁住几分钟甚至几个小时。 大表表结构变更是一件很麻烦的事情,一般可以考虑的方案有 3 种:: 停机变更 业务低谷变更 创建新表 准备 ^^^^ * 你维护的业务的所有表结构定义(包含索引定义),每张表上执行最频繁的三个 SQL 是否用到了索引。 * 公司内部曾经或者已有的慢 SQL 是怎么发现、分析和优化的。然后要记住 SQL 优化前后的执行时间,以凸显优化的效果。 * 每一个 SQL 优化案例你都要考虑清楚面试官如果要深挖,那么会朝着什么方向深挖。 * 你是否做过性能优化? * 接口的响应时间是多少?有没有优化的空间? * 你是否了解索引?是否用过索引? 基本思路 """""""" * 我们公司有 SQL 的慢查询监控,当我们发现接口响应时间比较差的时候,就会去排查 SQL 的问题。我们主要是使用 EXPLAIN 命令来查看 SQL 的执行计划,看看它有没有走索引、走了什么索引、是否有内存排序、去重之类的操作。 * 初步判定了问题所在之后,我们尝试优化,包括改写 SQL 或者修改、创建索引。之后再次运行 SQL 看看效果。如果效果不好,就继续使用 EXPLAIN 命令,再尝试修改。如此循环往复,直到 SQL 性能达到预期。 优化案例 ^^^^^^^^ 覆盖索引 """""""" * 原来我们有一个执行非常频繁的 SQL。这个 SQL 查询全部的列,但是业务只会用到其中的三个列 A B C,而且 WHERE 条件里面主要的过滤条件也是这三个列组成的,所以我后面就在这三个列上创建了一个组合索引。 * 对于这个高频 SQL 来说,新的组合索引就是一个覆盖索引。所以我在创建了索引之后,将 SQL 由 SELECT * 改成了 SELECT A, B, C,完全避免了回表。这么一来,整个查询的查询时间就直接降到了 1ms 以内。 * 正常来说,对于非常高频的 SQL,都要考虑避免回表,那么设计一个合适的覆盖索引就非常重要了。 优化 ORDER BY """"""""""""" * 我在公司优化过一个 SQL,这个 SQL 非常简单,就是将某个人的数据搜索出来,然后按照数据的最后更新时间来排序。SQL 大概是 SELECT * FROM xxx WHERE uid = 123 ORDER BY update_time。 * 如果用户的数据比较多,那么这个语句执行的速度还是比较慢的。后来我们做了一个比较简单的优化,就是用 uid 和 update_time 创建一个新的索引。从数据库原理上说,在 uid 确认之后,索引内的 update_time 本身就是有序的,所以避免了数据库再次排序的消耗。这样一个优化之后,查询时间从秒级降到了数十毫秒。 * 在所有的排序场景中,都应该尽量利用索引来排序,这样能够有效减轻数据库的负担,加快响应速度。进一步来说,像 ORDER BY,DISTINCT 等这样的操作也可以用类似的思路。 优化 COUNT """""""""" * 第一种方法是用估计值取代精确值,关键词是预估 * 我的这个场景对数据的准确性要求不是很高,所以我用了一个奇诡的方法,即用 EXPLAIN your_sql,之后用 EXPLAIN 返回的预估行数。比如说 SELECT COUNT(*) FROM xxx WHERE uid= 123,就可以用 EXPLAIN SELECT * FROM xxx WHERE uid = 123 来拿到一个预估值。 * 不过如果需要精确值,那么就可以考虑使用 Redis 之类的 NoSQL 来直接记录总数。或者直接有一个额外的表来记录总数也可以。 * 如果你用了 Redis 来维持总数,那么就会涉及数据一致性的问题。这本质上是一个分布式事务的问题。主要思路有两个:: 1. 如果数据短时间不一致但是业务可以接受的话,那么就可以考虑异步刷新 Redis 上的总数。 2. 使用 Canal 之类的工具监听 MySQL binlog,然后刷新 Redis 上的总数。 用 WHERE 替换 HAVING """""""""""""""""""" * 早期我们有一个历史系统,里面有一个 SQL 是很早以前的员工写的,比较随意。他将原本可以用在 WHERE 里面的普通的相等判断写到了 HAVING 里面。后来我将这个条件挪到了 WHERE 之后查询时间降低了 40%。 * 如果不是使用聚合函数来作为过滤条件,最好还是将过滤条件优先写到 WHERE 里面。 优化分页中的偏移量 """""""""""""""""" * 说明:LIMIT 5000, 50。5000 就是偏移量。实际执行中数据库就需要读出 5050 条数据,然后将前面的 5000 都丢掉,只保留 50 条。 * 在我们的系统里面,最开始有一个分页查询,那时候数据量还不大,所以一直没出什么问题。后来数据量大了之后,我们发现如果往后翻页,页码越大查询越慢。问题关键就在于我们用的 LIMIT 偏移量太大了。 * 所以后来我就在原本的查询语句的 WHERE 里面加上了一个 WHERE id > max_id 的条件。这个 max_id 就是上一批的最大 ID。这样我就可以保证 LIMIT 的偏移量永远是 0。这样修改之后,查询的速度非常稳定,一直保持在毫秒级。 * 很多时候因为测试环境数据量太小,这种性能问题根本不会被发现。所以所有使用分页的查询都应该考虑引入类似的查询条件。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/hvkz5V.png 评论 ^^^^ * 在之前的团队中用过强制force指定索引,之所以这样做是为了在mysql表的数据量特别大的时候,mysql自己的内部优化器会给推荐别的索引,现实中发现强制有另一个索引,查询的性能会很大,也就是可以理解为mysql自己的查询优化器有时候会选择错误的索引,不过还是挺罕见的场景 12| 数据库锁:明明有行锁,怎么突然就加了表锁 ------------------------------------------ * 锁在整个数据库面试中都是属于偏难,而且偏琐碎的一类问题。但是偏偏锁又很重要,一句话总结就是锁既难又琐碎还热门。 前置知识 ^^^^^^^^ 锁与索引 """""""" * InnoDB的行级锁是通过多版本并发控制(MVCC)和锁算法来实现的 * 行级锁是基于索引来实现的,但实际上锁定的是索引记录(index record),而不是索引本身。在执行查询时,锁定的是满足查询条件的索引记录所对应的行,而不是最终使用的那个索引。最终使用的索引仅仅是用于定位行的工具,而实际的锁是针对行进行的。 * 如果查询没有使用任何索引呢?那么锁住的就是整个表,也就是此时退化为表锁。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/m58vwh.png 如果id = 15 的值根本不存在,那么怎么锁?InnoDB 引擎会利用最接近 15 的相邻的两个节点,构造一个临键锁。此时如果别的事务想要插入一个 id=15 的记录,就不会成功。范围查询也是锁住这个范围 释放锁时机 """""""""" * 当一个事务内部给数据加上锁之后,只有在执行 Rollback 或者 Commit 的时候,锁才会被释放掉。 乐观锁与悲观锁 """""""""""""" 并发控制中常用的两种锁机制:: 1. 乐观锁是直到要修改数据的时候,才检测数据是否已经被别人修改过。 2. 悲观锁是在初始时刻就直接加锁保护好临界资源。 行锁与表锁 """""""""" 那么在 MySQL 里面,InnoDB 引擎同时支持行锁和表锁。但是行锁是借助索引来实现的,也就是说,如果你的查询没有命中任何的索引,那么 InnoDB 引擎是用不了行锁的,只能使用表锁。当然,如果用的是 MySQL,类似于 MyISAM 引擎,那么只能使用表锁,因为这些引擎不支持行锁。 共享锁与排它锁 """""""""""""" * 共享锁是指一个线程加锁之后,其他线程还是可以继续加同类型的锁。 * 排它锁是指一个线程加锁之后,其他线程就不能再加锁了。 意向锁 """""" * 意向锁,Intention Lock * 意向共享锁(Intention Shared Lock,IS) * 意向共享锁(Intention Shared Lock,IS) .. note:: 【思考】可以这么理解:意向锁是为了防止在加锁时死锁等情况,在加锁的上层增加了一层防护。 * 意向锁相当于一个信号,就是告诉别人我要加锁了,所以意向锁并不是一个真正物理意义上的锁。 * 在 MySQL 里面,使用意向锁的典型场景是在增删改查的时候,对表结构定义加一个意向共享锁,防止在查询的时候有人修改表结构。而在修改表结构的时候,则会加一个意向排它锁。这也就是修改表结构的时候会直接阻塞掉所有的增删改查语句的原因。使用意向锁能够提高数据库的并发性能,并且避免死锁问题。 .. note:: 意向锁是自动管理的,一般情况下无需手动操作。MySQL的锁管理系统会自动处理意向锁的获取和释放,以满足并发事务的锁定需求。总之,意向锁在MySQL中主要用于协调事务对表级别锁定的请求,确保并发事务之间的互斥性和一致性。在多个事务涉及表级别锁定或需要获取排他锁或共享锁时,使用意向锁可以提供并发控制的支持。 以下情况下使用意向锁可以是有益的:: 1. 并发事务涉及表级别的锁定 当多个事务需要对同一张表进行锁定操作时,可以使用意向锁来协调事务之间的锁请求。 2. 事务需要获取排他锁(Exclusive Lock)或共享锁(Shared Lock) 当事务需要获取表级别的排他锁或共享锁时,可以先获取意向锁,以表明该事务有意向获取排他锁或共享锁。 3. 防止死锁 通过使用意向锁,可以避免因为多个事务同时请求表级别的排他锁而导致的死锁情况。 记录锁,间隙锁和临键锁 """"""""""""""""""""" 记录锁 ++++++ * 记录锁是指锁住了特定的某一条记录的锁。 .. note:: 使用了主键作为查询条件,并且是相等条件下,将只命中一条记录。这一条记录就会被加上记录锁。但是如果查询条件没有命中任何记录,那么就不会使用记录锁,而是使用临键锁。 * 如果数据库中只有 id 为(1,4,7)的三条记录,也就是 id= 3 这个条件没有命中任何数据,那么这条语句会在(1,4)加上临键锁。所以你可以看到,在生产环境里面遇到了未命中索引的情况,对性能影响极大。 间隙锁(Gap Locks) +++++++++++++++++ * 间隙锁是锁住了某一段记录的锁。直观来说就是你锁住了一个范围的记录。比如说你在查询的时候使用了 <、<=、BETWEEN 之类的范围查询条件,就会使用间隙锁。 示例:: SELECT * FROM your_tab WHERE id BETWEEN 50 AND 100 FOR UPDATE 间隙锁会锁住 (50,100) 之间的数据,而 50 和 100 本身会被记录锁锁住 * 如果你的表里面没有 50,那么数据库就会一直向左,找到第一个存在的数据,比如说 40;如果你的表里面没有 100,那么数据库就会一直向右,找到第一个存在的数据,比如说 120。那么使用的间隙锁就是 (40,120)。如果此时有人想要插入一个主键为 70 的行,是无法插入的,它需要等这个 SELECT 语句释放掉间隙锁。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/1p4aA6.png 间隙锁我们一般都说两边都是开的,即端点是没有被间隙锁锁住的。记录锁和记录锁是排它的,但是间隙锁和间隙锁不是排它的。也就是说两个间隙锁之间即便重叠了,也还是可以加锁成功的。 临键锁 ++++++ * 临键锁(Next-Key Locks)是很独特的一种锁,直观上来说可以看做是一个记录锁和间隙锁的组合。也就是说临键锁不仅仅是会用记录锁锁住命中的记录,也会用间隙锁锁住记录之间的空隙。临键锁和数据库隔离级别的联系最为紧密,它可以解决在 **可重复读** 隔离级别之下的幻读问题。 * 间隙锁是左开右开,而临键锁是左开右闭。还是用前面的例子来说明。如果 id 只有(1,4,7)三条记录,那么临键锁就将(1,4]锁住。 * 你可以认为,全部都是加临键锁的,除了下面两个子句提到的例外情况:: 右边缺省间隙锁。 例如你的值只有(1,4,7)三个,但是你查询的条件是 WHERE id < 5 那么加的其实是间隙锁,因为 7 本身不在你的条件范围内 等值查询记录锁。 这个其实针对的是主键和唯一索引,普通索引只适用上面两条 .. note:: 作用:防止幻读出现(前提:在可重复读隔离级别下,并且使用了悲观锁select ... for update) * 幻读就是同一事务里面,同一个sql查询查出来的记录行数不一样。为什么会不一样?因为有别的事务在你执行sql的时候进行了插入,插入到了你的查询条件范围内了,导致你上一次查还好好的,下一次查就莫名奇妙多出来记录了。 准备 ^^^^ * 公司出现过的死锁,包含排查过程、解决方案。 * 其他锁使用不当的场景,比如因为锁使用不当造成的一些性能问题。 * 收集至少一个使用乐观锁的场景,并且看看相关的 SQL 是怎么写的,做到心中有数。 * 收集公司内使用悲观锁的场景,并且尝试使用乐观锁来进行优化。 * 在主键或唯一索引上使用等值查询,例如 WHERE email = 'abc@qq.com',区分记录存在与不存在两种情况。 * 在主键或唯一索引上使用范围查询,例如 WHERE email >= 'abc@qq.com'。 * 在普通索引上使用等值查询。 * 在普通索引上使用范围查询。 * 执行查询,但是该查询不会使用任何索引。 引导到锁机制 """""""""""" * 索引: MySQL 的 InnoDB 引擎是借助索引来实现行锁的。 * 性能问题:锁使用不当引起的性能问题。 * 乐观锁:比如说原子操作中的 CAS 操作,你可以借助 CAS 这个关键词,聊一聊在 MySQL 层面上怎么利用类似的 CAS 操作来实现一个乐观锁。 * 语言相关的锁:比如说 Go 语言的 mutex 和 Java 的 Lock,都可以引申到数据库的锁。 * 死锁:聊一聊公司的数据库死锁案例。 基本方案 ^^^^^^^^ * MySQL 里面的锁机制特别丰富,这里我以 InnoDB 引擎为例。首先,从锁的范围来看,可以分成行锁和表锁。其次,从排它性来看,可以分成排它锁和共享锁。还有意向锁,结合排它性,就分为排它意向锁和共享意向锁。还有三个重要的锁概念,记录锁、间隙锁和临键锁。记录锁,是指锁住某条记录;间隙锁,是指锁住两条记录之间的位置;临键锁可以看成是记录锁与间隙锁的组合情况。 * 还有一种分类方法,是乐观锁和悲观锁。那么在数据库里面使用乐观锁,本质上是一种应用层面的 CAS 操作。 .. note:: 在 MySQL 的 InnoDB 引擎里面,锁和索引、隔离级别都是有密切关系的。在 InnoDB 引擎里面,行级锁是基于索引来实现的,但实际上锁定的是索引记录(index record),而不是索引本身。在执行查询时,锁定的是满足查询条件的索引记录所对应的行,而不是最终使用的那个索引。最终使用的索引仅仅是用于定位行的工具,而实际的锁是针对行进行的。 亮点方案 ^^^^^^^^ * 早期我发现我们的业务有一个神奇的性能问题,就是响应时间偶尔会突然延长。后来经过我们排查,确认响应时间是因为数据库查询变慢引起的。但是这些变长的查询,SQL 完全没有问题,我用 EXPLAIN 去分析,都很正常,也走了索引。 * 直到后面我们去排查业务代码的提交记录,才发现新加了一个功能,这个功能会执行一个 SQL,但是这个 SQL 本身不会命中任何索引。于是数据库就会使用表锁,偏偏这个 SQL 因为本身没有命中索引,又很慢,导致表锁一直得不到释放。结果其他正常的 SQL 反而被它拖累了。最终我们重新优化了这个使用表锁的 SQL,让它走了一个索引,就解决了这个问题。 临键锁引发的死锁 """""""""""""""" * 有一个场景是先从数据库中查询数据并锁住。如果这个数据不存在,那么就需要执行一段逻辑,计算出一个数据,然后插入。如果已经有数据了,那么就将原始的数据取出来,再利用这个数据执行一段逻辑,计算出来一个结果,执行更新。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/SmTcSM.png * 这个地方会引起死锁。假设说现在数据库中 ID 最大的值是 78。那么如果两个业务进来,同时执行这个逻辑。一个准备插入 id=79 的数据,一个准备插入 id = 80 的数据。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/f22Pot.png 当线程 1 想要执行插入的时候,它想要获得 id = 79 的行锁。当线程 2 想要执行插入的时候,它想要获得 id = 80 的行锁,这个时候就会出现死锁。因为线程 1 和线程 2 同时还在等着对方释放掉持有的间隙锁。 * 三个解决方案: * 1.不管有没有数据,先插入一个默认的数据。如果没有数据,那么会插入成功;如果有数据,那么会出现主键冲突或者唯一索引冲突,插入失败。那么在插入成功的时候,执行以前数据不存在的逻辑,但是因为此时数据库中有数据,所以不会使用间隙锁,而是使用行锁,从而规避了死锁问题。 * 2.调整数据库的隔离级别,降低为已提交读,那么就没有间隙锁了。这种解决方案也可以,而且你可以将话题进一步引申到 MVCC 中。 * 3.放弃悲观锁,使用乐观锁。这也是我提供的亮点方案。 * 关键词是临键锁 * 早期我优化过一个死锁问题,是临键锁引起的。业务逻辑很简单,先用 SELECT FOR UPDATE 查询数据。如果查询到了数据,那么就执行一段业务逻辑,然后更新结果;如果没有查询到,那么就执行另外一段业务逻辑,然后插入计算结果。 * 那么如果 SELECT FOR UPDATE 查找的数据不存在,那么数据库会使用一个临键锁。此时,如果有两个线程加了临键锁,然后又希望插入计算结果,那么就会造成死锁。 * 我这个优化也很简单,就是上来先不管三七二十一,直接插入数据。如果插入成功,那么就执行没有数据的逻辑,此时不会再持有临键锁,而是持有了行锁。如果插入不成功,那么就执行有数据的业务逻辑。 * 此外,还有两个思路。一个是修改数据库的隔离级别为 RC,那么自然不存在临键锁了,但是这个修改影响太大,被 DBA 否决了。另外一个思路就是使用乐观锁,不过代码改起来要更加复杂,所以就没有使用。 弃用悲观锁 """""""""" * 放弃使用 SELECT ... FOR UPDATE 语句 * 我在入职这家公司之后,曾经系统地清理过公司内部使用悲观锁的场景,改用乐观锁。正常的悲观锁都是使用了 SELECT FOR UPDATE 语句,查询到数据之后,进行一串计算,再将结果写回去。那么改造的方案很简单,查询的时候使用 SELECT 语句直接查询,然后进行计算。但是在写回去的时候,就要用到数据库的 CAS 操作,即 UPDATE 的时候要确认之前查询出来的结果并没有实际被修改过。 * 一般来说就是 UPDATE xxx SET data = newData WHERE id = 1 AND data = oldData。这种改造效果非常好,性能提升了 30%。当然,并不是所有的悲观锁场景都能清理,还有一部分实在没办法,只能是考虑别的手段了。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/bZgeYT.png 13| MVCC协议:MySQL修改数据时,还能不能读 --------------------------------------- * MVCC(Multi-Version Concurrency Control)中文叫做多版本并发控制协议,是 MySQL InnoDB 引擎用于控制数据并发访问的协议。 隔离级别 ^^^^^^^^ * 数据库的隔离级别是一组规则,用来控制并发访问数据库时如何分配、保护和共享资源。不同的隔离级别在不同的并发控制策略之间进行调整,从而提供了不同的读写隔离级别和安全性。 1. 读未提交(Read Uncommitted) """"""""""""""""""""""""""""" * 一个事务可以看到另外一个事务尚未提交的修改。 * 事务不需要获取共享锁或排他锁来读取或修改数据。事务可以读取到其他事务尚未提交的数据(脏读)。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/R6xeSq.png 2. 读已提交(Read Committed) """"""""""""""""""""""""""" * 简写 RC:是指一个事务只能看到已经提交的事务的修改。这意味着如果在事务执行过程中有别的事务提交了,那么事务还是能够看到别的事务最新提交的修改。 * 事务对于读取操作不需要获取任何锁,可以读取到已提交的数据。但对于修改操作,事务需要获取排他锁(Exclusive Lock)来保护被修改的数据,以防止其他事务同时修改该数据。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/ARFDIL.png 3. 可重复读(Repeatable Read)(默认) """""""""""""""""""""""""""""""""" * 简写 RR:是指在这一个事务内部读同一个数据多次,读到的结果都是同一个。这意味着即便在事务执行过程中有别的事务提交,这个事务依旧看不到别的事务提交的修改。这是 MySQL 默认的隔离级别。 * 事务在查询过程中会创建一个一致性视图,用于保证事务期间读取的数据保持一致。事务需要获取共享锁(Shared Lock)来保护查询过程中读取的数据,防止其他事务并发地修改这些数据。对于修改操作,事务需要获取排他锁来保护被修改的数据。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/e9r2bs.png 4. 串行化(Serializable) """"""""""""""""""""""" * 是指事务对数据的读写都是串行化的。 * 事务对于读取和修改操作,需要获取排他锁。串行化隔离级别下,事务会对读取和修改的数据行加上锁,以实现严格的串行化执行。 .. note:: 从上到下,隔离性变强但是性能变差了。 读异常 ^^^^^^ 脏读 """" * 脏读,是指读到了别的事务还没有提交的数据。之所以叫做“脏”读,就是因为未提交数据可能会被回滚掉。 不可重复读 """""""""" * 不可重复读,是指在一个事务执行过程中,对同一行数据读到的结果不同。 幻读 """" * 幻读,是指在事务执行过程中,别的事务插入了新的数据并且提交了,然后事务在后续步骤中读到了这个新的数据(列表数据不一定)。 其他 """" * 快照读,是在事务开始的时候创建了一个数据的快照,在整个事务过程中都读这个快照;MySQL 在可重复读这个隔离级别下,查询的执行效果和快照读非常接近。 * 当前读,则是每次都去读最新数据。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/Qx3U6o.png 理论上来说可重复读是没有解决幻读的。但是 MySQL 因为使用了临键锁,因此它的可重复读隔离级别已经解决了幻读问题。 版本链 ^^^^^^ * InnoDB 引擎给每一行都加了两个额外的字段 trx_id 和 roll_ptr。 * trx_id:事务 ID,也叫做事务版本号。MVCC 里面的 V 指的就是这个数字。每一个事务在开始的时候就会获得一个 ID,然后这个事务内操作的行的事务 ID,都会被修改为这个事务的 ID。 * roll_ptr:回滚指针。InnoDB 通过 roll_ptr 把每一行的历史版本串联在一起。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/bfHQ3u.png 这条链就是大名鼎鼎的版本链。这个版本链存储在所谓的 undolog 里面:假设最开始我插入了一行数据,我插入数据的这个事务的 ID 是 100,事务 A(事务ID 101)把 x 的值修改为 15,事务 B(事务ID 102)把 x 的值修改为 20。现在问题来了,假如这个时候我有一个新的事务 C,我要读 x 的值,那么我该读取 trx_id 为几的数据呢?这就涉及到了另外一个和 MVCC 紧密相关的概念:Read View。 Read View """"""""" * Read View 你可以理解成是一种可见性规则。被用来控制这个事务应该读取哪个版本的数据。 * Read View 最关键的字段叫做 m_ids,它代表的是当前已经开始,但是还没有结束的事务的 ID,也叫做活跃事务 ID。 * Read View 只用于 **已提交读** 和 **可重复读** 两个隔离级别,它用于这两个隔离级别的不同点就在于什么时候生成 Read View:: 1. 已提交读:事务每次发起查询的时候,都会重新创建一个新的 Read View 2. 可重复读:事务开始的时候,创建出 Read View 已提交读 ++++++++ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/cZHfi4.png * 在已提交读的隔离级别下,每一次查询语句都会重新生成一个 Read View。这意味着在事务执行过程中,Read View 是在不断变动的。现在我们来看一个例子,假如说现在已经有三个事务了,状态分别是已提交、未提交、未提交。 * 假如说现在新开了一个事务 A,分配给它的 ID 是 4。如果这个时候 A 开始查询 x 的值,那么 MySQL 会创建一个新的 Read View,其中 m_ids = 2,3。事务 A 发现最后一个已经提交的是事务 trx_id = 1,对应的 x 的值是 1。于是事务 A 读到 x = 1。 * 如果这个时候事务 2 提交了,事务 A 再次读取 x,这个时候 MySQL 又会生成一个新的 Read View m_ids=3。因此事务 A 会读取到 x = 4。 可重复读 ++++++++ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/wkzmL7.png * 在可重复读的隔离级别下,数据库会在事务开始的时候生成一个 Read View。这意味着整个 Read View 在事务执行过程中都是稳定不变的。我们用前面的例子来说明,就是在事务 A 开始的时候就会创建出来一个 Read View m_ids=2,3。 * 开始时,事务 A 去读 x 的数据,毫无疑问,读出来的是 x=1。 * 如果这时候事务 2 提交了,然后事务 A 想要再去读 x 的值,Read View 不会发生变化,还是 m_ids = 2,3。所以你可以看到,虽然事务 2 提交了,但是事务 A 完全不知道这回事,因此它还是读到 x=1。 * 万一这时候有一个新事务 ID = 5 开始了,并且也提交了。那么事务 A 并不会读取这个新事务的数据,因为新事务 ID 已经大于事务 A 的 ID 了(5 > 4),事务 A 知道这是一个比它还要晚的事务,所以会忽略新的事务的修改。 和 Read View 相关的概念还有三个(不重要):: 1. m_up_limit_id 是指 m_ids 中的最小值 2. m_low_limit_id 是指下一个分配的事务 ID 3. m_creator_trx_id 当前事务 ID .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/KaU4P2.png 准备 ^^^^ 基本思路 """""""" * 单纯使用锁的时候,并发性能会比较差。即便是在读写锁这种机制下,读和写依旧是互斥的。而数据库是一个性能非常关键的中间件,如果某个线程修改某条数据就让其他线程都不能读这条数据,这种性能损耗是无法接受的。所以 InnoDB 引擎引入了 MVCC,就是为了减少读写阻塞。 问题:: 你是否了解 MVCC? MVCC 是什么? MySQL 的 InnoDB 引擎是怎么控制数据并发访问的? 当一个线程在修改数据的时候,另外一个线程还能不能读到数据? * MVCC 是 MySQL InnoDB 引擎用于控制数据并发访问的协议。MVCC 主要是借助于版本链来实现的。在 InnoDB 引擎里面,每一行都有两个额外的列,一个是 trx_id,代表的是修改这一行数据的事务 ID。另外一个是 roll_ptr,代表的是回滚指针。InnoDB 引擎通过回滚指针,将数据的不同版本串联在一起,也就是版本链。这些串联起来的历史版本,被放到了 undolog 里面。当某一个事务发起查询的时候,MVCC 会根据事务的隔离级别来生成不同的 Read View,从而控制事务查询最终得到的结果。 * 在 MySQL 的 InnoDB 引擎里面,使用了临键锁来解决幻读的问题,所以实际上 MySQL InnoDB 引擎的可重复读隔离级别也没有幻读的问题。一般来说,隔离级别越高,性能越差。所以我之前在公司做的一个很重要的事情,就是推动隔离级别降低为已提交读。 亮点方案 """""""" 重点在于描述清楚两方面的内容:: 推动公司将隔离级别从默认的可重复读降低为已提交读。 在已提交读的基础上,万一需要利用可重复读的特性,该怎么办? * 强调为什么要改。 * 最开始我来到公司的时候,我们的数据库隔离级别都是使用默认的隔离级别,也就是可重复读。但其实我们的业务场景很少利用可重复读的特性,比如说几乎全部事务内部对某一个数据都是只读一次的。 * 并且,可重复读比已提交读更加容易引起死锁的问题,比如说我们之前就出现过一个因为临键锁引发的死锁问题。而且已提交读的性能要比可重复读更好。所以综合之下,我就推动公司去调整隔离级别,将数据库的默认隔离级别降低为已提交读。 关键词是改造业务 ++++++++++++++++ * 正常来说我是不推荐使用可重复读的,因为在我们的业务环境下想不到有什么场景非得使用可重复读这个隔离级别。 * 之前在推动降低隔离级别的时候,我其实重构过一些业务。这一类业务就是在一个事务里面发起了两个同样的查询,比如说在 UPDATE 之后又立刻查询,这种查询还必须走主库,不然会有主从延迟的问题。 * 这种业务可以通过缓存第一次查询的数据来避免第二次查询。但是这种改造一般是避不开幻读的。不过在业务上幻读一般不是问题。一方面是业务层面上区分不出来是否是幻读。另外一方面,事务提交了往往代表业务已经结束,那么发生幻读了,业务依旧是正常的。比如说事务 A 读到了事务 B 新插入的数据,但是事务 B 本身已经提交了,那么事务 A 就认为事务 B 所在的业务已经完结了,那么读到了就读到了,并不会出什么问题。 关键词是指定隔离级别 ++++++++++++++++++++ * 万一不能改造业务,那么还有一个方法,就是直接在创建事务的时候指定隔离级别。我前面调整的都是数据库的默认隔离级别,实际上还可以在 Session 或者事务这两个维度上指定隔离级别。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/mLtICP.png 14|数据库事务:事务提交了,你的数据就一定不会丢吗 ----------------------------------------------- 前置知识 ^^^^^^^^ undo log """""""" * undo log 是指回滚日志,它记录着事务执行过程中被修改的数据。当事务回滚的时候,InnoDB 会根据 undo log 里的数据撤销事务的更改,把数据库恢复到原来的状态。 redo log """""""" * InnoDB 引擎在数据库发生更改的时候,把更改操作记录在 redo log 里,以便在数据库发生崩溃或出现其他问题的时候,能够通过 redo log 来重做。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/9oLgk7.png InnoDB 引擎读写都不是直接操作磁盘的,而是读写内存里的 buffer pool,后面再把 buffer pool 里面修改过的数据刷新到磁盘里面。这是两个步骤,所以就可能会出现 buffer pool 中的数据修改了,但是还没来得及刷新到磁盘数据库就崩溃了的情况。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/IWqL3g.png 为了解决这个问题,InnoDB 引擎就引入了 redo log。相当于 InnoDB 先把 buffer pool 里面的数据更新了,再写一份 redo log。等到事务结束之后,就把 buffer pool 的数据刷新到磁盘里面。万一事务提交了,但是 buffer pool 的数据没写回去,就可以用 redo log 来恢复。 .. note:: redo log 不需要写磁盘吗?如果 redo log 也要写磁盘,干嘛不直接修改数据呢?redo log 是需要写磁盘的,但是 redo log 是顺序写的,所以也是 WAL(write-ahead-log) 的一种。也就是说,不管你要修改什么数据,一会修改这条数据,一会修改另外一条数据,redo log 在磁盘上都是紧挨着的。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/tOyBkD.png redo log 本身也是先写进 redo log buffer,后面再刷新到操作系统的 page cache,或者一步到位刷新到磁盘。 InnoDB 引擎本身提供了参数 innodb_flush_log_at_trx_commit 来控制写到磁盘的时机,里面有三个不同值:: 0: 每秒刷新到磁盘,是从 redo log buffer 到磁盘 1: 每次提交的时候刷新到磁盘上,也就是最安全的选项,InnoDB 的默认值 2: 每次提交的时候刷新到 page cache 里,依赖于操作系统后续刷新到磁盘 注: 除非把 innodb_flush_log_at_trx_commit 设置成 1,否则其他两个都有丢失的风险 .. note:: 结论:你的业务都认为事务提交成功了,但是数据库实际上丢失了这个事务。 磁盘刷新例外:: 1. 如果 redo log buffer 快要满了,也会触发把 redo log 刷新到磁盘里这个动作 2. 如果某个事务提交的时候触发了刷新到磁盘的动作,那么当下所有事务的 redo log 也会一并刷新 事务执行过程 """""""""""" .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/RMtOFE.png 示例说明:: 原本 a = 3,现在要执行 UPDATE tab SET a = 5 WHERE id = 1。 1. 事务开始,在执行 UPDATE 语句之前会先查找到目标行,加上锁,然后写入到 buffer pool 里面 2. 写 undo log 3. InnoDB 引擎在内存上更新值,实际上就是把 buffer pool 的值更新为目标值 5 4. 写 redo log 5. 提交事务,根据 innodb_flush_log_at_trx_commit 决定是否刷新 redo log * 两个比较重要的子流程。 * 如果在 redo log 已经刷新到磁盘,然后数据库宕机了,buffer pool 丢失了修改,那么在 MySQL 重启之后就会回放这个 redo log,从而纠正数据库里的数据。 * 如果都没有提交,中途回滚,就可以利用 undo log 去修复 buffer pool 和磁盘上的数据。因为有时,buffer pool 脏页会在事务提交前刷新磁盘,所以 undo log 也可以用来修复磁盘数据。 * 实际上,事务执行过程有很多细节,也要比这里描述得复杂很多。 binlog """""" * binlog 是用于存储 MySQL 中二进制日志(Binary Log)的操作日志文件,它是 MySQL Server 级别的日志,也就是说所有引擎都有。它记录了 MySQL 中数据库的增删改操作,因此 binlog 主要有两个用途,一是在数据库出现故障时恢复数据。二是用于主从同步,即便是 Canal 这一类的中间件本质上也是把自己伪装成一个从节点。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/hcojDt.png 在事务执行过程中,写入 binlog 的时机有点巧妙。它和 redo log 的提交过程结合在一起称为 MySQL 的两阶段提交。一阶段:redo log Prepare(准备);第二阶段:redo log Commit(提交) * 如果 redo log Prepare 执行完毕后,binlog 已经写成功了,那么即便 redo log 提交失败,MySQL 也会认为事务已经提交了。 * 如果 binlog 没写成功,那么 MySQL 就认为提交失败了。 binlog 也有刷新磁盘的问题,可以通过 sync_binlog 参数来控制它:: 0:由操作系统决定,写入 page cache 就认为成功了。0 也是默认值,这个时候数据库的性能最好。 N:每 N 次提交就刷新到磁盘,N 越小性能越差。 准备 ^^^^ 基本思路 """""""" * 关键词是隔离级别。 * ACID 中的隔离性是比较有意思的,它和数据库的隔离级别概念密切相关。我个人认为隔离级别中未提交读和已提交读看起来不太符合这里隔离性的定义。按理来说,可重复读也不满足。但是 MySQL InnoDB 引擎的可重复读解决了幻读问题,所以我认为 MySQL 的可重复读和串行化才算是满足了这里隔离性的要求。 亮点方案 """""""" 写入语义 ++++++++ * redo log 和 binlog 的刷盘问题,在中间件里面经常遇到。如果不是直接写到磁盘,那么中间件就会考虑定时或者定量刷新数据到磁盘。 * 一般来说,中间件的写入语义可能是:: 1. 中间件成功写入到自身的日志中或者缓冲区 2. 中间件发起了系统调用,成功写入操作系统的 page cache 3. 中间件强制发起刷盘,数据被成功持久化到了磁盘上 * 在分布式环境下,写入语义会更加复杂,因为要考虑是否写从节点,以及写多少个从节点的问题。比如说在 Kafka 里面 acks 机制,就是控制写入的时候要不要同步写入到从分区里面,或者 Redis 里面控制 AOF 刷盘时机。 调整刷盘时机 ++++++++++++ * 在我的系统里面,有一个业务对数据不丢失,一致性要求非常高。因此我们尝试调整 sync_binlog 为 1。但是代价就是数据库的性能比较差,因为每次提交都需要刷新 binlog 到磁盘上。当然这时候 innodb_flush_log_at_trx_commit 也要使用默认值 1。 * 我们有一个对性能非常敏感,但是对数据丢失容忍度比较高的业务,那么我就尝试将 innodb_flush_log_at_trx_commit 设置为 2,让操作系统来决定什么时候刷新 redo log。同时还把 sync_binlog 的值调整为 100,进一步提高数据库的性能。 * 综合两者 * 之前我们有一个数据库,给两类业务使用。一类业务对数据不丢失,一致性要求极高,另外一类对性能敏感,但是可以容忍一定程度的数据丢失。后来我将这两类业务的表分开,放在两个数据库上。 思路总结 """""""" .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/NNNVg7.png 15|数据迁移:如何在不停机的情况下保证迁移数据的一致性 ---------------------------------------------------- 准备 ^^^^ * 这个系统是我们公司的一个核心系统,但是又有非常悠久的历史。在我刚接手的时候,它已经处于无法维护的边缘了。但是不管是重构这个系统,还是重新写一个类似的系统,已有的数据都是不能丢的。所以我的核心任务就是重新设计表结构,并且完成数据迁移。为此我设计了一个高效、稳定的数据迁移方案。 * 我进公司的时候,刚好遇上单库拆分分库分表。我主要负责的事情就是设计一个数据迁移方案,把数据从单库迁移到分库分表上。 解决方案 """""""" 基本步骤:: 1. 创建目标表。 2. 用源表的数据初始化目标表。 3. 执行一次校验,并且修复数据,此时用源表数据修复目标表数据。 4. 业务代码开启双写,此时读源表,并且先写源表,数据以源表为准。 5. 开启增量校验和数据修复,保持一段时间。 6. 切换双写顺序,此时读目标表,并且先写目标表,数据以目标表为准。 7. 继续保持增量校验和数据修复。 8. 切换为目标表单写,读写都只操作目标表。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/tc6IFh.png 不考虑数据校验,那么整个数据迁移过程是这样的 步骤 """" * 我选择了从源表导出数据,使用的是 mysqldump 工具。mysqldump 是一个开源的逻辑备份工具,优点是使用简单,能够直接导出整个数据库。缺点则是导出和导入的速度都比较慢,尤其是在数据量非常大的情况下。所以我针对 mysqldump 做了一些优化,来提高导出和导入的性能。加快导出速度能做的事情并不多,主要就是开启 extended-insert 选项,将多行合并为一个 INSERT 语句。 * 加快导入速度就可以做比较多的事情。 * 关闭唯一性检查和外键检查,源表已经保证了这两项,所以目标表并不需要检查。 * 关闭 binlog,毕竟导入数据用不着 binlog。 * 调整 redo log 的刷盘时机,把 innodb_flush_log_at_trx_commit 设置为 0。 第一次校验与修复 """""""""""""""" * 可以使用 update_time 字段,update_time 晚于你导出数据的那个时间点,才说明这一行的数据已经发生了变更。在修复的时候就直接用源表的数据覆盖掉目标表的数据。 业务开启双写 """""""""""" * 支持双写大体上有两个方向:侵入式和非侵入式两种。 * 侵入式方案就是直接修改业务代码。要求业务代码在写了源表之后再写目标表。但是侵入式方案是不太可行的,或者说代价很高。因为这意味着所有的业务都要检查一遍,然后修改。既然修改了,那自然还要测试。所以,一句话总结就是工作量大还容易出错。 * 非侵入式一般和你使用的数据库中间件有关,比如说 ORM 框架。 这一类框架一般会提供两种方式来帮你解决类似的问题:: AOP(Aspect Oriented Program 面向切面编程)方案 不同框架有不同叫法,比如说可能叫做 interceptor、middleware、hook、handler、filter 这个方案的关键就是捕捉到发起的增删改调用,篡改为双写模式 数据库操作抽象 可能叫做 Session、Connection、Connection Pool、Executor 等,就是将对源表的操作修改为双写模式。 .. note:: 不管你采用哪个方案,你都要确保一个东西,就是双写可以在运行期随时切换状态,单写源表、先写源表、先写目标表、单写目标表都可以。 数据一致性问题 """""""""""""" * 如果在双写过程中,写入源表成功了,但是写入目标表失败了,该怎么办?那么最基础的回答就是不管。 * 写入源表成功,但是写入目标表失败,这个是可以不管的。因为我后面有数据校验和修复机制,还有增量校验和修复机制,都可以发现这个问题。 * 然后你可以提出一个曾经思考过但是最终没有实施的方案,这能够证明你在数据一致性上有过很深入的思考,关键词是难以确定被影响的行。 * 在设计方案的时候,我考虑过在写入目标表失败的时候,发一个消息到消息队列,然后尝试修复数据。但是这个其实很难做到,因为我不知道该修复哪些数据。比如说一个 UPDATE 语句在目标表上执行失败,我没办法根据 UPDATE 语句推断出源表上哪些行被影响到了。 主键问题 """""""" .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/DBSPkx.png 在双写的时候比较难以处理的问题是自增主键问题。为了保持源表和目标表的数据完全一致,需要在源表插入的时候拿到自增主键的值,然后用这个值作为目标表插入的主键。 增量校验和数据修复 """""""""""""""""" * 增量校验基本上就是一边保持双写,一边校验最新修改的数据,如果不一致,就要进行修复。 利用更新时间戳 ++++++++++++++ * 利用更新时间戳的思路很简单,就是定时查询每一张表,然后根据更新时间戳来判断某一行数据有没有发生变化。 * 关键词是更新时间戳。 * 我们采用的方案是利用更新时间戳找出最近更新过的记录,然后再去目标表里面找到对应的数据,如果两者不相等,就用源表的数据去修复目标表的数据。这个方案有两个条件:所有的表都是有更新时间戳的,并且删除是软删除的。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/j7qxwB.png 如果不是软删除的,那么源表删掉数据之后,如果目标表没删除,在我们的匹配逻辑里面是找不到的。在这种场景下,还有一个补救措施,就是反向全量校验。也就是说从目标表里面再次查询全量数据,再去源表里找对应的数据。如果源表里面没有找到,就说明源表已经删了数据,那么目标表就可以删除数据了。 * 主从同步延迟引发的问题。 * 假设你在校验和修复的时候,读的都是从库,那么你会遇到两种异常情况。一种是目标表主从延迟,另一种是源表主从延迟。 * 简单粗暴的方法就是全部读主库,校验和修复都以主库数据为准。缺点就是对主库的压力会比较大。 * 更加高级的方案:双重校验。 * 校验和修复的时候都要小心主从同步的问题,如果校验和修复都使用从库的话,那么就会出现校验出错,或者修复出错的情况。按照道理来说,强制走主库就可以解决问题,但是这样对主库的压力是比较大的。 * 所以我采用的是双重校验方案。第一次校验的时候读从库,如果发现数据不一致,再读主库,用主库的数据再校验一次。修复的时候就只能以主库数据为准。这种方案的基本前提是,主从延迟和数据不一致的情况是小概率的,所以最终会走到主库也是小概率的。 利用 binlog +++++++++++ * 关键词就是 binlog 触发。 * 在校验和修复的数据时候,我采用的是监听 binlog 的方案。binlog 只用于触发校验和修复这个动作,当我收到 binlog 之后,我会用 binlog 中的主键,去查询源表和目标表,再比较两者的数据。如果不一致,就用源表的数据去修复目标表。 * 第二个形态:binlog 的数据被看作源表数据。 * 拿到 binlog 之后,我用主键去目标表查询数据,然后把 binlog 里面的内容和目标表的数据进行比较。如果数据不一致,再用 binlog 的主键去源表里面查询到数据,直接覆盖目标表的数据。 * 需要查询源表,再用查询到的数据去覆盖目标表的数据,而不是直接用 binlog 的数据去覆盖目标表的数据。因为你要防止 binlog 是很古老的数据,而目标表是更加新的数据这种情况。 * 第三个形态:直接比较源表的 binlog 和目标表的 binlog * 理论上是可行的,但是它有两个非常棘手的问题。 * 一次双写,你可能立刻就收到了源表的 binlog,但是你过了好久才收到目标表的 binlog。反过来,先收到目标表的 binlog,隔了很久才收到源表的 binlog 也一样。所以你需要缓存住一端的 binlog,再等待另外一端的 binlog。 * 顺序问题,如果有两次双写操作的是同一行,那么你可能先收到源表第一次的 binlog,再收到目标表第二次双写的 binlog,你怎么解决这个问题呢?你只能考虑利用消息队列之类的东西给 binlog 排个序,确保 binlog 收到的顺序和产生的顺序一致。 切换双写顺序 """""""""""" * 引入这一步,是为了能够在切换到以目标表为准之前,有一个过渡阶段。也就是说,通过先写目标表,再写源表这种方式,万一发现数据迁移出现了问题,还可以回滚为先写源表,再写目标表,确保业务没有问题。 保持增量校验和修复 """""""""""""""""" * 保持增量校验和修复是顺理成章的,只不过在这一步,就是以目标表为准。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/UAvJBd.png 16|分库分表主键生成:如何设计一个主键生成算法 -------------------------------------------- 前置知识 ^^^^^^^^ * 所谓的分库分表严格来说是分数据源、分库和分表。 * 主键生成的两个要点。 * 1. 我们希望全局唯一的 ID 依旧能够保持自增,因为自增与否会显著影响插入的性能。 * 2. 因为只有数据量大的才会考虑分库分表,而数据量大一般意味着并发高,所以还要考虑怎么支持高并发。 准备 ^^^^ * 深入理解市面上常见的主键生成策略。 * 准备一个有亮点的、微创新的主键生成方案。 * 记住一些可行的优化方案。 常见思路 ^^^^^^^^ UUID """" UUID 的两个弊端:: 1. 过长:这个弊端其实在面试里面讨论得比较少,毕竟会采用 UUID 的地方就不会在意它的长度了。 2. UUID 不是递增的:这个弊端是你面试时要重点描述的,并且要尝试刷出亮点来。 * 【亮点 1:页分裂】UUID 最大的缺陷是它产生的 ID 不是递增的。一般来说,我们倾向于在数据库中使用自增主键,因为这样可以迫使数据库的树朝着一个方向增长,而不会造成中间叶节点分裂,这样插入性能最好。而整体上 UUID 生成的 ID 可以看作是随机,那么就会导致数据往页中间插入,引起更加频繁地页分裂,在糟糕的情况下,这种分裂可能引起连锁反应,整棵树的树形结构都会受到影响。所以我们普遍倾向于采用递增的主键。 * 【亮点 2:顺序读】自增主键还有一个好处,就是数据会有更大的概率按照主键的大小排序,两条主键相近的记录,在磁盘上位置也是相近的。那么可以预计,在范围查询的时候,我们能够更加充分地利用到磁盘的顺序读特性。 * 【亮点 3:InnoDB 的数据组织】进一步解释数据库的页分裂究竟是怎么一回事:MySQL 的 InnoDB 引擎,每一个页上按照主键的大小放着数据。假如说我现在有一个页放着主键 1、2、3、5、6、7 这六行数据,并且这一页已经放满了。现在我要插入一个 ID 为 4 的行,那么 InnoDB 引擎就会发现,这一页已经放不下 4 这行数据了,于是逼不得已,就要将原本的页分成两页,比如说 1、2、3 放到一页,5、6、7 放到另外一页,然后可以把 4 放到第一页。这种页分裂会造成一个问题,就是虽然从逻辑上来说 1、2、3 这一页和 5、6、7 这一页是邻近的两个页,但是在真实存储的磁盘上,它们可能离得很远。 数据库自增 """""""""" * 设置了步长的自增。 * 经过分库分表之后我有十个表,那么我可以让每一个表按照步长来生成自增 ID。比如说第一个表就是生成 1、11、21、31 这种 ID,第二个表就是生成 2、12、22、32 这种 ID。 * 关键词是表内部自增。 * 这种方案非常简单,而且本身我们在应用层面并不需要做任何事情,只是需要在创建表的时候指定好步长就可以了。ID 虽然并不一定是全局递增的,但是在一个表内部,它肯定是递增的。这个方案的性能基本取决于数据库性能,应用层面上也不需要关注。 雪花算法 """""""" .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/DZPZ8h.png 雪花算法的原理倒不难,它的关键点在于分段。 雪花算法保证 ID 唯一性的理由:: 1. 时间戳是递增的,不同时刻产生的 ID 肯定是不同的 2. 机器 ID 是不同的,同一时刻不同机器产生的 ID 肯定也是不同的 3. 同一时刻同一机器上,可以轻易控制序列号 * 雪花算法采用 64 位来表示一个 ID,其中 1 比特保留,41 比特表示时间戳,10 比特作为机器 ID,12 比特作为序列号。 * 【亮点 1:调整分段】机器 ID 虽然明面上是机器 ID,但是实际上并不是指物理机器,准确说是算法实例。例如,一台机器部署两个进程,每个进程的 ID 是不同的;又或者进一步切割,机器 ID 前半部分表示机器,后半部分可以表示这个机器上用于产生 ID 的进程、线程或者协程。甚至机器 ID 也并不一定非得表示机器,也可以引入一些特定的业务含义。而序列号也是可以考虑加长或者缩短的。【升华】雪花算法可以算是一种思想,借助时间戳和分段,我们可以自由切割 ID 的不同比特位,赋予其不同的含义,灵活设计自己的 ID 算法。 * 【亮点 2:序列号耗尽】一般来说可以考虑加长序列号的长度,比如说缩减时间戳,然后挪给序列号 ID。当然也可以更加简单粗暴地将 64 位的 ID 改成 96 位的 ID,那么序列号 ID 就可以有三四十位,即便是国际大厂也不可能用完了。不过,彻底的兜底方案还是要有的。我们可以考虑引入类似限流的做法,在当前时刻的 ID 已经耗尽之后,可以让业务方等一下。我们的时间戳一般都是毫秒数,那么业务方最多就会等一毫秒。 亮点方案: 主键内嵌分库分表键 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 大多数时候,我们会面临一个问题,就是分库分表的键和主键并不是同一个。比如说在 C 端的订单分库分表,我们可以采用买家 ID 来进行分库分表。但是一些业务场景,比如说查看订单详情,可能是根据主键又或者是根据订单 SN 来查找的。 * 那么我们可以考虑借鉴雪花算法的设计,将主键生成策略和分库分表键结合在一起,也就是说在主键内部嵌入分库分表键。例如,我们可以这样设计订单 ID 的生成策略,在这里我们假设分库分表用的是买家 ID 的后四位。第一段依旧是采用时间戳,但是第二段我们就换成了这个买家后四位,第三段我们采用随机数。 * 普遍情况下,我们都是用买家 ID 来查询对应的订单信息。在别的场景下,比如说我们只有一个订单 ID,这种时候我们可以取出订单 ID 里嵌入进去的买家 ID 后四位,来判断数据存储在哪个库、哪个表。类似的设计还有答题记录按照答题者 ID 来分库分表,但是答题记录 ID 本身可以嵌入这个答题者 ID 中用于分库分表的部分。 * 【升华】这一类解决方案,核心就是不拘泥于雪花算法每一段的含义。比如说第二段可以使用具备业务含义的 ID,第三段可以自增,也可以随机。只要我们最终能够保证 ID 生成是 **全局递增** 的,并且是 **独一无二** 的就可以。 全局递增 """""""" * 这个方案能够保证主键递增吗? * 这个保证不了,但是它能够做到大体上是递增的。你可以设想,同一时刻如果有两个用户来创建订单,其中用户 ID 为 2345 的先创建,用户 ID 为 1234 的后创建,那么很显然用户 ID 1234 会产生一个比用户 ID 2345 更小的订单 ID;又或者同一时刻一个买家创建了两个订单,但是第三段是随机数,第一次 100,第二次 99,那么显然第一次产生的 ID 会更大。 * 但是这并不妨碍我们认为,随着时间推移,后一时刻产生的 ID 肯定要比前一时刻产生的 ID 要大。这样一来,虽然性能比不上完全严格递增的主键好,但是比完全随机的主键好。 独一无二 """""""" * 产生一样 ID 的概率不是没有,而是极低。它要求同一个用户在同一时刻创建了两个订单,然后订单 ID 的随机数部分一模一样,这是一个很低的概率。 * 在下单场景下,正常的用户都是一个个订单慢慢下,在同一时刻同时创建两个订单,对于用户来说,是一件不可能的事情。而如果有攻击者下单,那就更加无所谓,反正是攻击者的订单,失败就失败了。而即便真的有用户因为共享账号之类的问题同一时刻下两个订单,那么随机到同一个数的概率也是十万分之一。 * 解决方案,关键词就是重新产生一个主键。 * 解决方案其实也很简单,就是在插入数据的时候,如果返回了主键冲突错误,那么重新产生一个,再次尝试就可以了。 * 实际上,还有一种非常偶发性的因素也可能会引起 ID 冲突,也就是时钟回拨,不过相比正统雪花算法,时钟回拨问题在这个方案里面不太严重,毕竟还有一个随机数的部分。 优化思路 ^^^^^^^^ * 优化的点就是:批量取、提前取、singleflight 取、局部分发。 批量取 """""" * 批量取是指业务方每次跟发号器打交道,不是只拿走一个 ID,而是拿走一批,比如说 100 个。拿到之后业务方自己内部慢慢消耗。消耗完了再去取下一批。 * 这种优化思路的优点就是极大地减轻发号器的并发压力。比如说一批是 100 个,那么并发数就降低为原本的 1% 了。缺点就是可能破坏递增的趋势。 提前取 """""" * 提前取是指业务方提前取到 ID,这样就不需要真的等到需要 ID 的时候再临时取。提前取可以和批量取结合在一起,即提前取一批,然后内部慢慢使用。 singleflight 取 """"""""""""""" * 类似于在缓存中应用 singleflight 模式。假如说业务方 A 有几十个线程或者协程需要 ID,那么没有必要让这些线程都去取 ID,而是派一个代表去取。这个代表取到之后再分发给需要的线程。这也能够降低发号器的并发负载。 局部分发 """""""" * 假如说现在整个实例上有 1000 个 ID,这些 ID 是批量获取的。那么一个线程需要 ID 的时候,它就不再是只拿一个,而是拿 20 个,然后存在自己的 TLB(thread-local-buffer) 里面,以后这个线程需要 ID 的时候,就先从自己的 TLB 里面拿,避开了全局竞争,减轻了并发压力。 思路回顾 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/DSOOYK.png 17|分库分表分页查询:为什么你的分页查询又慢又耗费内存 ---------------------------------------------------- 前置知识 ^^^^^^^^ 一般做法 ^^^^^^^^ * 哈希分库分表:根据分库分表键算出一个哈希值,然后根据这个哈希值选择一个数据库。最常见的就是使用数字类型的字段作为分库分表键,然后取余。比如说在订单表里面,按照买家的 ID 除以 8 的余数进行分表。 * 范围分库分表:将某个数据按照范围大小进行分段。比如说根据 ID,[0, 1000) 在一张表,[1000, 2000) 在另外一张表上。最常见的应该是按照日期进行分库分表,比如说按照月分表,每个月一张表。 * 中间表:引入一个中间表来记录数据所在的目标表。一般是记录主键到目标表的映射关系。 分库分表中间件的形态 ^^^^^^^^^^^^^^^^^^^^ * SDK 形态:SDK 形态就是我们最熟悉的,它通过依赖的形式引入到你的代码里面。比如说 ShardingSphere 的 Java 依赖。 * Proxy 形态:独立部署的分库分表中间件,它对于所有的业务方来说,就像一个普通的数据库,业务方的查询发送过去之后,它就会执行分库分表,发起实际查询,然后把查询结果返回给业务方。ShardingSphere 也支持这种形态。 * Sidecar 形态:简单来说就是一个提供了分库分表的 Sidecar。这是一个理论上的形态,现在并没有非常成熟的产品。 * 这三种形态里面,SDK 形态性能最好,但是和语言强耦合。比如说 Java 研发的 ShardingSphere jar 包是没办法给 Go 语言使用的。 * Proxy 形态性能最差,因为所有的数据库查询都发给了它,很容易成为性能瓶颈。尤其是单机部署 Proxy 的话,还面临着单节点故障的问题。它的优点就是跟编程语言没有关系,所以部署一个 Proxy 之后可以给使用不同编程语言的业务使用。同时,Proxy 将自己伪装成一个普通的数据库之后,业务方可以轻易地从单库单表切换到分库分表,整个过程对于业务方来说就是换了一个数据源。 准备 ^^^^ * 最好是把分页查询优化作为你性能优化的一个举措,可以和查询优化、数据库参数优化相结合,这样你的方案会更加完善 基本思路 """""""" * 最开始我在公司监控慢查询的时候,发现有一个分页查询非常慢。这个分页查询是按照更新时间降序来排序的。后来我发现那个分页查询用的是全局查询法,因为这个接口原本是提供给 Web 端用的,而 Web 端要支持跨页查询,所以只能使用全局查询法。当查询的页数靠后的时候,响应时间就非常长。 * 后来我们公司搞出 App 之后,类似的场景直接复用了这个接口。但是事实上在 App 上是没有跨页需求的。所以我就直接写了一个新接口,这个接口要求分页的时候带上上一页的最后一条数据的更新时间。也就是我用这个更新时间构造了一个查询条件,只查询早于这个时间的数据。那么分页查询的时候 OFFSET 就永远被我控制在 0 了,查询的时间就非常稳定了。 * 【总结】分页查询在分库分表里面是一个很难处理的问题,要么查询可能有性能问题,比如说这里使用的全局查询法,要么就是要求业务折中,比如说禁用跨页查询 * 目前比较好的分页做法是禁用跨页查询,然后在每一次查询条件里面加上上一次查询的极值 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/NeWi1S.png 18|分布式事务:如何同时保证分库分表, ACID和高性能 ------------------------------------------------ * 把单库拆分成为分库分表之后,一个巨大的挑战就是本地事务变成了分布式事务。 前置知识 ^^^^^^^^ 两阶段提交 """""""""" * 两阶段提交协议(Two Phase Commit)是分布式事务中的一种常用协议,算法思路可以概括为参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情况决定各参与者要提交操作还是中止操作。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/ACwI3t.png * 准备阶段,协调者让参与者执行事务,但是并不提交,协调者返回执行情况。这个阶段参与者会记录 Redo 和 Undo 信息,用于后续提交或者回滚。 * 提交阶段,协调者根据准备阶段的情况,要求参与者提交或者回滚,参与者返回提交或者回滚的结果。准备阶段任何一个节点执行失败了,就都会回滚。全部执行成功就提交。 .. note:: 两阶段提交协议的缺点很多。最大缺点是在执行过程中节点都处于阻塞状态。此外,协调者也是关键,如果协调者崩溃,整个分布式事务都无法执行。 三阶段提交 """""""""" * 三阶段提交协议是在两阶段提交协议的基础上进行的改进,三阶段提交协议引入了一个额外阶段来确保在执行事务之前有足够的资源,减少两阶段协议引起的事务失败的可能。 整个三阶段提交协议的三个阶段是这样的:: 第一阶段(CanCommit):协调者问一下各个参与者能不能执行事务。参与者这时候一般是检查一下自己有没有足够的资源。 第二阶段(PreCommit):类似于两阶段提交的第一个阶段,执行事务但是不提交。 第三阶段(Commit):直接提交或者回滚。 .. note:: 目前看来,三阶段提交协议并没有两阶段提交协议使用得那么广泛,原因有两个,一是两阶段提交协议已经足以解决大部分问题了,二是三阶段提交协议的收益和它的复杂度比起来,性价比有点低。 XA 事务 """"""" * 可以这么认为:两阶段协议是一种学术理论,而 XA 则是把两阶段提交协议具像化之后的一个标准。它定义了协调者和参与者之间的接口。 准备 ^^^^ 几个问题:: 如果公司使用了分库分表,那么是否允许跨库事务? 如果允许跨库事务,那么是如何解决的? 如果你使用了分库分表中间件,那么它支持哪些类型的事务? 在微服务层面上,使用的是什么样的分布式事务方案?是 TCC、SAGA 还是 AT? 当你在使用分布式事务的时候,中间步骤出错了你怎么办? 在面试微服务架构的时候就有可能面到分布式事务:: 在单体应用拆分成微服务架构之后,你怎么解决分布式事务? 你们的服务是共享一个数据库吗?如果不是的话,你们怎么解决分布式事务问题? 在分库分表里面也会有类似的问法:: 在单库拆分之后,你怎么解决分布式事务问题? 当你开启一个事务的时候,分库分表中间件做了什么? 怎么在分库分表的事务里面保证 ACID? 基本思路 ^^^^^^^^ * 关键词是最终一致性。 * 分布式事务或者说跨库事务基本上都只能依赖于最终一致性,ACID 是不太可能的。比如说常见的 TCC、AT、SAGA,又或者比较罕见的延迟事务,其实都是追求最终一致性。 TCC 事务 """""""" TCC 是一个追求最终一致性,而不是严格一致性的事务解决方案,它不满足 ACID 要求。TCC 是 Try-Confirm-Cancel 的缩写,它勉强也算是两阶段提交协议的一种实现:: Try:对应于两阶段提交协议的准备阶段,执行事务但是不提交。 Confirm:对应于两阶段提交协议第二阶段的提交步骤。 Cancel:对应于两阶段提交协议第二阶段的回滚步骤。 .. note:: 之所以给它一个新名字,完全是因为 TCC 强调的是业务自定义逻辑。也就是说 Try 是执行业务自定义逻辑,Confirm 也是执行业务自定义逻辑,Cancel 同样如此。 * 【亮点一:TCC 与本地事务】在 TCC 里面,Try、Confirm、Cancel 都可以看成是一个完整的本地事务。比如在我的某个业务里面,Try 本身就是插入数据,但是处于初始化状态,还不能使用。后续 Confirm 的时候就是把状态更新为可用,而 Cancel 则是更新为不可用,当然直接删除也是可以的。 * 【亮点二:容错】首先要分析出错的场景。正常来说,TCC 里面 T 阶段出错是没有关系的。比如说前面的那个例子里,数据处于初始化状态的时候,其实后续业务是用不了的,也就不会有问题。但是如果在 Confirm 的时候出错了,问题就比较严重了。比如说一部分业务已经将数据更新为可用了,另外一部分业务更新数据为可用失败,那么就会出现不一致的情况。只能考虑不断重试,确保在 Confirm 阶段都能提交成功。毫无疑问,不管怎么重试,最终都是有可能失败的,所以要做好监控和告警的机制。 * 补充重试失败了之后怎么办:[方案一]我在后面搞了一个离线比对数据并修复的方案,就是用来查找这种相关联的数据的,一部分数据还处于初始化状态,但是一部分数据已经处于可用状态,然后修复那部分初始化的数据。[方案二]在一些业务场景下,读请求是能够发现这种数据不一致的。那么它就会立刻丢弃这个数据,并且触发修复程序。 SAGA 事务 """"""""" * 核心思想就是把业务分成一个个步骤,当某一个步骤失败的时候,就反向补偿前面的步骤。 * 关键词是反向补偿。 * SAGA 的核心思想是反向补偿事务中已经成功的步骤。比如说某个业务,需要在数据库 1 和数据库 2 中都插入一条数据,那么在数据库 1 插入之后,数据库 2 插入失败,那么就要删除原本数据库 1 的数据。但是要注意,在最开始数据库 1 插入的时候,事务是已经被提交了的。 * 关键词是并发调度。 * 早期我设计过一个比较复杂的 SAGA 机制,它支持并发调度。也就是说如果整个分布式事务中有可以并发执行的步骤,那么就并发执行,在后续出错的时候,这些并发执行的步骤也可以并发反向补偿。 AT 事务 """"""" * AT 是指如果你操作很多个数据库,那么分布式事务中间件会帮你生成这些数据库操作的反向操作。 * AT 模式的核心是分布式事务中间件会帮你生成数据库的反向操作,比如说 INSERT 对应的就是 DELETE,UPDATE 对应的就是 UPDATE,DELETE 对应的就是 INSERT。这个机制有点类似于 undo log。 禁用跨库事务 """""""""""" * 在实践中,解决分库分表中的分布式事务问题,最简单的方式就是直接禁用跨库事务。跨库本身就是一个不好的实践。在分库分表之后我们要做的就是改造业务代码,确保不会出现跨库事务。 亮点方案:延迟事务 ^^^^^^^^^^^^^^^^^ * 默认情况下,我们使用的是延迟事务。在正常的情况下,当我们执行 Begin 的时候,其实并不知道后续事务里面的查询会命中哪些数据库和数据表,那么只有两个选择,要么 Begin 的时候在所有的分库上都开启事务。但是这会浪费一些资源,毕竟事务不太可能操作所有的库,因此才有了延迟事务。也就是在 Begin 的时候,分库分表中间件并没有真的开启事务,而是直到执行 SQL 的时候,才在目标数据库上开启事务。 * 举例来说,如果 SQL 命中了数据库 db_0,这个时候 db_0 还没有开始事务,那么就会直接开启事务,然后执行 SQL;如果又来了一个 SQL,再次命中了 db_0,此时 db_0 上已经开启了事务,因此直接使用已有的事务。在提交或者回滚的时候,就提交或者回滚所有开启的事务。不过提交或者回滚的时候,部分失败的问题比较难以解决。 * 部分失败并没有更好的解决办法。我们这里就是在 Commit 的时候,如果发现某个数据库失败了,那么会立刻发起重试。如果连续重试失败,就会触发告警,人工介入处理。 自动故障处理机制 """""""""""""""" * 在重试失败的时候,最开始我们公司就是告警,然后人手工介入处理。后来我改进了这个机制,引入了自动故障处理机制。也就是说如果一个事务里面部分数据库提交或者回滚失败,触发告警,然后自动故障处理机制就会根据告警的上下文来修复数据。 * 修复数据本身分成两种,一种是用已经提交的数据库的数据来修复没有提交成功的数据库的数据;另外一种则是用没有提交成功的数据库的数据来还原已经提交的数据库的数据。具体采用哪种,根据业务来决定。 * 在我引入这个机制之后,很多业务都接入了自己的自动修复逻辑,整体上数据出错之后的持续时间和出错本身的比率都大幅度下降了,系统可用性提升到了三个九。 * 实际上,这种自动修复的逻辑是跟业务强相关的,所以你可以提供一些简单的通用处理机制,但是如果比较复杂的话,就需要业务方来控制如何修复了。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/dHW4dz.png 19|分库分表无分库分表键查询:你按照买家分库分表, 那我卖家怎么查 -------------------------------------------------------------- 重试方案 ^^^^^^^^ * 重试次数:一般来说设置无限重试是没有多大意义的。如果一个东西重试很多次都没有成功,说明大概率永远没办法重试成功了。 * 重试间隔:无非是等间隔重试或者指数退避重试。指数退避重试是指重试间隔的时间是在增长的,一般是按照两倍增长。当然面试的时候你可以设计一些更加灵活的重试间隔策略,比如说最开始按照两倍增长,再按照 50% 增长,最后保持最大重试间隔不断重试。 * 是否允许跨进程重试:如果我本身在 A 进程里面触发了重试,但是在重试了一次之后,我是否可以在 B 进程上重试第二次?在分布式环境下,这往往意味着是否可以在不同的机器上重试。 准备 ^^^^ 引入中间表 """""""""" .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/KayvcR.png 在设计订单主键的时候,将买家 ID 编码放到了订单 ID 里面。再用中间表关联上专家 ID * 最大的缺陷是性能瓶颈 * 这个方案的一个重大缺陷是中间表就是性能瓶颈。如果中间表的数据只插入,不存在更新的话,主要就是读瓶颈,那么多加几个从库就可以了。但是如果中间表里面有一些列是需要频繁被更新的,那么中间表本身就扛不住写压力。但是本身中间表是不能分库分表的,因为分库分表之后你又面临同样的问题:你怎么知道该查询哪张中间表。 * 一般来说,在设计中间表的时候应该包含尽可能少的列,而且这些列的值应该尽可能不变,会频繁更新的列就不要放了。类似于订单 ID 这种 ID 类的基本不会变,那就可以随便放,而状态这种经常变更就还是不要放了。 * 中间表的一个缺陷就是表结构很固定,如果将来需要支持新的查询场景,那么就必须修改中间表的表结构,大多数情况下会是增加新的列。但是另外一方面,中间表本身往往又是一个大表,大表修改表结构是一个非常危险的事情。当然也可以考虑增加新的中间表,但都是治标不治本。中间表越多越难维护,数据一致性越难保证。 二次分库分表 """""""""""" * 二次分库分表指复制出来一份数据,然后尝试再进行分库分表。所以你的系统里面就会有两份数据,分别按照不同的分库分表规则存储。 * 原本订单表是按照买家的 ID 来进行的,但是这种情况下,卖家查询订单就很困难。比如说卖家查询自己当日成交的订单量,就难以支持。而且本身卖家查询订单也不能算是一个低频行为,所以我尝试把数据复制了一份出去,然后按照卖家 ID 进行分库分表。这种方案的主要缺陷就是数据一致性问题,以及数据复制一份需要很多存储空间。 使用其他中间件支持查询 """""""""""""""""""""" * 为了支持复杂多样的查询,可以尝试使用别的中间件,比如说 Elasticsearch。 广播 """" * 我们还有一些兜底措施,也就是如果一个查询确实没办法使用前面那些方案的时候,那就可以考虑使用广播。也就是说直接把所有的请求发送到所有的候选节点里面,然后收集到的数据就是查询的结果。不过这种方式的缺陷就是对数据库压力很大,很多数据库上的表根本不可能有数据,但是都会收到请求,白白浪费资源。尤其是如果这些查询还会触发锁,那么性能就会更差。 数据同步问题 """""""""""" * 一般来说有两种思路:: 1. 双写 2. binlog 亮点方案 ^^^^^^^^ * 在分库分表之后,为了充分满足不同情况下的查询需求,我们公司综合使用了三种方案:引入中间表、二次分库分表和 Elasticsearch。对于卖家查询来说,我们直接复制了一份数据,按照卖家 ID 分库分表。对于一些复杂的查询来说,就是利用 Elasticsearch。还有一些查询是通过建立中间表来满足,比如说商品 ID 和订单 ID 的映射关系。 * [同步方案]我们的数据同步方案是使用监听 binlog 的方案。买家库插入数据之后,就会同步一份到卖家库和 Elasticsearch 上。这个过程是有可能失败的,那么在失败之后会有重试机制,如果重试都失败了,那么就只能人手工介入处理了。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/tORqvB.png * 我们是允许卖家直接修改数据的,所以实际上我们卖家库的修改也会同步到其他数据源。因为卖家和买家都可能同时修改各自的库。这里我举一个订单状态修改的例子。 * 如果买家发起取消订单,然后卖家那边要把状态修改成已发货。那么可能出现买家先修改,然后被卖家覆盖的情况,结果就是两边都是已发货;也有可能出现卖家先修改,然后被买家覆盖的情况,那么结果就是两边都是已取消。 * 所以类似的场景最好是采用分布式锁和双写方案。比如买家修改状态的时候,要先拿到分布式锁,然后同时修改买家库和卖家库。当然,要是覆盖数据也没关系,那么就还是可以继续采用 Canal 的同步方案。 * 所以综合来看,允许卖家直接修改卖家库是比较危险的事情,数据一致性问题更加严重。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/pGuxVr.png * 也可以考虑只允许从买家库进去修改数据,也就是说,不允许直接修改卖家库的数据。举个例子,如果卖家想要修改某个订单的数据,那么他需要在卖家库查到订单的信息,但是在修改的时候要拿着订单信息去买家库修改。 * 这种做法最大的优点就是简单,没有那么多数据同步和数据一致性方面的问题。缺点就是性能比较差,而且写压力始终都在买家库上。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/bqaqf5.png 20|分库分表容量预估:分库分表的时候怎么计算需要多少个库多少个表 -------------------------------------------------------------- 基本思路 ^^^^^^^^ 为什么分库分表 """""""""""""" * 性能瓶颈。 * 一句话总结分库分表,那就是数据库本身出现了性能问题,而且这些性能问题已经没办法通过 SQL 优化、索引优化之类的手段来解决了。 引起性能瓶颈的角度 ++++++++++++++++++ * 从硬件资源、并发、数据量三个引起性能瓶颈的角度去分析。 * 通常在分库分表之前应该优先考虑分区表和读写分离,因为这两种方案和分库分表比起来都更简单、好维护。 * 如果是数据库本身硬件资源不足,那么不管是分区表还是读写分离都难以解决问题。比如说数据库网络带宽不够了,这种情况下分区表肯定解决不了;而如果是写操作引发的网络带宽不够,那么读写分离增加从库也解决不了。 * 如果是并发引起的问题,那么分区表和读写分离也不太能解决。比如说表锁,读写分离是没办法解决的,你就算增加 100 个从库,表锁都还是在主库上。分区表虽然因为有分区,可以减少并发竞争,但是如果某一个特定分区上已经遇到写瓶颈了,那么分区表也没用。 * 如果是单纯因为数据量过大而引起的性能瓶颈,读写分离也不太能够解决。举个例子来说,如果一个表有几千亿条数据,那么显然无论怎么加从库,单一一个查询都会很慢。如果数据量极大,以至于连索引都无法放入内存,此时查询性能极差。 简洁版 ++++++ * 对于写瓶颈来说,分区表可以缓解问题,而读写分离几乎没有效果,比如频繁地增删改操作。 * 对于硬件瓶颈来说,读写分离、分区表基本上也解决不了,比如写操作引发的网络带宽问题。 表达 ++++ * 我们这个业务数据库逼不得已要分库分表的原因就是主库现在已经不堪重负,可以认为 TPS 已经触及到硬件天花板了。虽然理论上我们还是可以购买更强的服务器来部署数据库实例,但是实在太贵了,而且业务增长也还是会触及新机器的性能瓶颈,那么索性分库分表一步到位。我们还准备了其他主从集群,将分库之后的数据库分散在不同的主从集群上,或者说分了数据源。 * 分库分表和读写分离、分区表都不是互斥的,可以结合在一起使用。 * 在日常实践中总结出来的很多表在这么一个量级下就会出现性能瓶颈。所以归根结底要不要分库分表,只需要看有没有性能瓶颈。 * 而且但凡性能瓶颈可以用分区表或者读写分离解决,就不要着急使用分库分表。当然,如果用得起 Oracle 数据库,那就根本不需要分库分表了。 分库还是分表 """""""""""" * 如果是数据库本身的硬件资源引起的性能瓶颈,那么就要分数据源。一句话来说,就是你得有更多的主从集群。 * 如果是逻辑数据库引起的性能瓶颈,那么你就只需要在逻辑数据库这个层面进一步分库就可以了。 * 如果是单表数据量过大、锁竞争等跟表维度相关的资源引发的性能问题,那么分表就可以了。 容量估算 ^^^^^^^^ * 分库分表容量确定需要依据两点:你现在有多少数据、你将来有多少数据。 * 数据的增长趋势只需要根据公司的战略规划来就可以。比如说今年公司的目标是业务翻倍,那么就可以认为今年数据的增长率是 100%。就算公司没有发布这一类的规划,但是产品经理肯定是背着 KPI 的,问一下他们也就知道了。不过正常来说,一家公司都是有三五年规划的,照着规划来预估容量就可以了。 * 正常来说,预估容量需要考虑未来三年的数据增长情况,只需要确保三年内不会触发扩容就可以。但是三年也不是一个硬性标准,比如说有些公司比较害怕扩容,那么可能直接预估了五年、十年的容量。 流量复制与重放 ^^^^^^^^^^^^^^ * 方案的原理很简单,就是在保持以源表为准的双写阶段,录制线上的 HTTP 请求,然后再异步重放。拿到原本 HTTP 请求的响应和重放的响应,做一个比较。 * 在数据校验上,最开始的时候我设计过一个利用流量录制和重放来做数据校验的方案,真正从业务逻辑上校验数据的准确性、完整性和一致性。整体思路是在 HTTP 入口处引入一个流量复制组件。当有读请求过来的时候,就会把请求整体录制下来,然后异步地重放请求,再把重放请求的响应和原始响应进行对比,判断数据迁移有没有出错。 * 流量复制和重放一般来说就是使用 tcpcopy 或者 goreplay。 去除 HTTPS """""""""" * 我这个方案是准备在 Nginx 后面接入流量复制。用户和 Nginx 之间是 HTTPS 通信,但是 Nginx 和后面的服务器之间是 HTTP 通信,所以就可以避开 HTTPS 的问题。 并发问题 """""""" * 虽然这里有可能出现假阳性的问题,不过不足为惧,因为本身数据是一致的,而且假阳性很少出现,因为我们的业务就是一个读多写少的场景。并且流量也不打算 100% 复制,只是小比例复制流量就可以了。 * 这个流量复制与重放算是一个非常高级也非常难做好的东西 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/rZvupq.png 评论 ^^^^ * 网络上有一种说法是超过 2000 万行数据就要分表了,你知道这个 2000 万是怎么来的吗? * 增加索引深度。MySQL 默认是 16K 的页面,抛开它的配置 header,大概就是 15K,因此,非叶子节点(主键按照8byte,地址是6byte)的索引页面可放 ``15*1024/(8+6)=1170`` 条数据,按照每行 1K 计算,每个叶子节点可以存 15 条数据。同理,三层就是 ``15*1170*1170=2053,3500`` 条数据。只有数据量达到 20533500 条时,深度才会增加为 4 21| 数据库综合应用: 怎么保证数据库的高可用,高性能 ------------------------------------------------- 整体方案 ^^^^^^^^ * 我擅长数据库,包括查询优化、MySQL 和 InnoDB 引擎优化,熟练掌握 MySQL 高可用和高性能方案。 * 我在数据库方面有比较多的积累,比如说我长期负责公司的查询优化,提高 MySQL 的可用性和性能。也在公司推动过读写分离和分库分表,实践经验丰富。 * 这个项目是我们公司的核心业务,我主要负责性能优化和提高系统可用性。在数据库上,我通过查询优化、参数优化和读写分离,提高了 20% 的查询性能。同时参与了一个核心业务数据库的分库分表,主要负责的是数据迁移和主键生成部分。 参数调优 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/10/XoJxqQ.png innodb_flush_log_at_trx_commit 这个参数非常重要,你一定要记住(详情参见上面) innodb_buffer_pool_size """"""""""""""""""""""" * 简单说就是 innodb 缓冲池的大小,用于缓存表和索引,也包括插入数据缓冲,增加这个值可以减少磁盘 IO。 * 最开始我会想到调整 innodb_buffer_pool_size 是因为我发现数据库上的 swap 非常高。经过排查我发现是因为 innodb_buffer_pool_size 设置得偏大了。在内存不足的时候,操作系统就会触发 swap。 * 解决思路自然是调小一点,但是这样做要小心对业务的影响。实际上 innodb_buffer_pool_size 是逐步调整的,最后调整到原本数值的 70%,swap 就大幅减少了,而且查询性能也没什么变化。 innodb_buffer_pool_instances """""""""""""""""""""""""""" * MySQL InnoDB 引擎内部默认只有一个缓冲池,所有查询都访问它,那么并发竞争会十分厉害。在内存足够的情况下,可以考虑启动多个缓冲池实例,具体多少个就由 innodb_buffer_pool_instances 这个参数指定。 * MySQL 有一些额外的限制,它要求在 innodb_buffer_pool_instances 大于 1 的情况下,innodb_buffer_pool_size 不能小于 1G。一般我建议在 8G 以内设置成 2 就可以,如果大于 8G 那么可以设置成 4。 * 在解决了 innodb_buffer_pool_size 的 Bug 之后,我负责的系统数据库的相关设置,又发现了一个问题。我们有一个核心数据库,innodb_buffer_pool_size 超过了 8G,但是 innodb_buffer_pool_instances 居然还保持着 1,这显然是不合理的。所以这个我就把它调整成了 4,减少数据库 buffer pool 的并发竞争。 query_cache_min_res_unit """""""""""""""""""""""" * 第一个角度是你认为查询缓存效果不好,所以你关掉了。 * 我们公司有一个数据库,用的是比较古老的 MySQL 版本。在这个版本上开启了查询缓存。但是实际上效果不太好,因为这里存储的数据其实经常变动,所以缓存命中率一直很低。我索性就关掉了这个查询缓存,后来查询性能也基本没有什么损失。 * 第二个角度是你赞同使用查询缓存。 * 有一个关键查询,这个查询比较复杂,执行的时候会非常慢。但是我注意到这个查询对应的数据是很少变动的,于是我尝试开启了查询缓存。果然开启缓存之后,这个查询大部分情况下都命中了缓存,性能得到了很大的提升。 * 调整了查询缓存的相关参数 * 有一个数据库是开启了查询缓存的,但是 query_cache_min_res_unit 一直使用的是默认值 4KB。后来我仔细评估了一下相关业务的查询结果集大小,4KB 显然太大了,浪费了很多内存。所以我后面把它调整成了 1KB,还是能够满足大多数查询的需求。这样就能缓存更多查询的结果集,查询性能得到了提升。 * 为什么 MySQL 8.0 移除了这个功能。答案也很简单,因为缓存功能适合那种耗时并且重复执行的查询,而实际上这一类的查询并不多。另外一个理由是,数据库本身就容易成为性能瓶颈,那么完全可以让应用自己去做缓存,减轻数据库的负担。 读写分离 ^^^^^^^^ * 最开始我进公司的时候,就发现他们居然连读写分离都还没做,包括我们的核心数据库都没有,而且当我去看观测数据的时候就感觉核心数据库已经快要触及性能瓶颈了。于是我就在公司里面引入了从库。虽然只是准备了一个从库,但是大部分读请求落到从库上,主库的压力就小多了。引入读写分离机制,一方面可以提高了数据库的可用性,另一方面也提高了查询的性能。 简要步骤:: 1. 准备一个从库。 2. 改造业务,允许业务动态切换读主库还是读从库。 3. 切换到读从库,看看是否有问题,如果有问题就立刻回滚。 * 关键词:主从延迟问题。 * 单库引入读写分离,并不是特别复杂。不过这个过程中要小心主从延迟问题。比如说原本有一个业务是在更新之后立刻读数据,那么就会读到更新后的值。但是如果修改成读从库,就可能还是会读到更新前的值,导致业务出错。在改造业务的过程中要小心这种场景。 * 云服务本身就提供了这种自动切换功能,所以接入了一下。 分库分表 ^^^^^^^^ 分库分表方案时,要包含以下几个方面:: 1. 为什么要分库分表? 2. 分库分表中间件选型 3. 容量规划 4. 数据迁移 5. 分库分表键选择 6. 主键生成策略,并且要能解释清楚这种决策的理由。 7. 分库分表之后的事务问题 8. 分库分表中一些特殊查询的处理,最主要的就是分页查询 * 我进我们公司的时候,刚好遇上了数据库性能瓶颈,所以我实际参与了我们公司的核心数据库的分库分表,我主要负责的是分库分表中的数据迁移和主键生成部分。 * 这里说数据迁移和主键生成,是因为单纯从技术上来说它们俩更有竞争优势。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/AMTwN9.png 模拟面试| 数据库面试思路一图懂 ------------------------------ 10| 数据库索引 ^^^^^^^^^^^^^^ * 什么是覆盖索引? * 什么是聚簇索引 / 非聚簇索引? * 什么是哈希索引?MySQL InnoDB 引擎怎么创建一个哈希索引? * 什么回表?如何避免回表? * 树的高度和查询性能是什么关系? * 什么是索引最左匹配原则? * 范围查询、Like 之类的查询怎么影响数据库使用索引? * 索引是不是越多越好? * 使用索引有什么代价? * 如何选择合适的索引列?组合索引里面怎么确定列的顺序?状态类的列是否适合作为索引的列? * 为什么 MySQL 使用 B+ 树作为索引的数据结构?为什么不用 B 树?为什么不用红黑树?为什么不用二叉平衡树?为什么不用跳表? * NULL 对索引有什么影响? * 唯一索引是否允许多个 NULL 值? 11| SQL 优化: 如何发现 SQL 中的问题 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 请你解释一下 EXPALIN 命令。 * 你有优化过 SQL 吗?具体是怎么优化的? * 你有没有优化过索引?怎么优化的? * 怎么优化 COUNT 查询? * 怎么优化 ORDER BY? * 怎么优化 LIMIT OFFSET 查询? * 为什么要尽量把条件写到 WHERE 而不是写到 HAVING 里面? * 怎么给一张表添加新的索引 / 修改表结构?如果我的数据量很大呢? * USE INDEX/FORCE INDEX/IGNORE INDEX 有什么效果? 12| 数据库锁: 明明有行锁,怎么突然就加了表锁 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 什么是行锁、表锁?什么时候加表锁?怎么避免? * 什么是乐观锁?怎么在 MySQL 里面实现一个乐观锁? * 什么是意向锁?可以举一个例子吗? * 什么是共享锁和排它锁?它们有什么特性? * 什么是两阶段加锁? * 什么是记录锁、间隙锁和临键锁? * RC 级别有间隙锁和临键锁吗? * MySQL 是怎么在 RR 级别下解决幻读的? * 什么情况下会加临键锁?什么情况下会加间隙锁?什么时候加记录锁? * 唯一索引和普通索引会怎么影响锁? * 你遇到过什么死锁问题吗?怎么排查的?最终又是怎么解决的? * 你有没有优化过锁?怎么优化的? 13| MVCC 协议: MySQL 修改数据时,还能不能读到这条数据 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 什么是 MVCC?为什么需要 MVCC? * 什么是隔离级别?隔离级别有哪几种? * 什么是脏读、不可重复读、幻读?它们与隔离级别的关系是怎样的? * 隔离级别是不是越高越好? * 你们公司用的是什么隔离级别?为什么使用这个隔离级别?能不能使用别的隔离级别? * 你有没有改过隔离级别?为什么改? 14| 数据库事务: 事务提交了, 你的数据就一定不会丢吗 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 什么是 undo log?为什么需要 undo log? * 什么是 redo log?为什么需要 redo log? * 什么是 binlog?它有几种模式?用来做什么? * 事务是如何执行的? * 什么是 ACID?隔离性和隔离级别是什么关系?你觉得哪个隔离级别满足这里的隔离性要求? * redo log 的刷盘时机有哪些?该如何选择?你们公司用的是哪个配置?为什么用这个配置? * binlog 的刷盘时机有哪些?该如何选择?你们公司用的是哪个配置?为什么用这个配置? * 我的事务提交了,就一定不会丢吗?怎么确保一定不会丢? * 什么是 page cache?为什么不直接写到磁盘? * 在分布式环境下,当服务器告诉我写入成功的时候,一定写入成功了吗?如果服务器宕机了了可能发生什么? 15| 数据迁移: 如何在不停机的情况下保证迁移数据的一致性 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你们单库拆分的时候是如何做数据迁移的 / 你们修改大表结构的时候是怎么做数据迁移的?怎么在保持应用不停机的情况下做数据迁移? * 什么是双写?为什么要引入双写? * 如果双写的过程中,有一边写失败了,怎么办? * 你可以用本地事务来保证双写要么都成功,要么都失败吗?分布式事务呢? * 为什么有一个阶段是双写,但是以目标表为准?干嘛不直接切换到单写目标表? * 你们有什么容错方案?比如说如果在迁移过程中出错了,你们的应用会怎么办? * 你们是怎么校验数据的? * 增量数据校验你们是怎么做的? * 数据迁移你能够做到数据绝对不出错吗? * 如果数据出错了你们怎么修复?怎么避免并发问题? * 让你迁移一个 2000 万行的表,你的方案大概要多久? * 你用过 mysqldump/XtraBackup 吗?它有什么缺点? 16|分库分表主键生成: 如何设计一个主键生成算法 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你们分库分表怎么生成主键的? * 使用 UUID/ 数据库自增 / 雪花算法有什么优缺点? * 雪花算法是如何实现的? * 雪花算法是怎么做到全局唯一的? * 怎么解决雪花算法的序列号耗尽问题? * 怎么解决雪花算法的数据堆积问题? * 你有没有优化过主键生成的性能?怎么优化的?效果如何? * 你的主键生成的 ID 是严格递增的吗?不是递增有什么问题? * 为什么我们一般使用自增主键? * 什么是页分裂?有什么缺点? 17|分库分表分页查询: 为什么你的分页查询又慢又耗费内存 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你们公司是怎么解决分页查询的?平均查询性能如何? * 为什么分页查询那么慢? * 全局查询有什么优缺点?对于一个查询 LIMIT X OFFSET Y 来说,如果我命中了三张表,会取来多少数据? * 怎么提高分页查询的速度? * 什么是二次查询?它的步骤是什么样的? * 怎么在二次查询里面计算全局的偏移量? * 二次查询有什么优缺点? * 代理形态的分库分表中间件有什么优缺点?怎么解决或者改进它的缺点? * 使用中间表来进行分库分表,有什么优缺点?怎么设计中间表? * 在使用中间表的时候,你怎么保证数据一致性?你能保证强一致吗?如果不能,不一致的时间最差是多久? * 你们公司有没有考虑使用别的中间件来解决分页查询?你选择哪一个?为什么? 18|分库分表事务: 如何同时保证分库分表, ACID 及高性能 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你们公司在分库分表之后,如何解决事务问题? * 什么是两阶段提交协议?有什么缺点? * 什么是三阶段提交协议?相比两阶段,改进点在哪里? * 什么是 XA 事务? * 你觉得 XA 事务是否满足 ACID?为什么? * 什么是 TCC? * 什么是 SAGA? * 什么是 AT 事务? * 什么是延迟事务?延迟事务失败了怎么办?为什么分库分表中间件喜欢用延迟事务? * 你们公司是否允许跨库事务?为什么?有什么场景是必须要使用跨库事务的? 19|分库分表无分库分表键查询: 按买家分库分表,卖家怎么查 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你们公司的分库分表是怎么分的?一般情况下怎么选择分库分表键? * 假设说现在我的订单表是按照买家 ID 来分库分表的,现在我卖家要查询,怎么办? * 利用中间表来支持无分库分表键查询的时候,怎么设计中间表? * 为什么在买家分库分表的时候,按照 4832,但是同样的数据,按照卖家分库分表的时候,就只需要按照 2816? * 广播有什么缺点? * 可以使用什么中间件来支持复杂查询?你们公司用了什么? 20|分库分表容量预估: 分库分表时怎么计算所需库,表的数量 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你是怎么估计容量的?考虑了什么因素? * 你怎么知道数据未来增长会有多快? * 你这容量是预估了几年的数据量?为什么? * 你是怎么利用流量录制和重放来验证数据的? * 在流量录制之后,重放之前,如果数据修改了,你的数据校验还能正常运行吗? * 你公司用的是 HTTPS 协议吗?使用 HTTPS 协议你怎么录制流量? * 为什么大家都喜欢用 2 的幂来作为容量? * 怎么扩容?有哪些步骤? * 如果你发现之前分库分表分太多了,能不能缩容?假如要你缩容,你怎么办? 21|数据库综合应用: 怎么保证数据库高可用,高性能 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你们有没有做过数据库优化?有没有做过 InnoDB 引擎优化? * 你调过什么数据库相关的参数?为什么要调? * InnoDB 引擎的 buffer pool 是拿来做什么的?怎么优化它的性能? * buffer pool 是不是越大越好?过大或者过小都有什么问题?怎么确定合适的大小? * 数据库里面有很多刷盘相关的参数,你都了解吗?调过吗?根据什么来调? * 你有没有做过主从分离?主从延迟是什么?怎么解决主从延迟? * 你们公司的数据库主节点宕机了会发生什么? * 什么是查询缓存?你们公司有没有用查询缓存? 数据库面试一图懂 ^^^^^^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/mYl2yn.png 消息队列 (10讲) =============== 22| 消息队列: 消息队列可以用来解决什么问题 ------------------------------------------ 前置知识 ^^^^^^^^ * 消息队列最鲜明的特性是: 异步、削峰、解耦。 * 两个场景:日志处理和消息通讯。 准备 ^^^^ * 你们公司有没有使用消息队列?主要用于解决什么场景的问题? * 如果使用了消息队列,那么在具体的场景下不使用消息队列是否可行?和使用消息队列的方案比起来,有什么优缺点? * 你们公司用的是什么消息队列,它有什么优缺点? 基本思路 ^^^^^^^^ 秒杀场景 """""""" .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/3IrhKx.png * 关键词是轻重之分。 * 消息队列还经常被用在秒杀场景里面。最基本的架构是秒杀请求进来之后,会有一个轻量级的服务。这个服务就是做一些限流、请求校验和库存扣减的事情。这些事情差不多都是内存操作,最多操作 Redis。当库存扣减成功之后,就会把秒杀请求丢到一个消息队列。 * 然后订单服务会从消息队列里面将请求拿出来,真正创建订单,并且提示用户支付。这一部分就是重量级的操作,无法支撑大规模并发。所以,在这个场景里面可以把消息队列看作是一个轻重操作的分界线。 订单超时取消 """""""""""" * 消息队列也可以用于订单超时取消这种场景。在这种场景下,我们可以准备一个延时队列,比如说超时时间是 30 分钟,那么延时也是 30 分钟。 * 但是消费的时候要小心并发问题,就是在 30 分钟这一个时刻,一边用户支付,一边消费者也消费超时消息,就会有并发问题。解决思路有很多,可以使用分布式锁、乐观锁,也可以使用 SELECT FOR UPDATE 锁住订单,防止并发操作。 亮点方案 ^^^^^^^^ 为什么一定要使用消息队列 """""""""""""""""""""""" * 【性能差】同步调用方案相比引入消息队列有三个缺陷,分别是性能差、可扩展性差和可用性差。并发调用相比于使用消息队列,性能差。在并发调用的情况下,性能取决于最坏的那个同步调用什么时候返回结果。而正常我们丢一个消息到消息中间件上是很快的。并且,即便并发调用的性能损耗是可以接受的,但是扩展性和可用性还是解决不了。 * 【扩展性】在使用消息队列的情况下,消息发送者完全不关心谁会去消费这些消息。同样地,如果有一个新的业务方要订阅这个消息,它可以自主完成。而同步调用的时候,上游必须知道下游的接口,然后要知道如何构造请求、如何解析响应,还要联调、测试、上线,整个过程都得和下游密切合作,因此效率特别低,可扩展性很差。 * 【可用性】在同步调用方案中,你必须要确保调用所有的下游都成功了才算是成功了。所以你还需要额外考虑部分成功部分失败的问题。所以相比使用消息队列的方案,同步调用的方案更加容易出错,并且容错也更难。 事件驱动 """""""" * 事件驱动(Event-Driven)可以说是一种软件开发模式,也可以看作是一种架构。它的核心思想是通过把系统看作一系列事件的处理过程,来实现对系统的优化和重构。 * 优点十分明显。 * 低耦合性:各个组件只依赖消息队列,组件之间通过消息的定义间接地耦合在一起。换句话来说,组件只需要知道消息的定义,并不需要知道发送消息的组件是哪个。 * 可扩展性:事件驱动的应用程序具有很强的扩展性,可以通过添加新的事件处理程序、组件等来实现系统的扩展和升级。 * 高可用:可以充分利用消息队列的可靠性、可重复消费等特性,来保证消息发送、消费高可用,从而保证整个系统的高可用。 * 事件驱动适合用来解决一些复杂、步骤繁多、流程冗长的业务问题。 * 事件驱动结合 SAGA 分布式事务的方案。 * 之前我们公司用事件驱动实现了 SAGA 的分布式事务解决方案。基于事件驱动的 SAGA 模式就是在每一个步骤结束之后发送事件,不同的步骤会发送一个或者多个事件。然后消费者消费了消息之后,就开始执行下一个步骤。比如说在更新 DB 再更新缓存的场景里就可以这样用。这种形态和一般的事务比起来,优势是低耦合、高扩展、高可用。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/dYQMz0.png 23| 延迟消息: 怎么在 Kafka 上支持延迟消息 ----------------------------------------- * 【场景】30min超时未支付取消订单 * 最简单的做法就是利用定时任务,最好是解决了持久化的分布式任务平台。那么业务发送者就相当于注册一个任务,这个任务就是在 30 分钟之后发送一条消息到 Kafka 上。之后业务消费者就能够消费了。这个方案的最大缺点是支撑不住高并发。这是因为绝大多数定时任务中间件都没办法支撑住高并发、大数据的定时任务调度,所以只有应用规模小,延迟消息也不多的话,才可以考虑使用这个方案。如果想要支持高并发、大数据的延迟方案,还是要考虑利用消息队列。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/h8zTVm.png * 随机延迟支持 * 可以用DB做队列,写入时增加字段(取出时间=写入时间+延时时间),每次取出当前时间对应的取出时间的数据列表 24| 消息顺序: 保证消息有序, 一个 topic 只能有一个 partition 吗 -------------------------------------------------------------- * 三个面试热点:有序消息和消息不丢失、消息重复消费 * Kafka 保证同一topic同一分区内的消息顺序,但不保证不同分区之间的顺序。 * 要保证消息全局有序,最简单的做法就是让特定的 topic 只有一个分区。这样所有的消息都发到同一个分区上,那么自然就是有序的。这种只有一个分区的方案性能差,没办法支撑高并发。 * 如果业务场景要求的都不是全局有序,而是业务内有序。有两个改进方案可以考虑。一个是异步消费,还有一个是多分区方案。 * 【异步消费】消费者线程从 Kafka 里获取消息,然后转发到内存队列里面。在转发的时候,要把同一个业务的消息转发到同一个队列里面。这种做法的缺陷就是存在消息未消费的问题。也就是消费线程取出来了,转发到队列之后,工作线程还没来得及处理,消费者整体就宕机了,那么这些消息就存在丢失的可能。 * 【多分区】只需要确保同一个业务的消息发送到同一个分区就可以保证同一个业务的消息是有序的。但是缺点有两个,一个是数据不均匀,另一个是增加分区可能导致消息失序。 基于优化的面试思路 ^^^^^^^^^^^^^^^^^^ * 最开始我进公司的时候就遇到了一个 Kafka 的线上故障。我司有一个业务需要用到有序消息,所以最开始的设计就是对应的 topic 只有一个分区,从而保证了消息有序。 * 可是随着业务增长,一个分区很快就遇到了性能瓶颈。只有一个分区,也就意味着只有一个消费者,所以在业务增长之后,就开始出现了消息积压。另外一方面,这个分区所在的 broker 的负载也明显比其他服务器要大,偶尔也会有一些性能抖动的问题。 * 后来我仔细看了我们的业务,实际上,我们的业务要求的不是全局有序,而是业务内有序。 * 换句话来说,不一定非得用一个分区,而是可以考虑使用多个分区。所以我就给这个 topic 增加了几个分区,同时也增加了消费者。优化完之后,到目前为止,还没有出现过消息积压的问题。 * 当然,为了避免在单分区增加到多分区的时候,出现消息失序的问题,我用了一个很简单的方案,就是对应的消费者在启动之后,并没有立刻消费,而是停顿了一分钟,把积压的消息消费掉,从而避免了潜在的消息失序问题。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/VZAted.png 评论 ^^^^ * 消息重试这个怎么保证有序?老大难的问题。实践中直接告警(就是在真的发现消息失序的时候,告警,人手工微调一下就可以。可用性高的话,你可能一个月才有一个两个这种失序的问题) 25| 消息积压: 业务突然增长, 导致消息消费不过来怎么办 ---------------------------------------------------- * 消息积压首先要看是临时性积压还是永久性积压。临时性积压是指突如其来的流量,导致消费者一时半会跟不上。而永久性积压则是指消费者的消费速率本身就跟不上生产速率。 * 如果是临时性积压,并且评估最终消费者处理完积压消息的时间是自己能够接受的,那么就不需要解决。比如说偶发性的消息积压,需要半个小时才能处理完毕,而我完全等得起半小时,就不需要处理。 * 但要是接受不了,又或者是永久性积压。就要尝试解决了。最简单的办法就是增加消费者,增加到和分区数量一样。不过我想大部分人在遇到消息积压问题的时候,消费者数量都已经和分区数量一样了。 这种情况消息积压的解决方案:: 1. 最简单的做法就是增加分区 2. 如果公司不允许增加分区的话,那么可以考虑直接创建一个更多分区的新 topic 3. 优化消费者性能 4. 消费者降级(前提是你的业务能接受有损消费消息) 5. 异步消费(取出数据后写入内存队列,一个线程池来做真正消费。本质是提升消费者速率) * 要确定新的分区数量的最简单的做法就是用平均生产者速率除以单一消费者的消费速率。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/aR90bP.png 26| 消息不丢失: 生产者收到写入成功响应后消息一定不会丢失吗 ---------------------------------------------------------- 写入语义 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/SiVvDN.png 0:就是所谓的 “fire and forget”,意思就是发送之后就不管了,也就是说 broker 是否收到,收到之后是否持久化,是否进行了主从同步,全都不管。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/3jpaVD.png 1:当主分区写入成功的时候,就认为已经发送成功了。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/HorXfb.png all:不仅写入了主分区,还同步给了所有 ISR 成员。 ISR ^^^ * ISR(In-Sync Replicas)是指和主分区保持了主从同步的所有从分区。 * 在我的核心业务里面,关键点是消息是不能丢失的。也就是说,发送者在完成业务之后,一定要把消息发送出去,而消费者也一定要消费这个消息。所以,我在发送方、消息队列本身以及消费者三个方面,都做了很多事情来保证消息绝对不丢失。 消息队列不丢失 ^^^^^^^^^^^^^^ * 在关键业务上,我一般都是把 acks 设置成 all 并且禁用 unclean 选举,来确保消息发送到了消息队列上,而且不会丢失。同时 log.flush.interval.messages、log.flush.interval.ms 和 log.flush.scheduler.interval.ms 三个参数会影响刷盘,这三个值我们公司设置的是 10000、2000、3000。 * 理论上来说,这种配置确实还是有一点消息丢失的可能,但是概率已经非常低了。只有一种可能,就是消息队列完成主从同步之后,主分区和 ISR 的从分区都没来得及刷盘就崩溃了,才会丢失消息。这个时候真丢失了消息,就只能人手工补发了。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/tmr6Q8.png 27| 重复消费: 高并发场景下怎么保证消息不会重复消费 -------------------------------------------------- 布隆过滤器 ^^^^^^^^^^ * 布隆过滤器(Bloom Filter)是一种数据结构,它可以用于检索一个元素是否在一个集合里。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是存在假阳性的问题,还有对于删除操作不是很友好。 28| 架构设计: 如果让你设计一个消息队列, 你会怎么设计它的架构 ------------------------------------------------------------ 准备 ^^^^ * 基于内存的消息队列,一般用于进程内的事件驱动,又或者用于替代真实的消息队列参与测试。 * 基于 TCP 的消息队列。这种消息队列是指生产者直接和消费者连在一起,没有 broker。生产者会直接把消息发给消费者。 * 基于本地文件的消息队列,也就是生产者直接把消息写入到本地文件,消费者直接从本地文件中读取。 :: Kafka 为什么要引入 topic? Kafka 为什么要引入分区?只有 topic 行不行? Kafka 为什么要强调把 topic 的分区分散在不同的 broker 上? Kafka 为什么要引入消费者组概念?只有消费者行不行? 思路 ^^^^ * 目前主流的消息队列已经很强大了,所以可以考虑参考它们的设计。现在的消息队列都有几个概念:生产者、消费者、Broker 和 topic。这里我以 MySQL 为例,讲述怎么在 MySQL 的基础上封装一个消息队列。 topic 设计 """""""""" * 首先我也会保留 topic 和分区的设计。现代的消息队列基本上都有类似的结构,只是名字可能不一样,有些叫分区 Partition,有些叫队列 Queue。 * [Kafka 设计的精髓]topic 是必不可少,因为它代表的是不同的业务。然后面临的选择就是要不要在 topic 内部进一步划分分区。假如说不划分分区的话,有一个很大的缺点,就是并发竞争,比如说所有的生产者都要竞争同一把锁才能写入到 topic,消费者要读取数据也必须竞争同一把锁才能读取数据,这样性能很差。所以 topic 内部肯定要进一步细分,因此需要引入分区的设计。 * [一个 topic 对应 MySQL 一个逻辑表]结合 MySQL 的特性,最基础的设计就是一个 topic 是一个逻辑表,而对应的分区就是对这个逻辑表执行分库分表之后得到的物理表。举个例子,假如说有一个叫做 create_order 的 topic,那么就代表我有一个逻辑上叫做 create_order 的表。如果这个 topic 有 3 个分区,那么就代表我有 create_order_0、create_order_1 和 create_order_2 三张物理表。这种情况下,每一张表都可以用自增主键,这个自增主键就对应于 Kafka 中的偏移量。 * [不采用所有 topic 都共用一张逻辑表的原因]有两方面的原因。首先是一张表,难以应付大数据与高并发的场景,即便分库分表,也要分出来几千张表,实在犯不着;其次 topic 天然就是业务隔离的,因此让不同 topic 用不同的表,那么相互之间就没有影响了。 * 而且使用不同的表,能更好地安排不同的数据库实例来存储。 broker 与消息存储 """"""""""""""""" * 为了进一步保证可用性,同一个 topic 的不同分区最好分散在不同的 broker 上存储。这样即便某一个 broker 崩溃了,这个 topic 也最多只有一个分区受到了影响。 * 要想提高可用性,最好的策略就是把分区分散在不同的主从集群上。比如说有四个分区,那么可以四个分区分别在四个不同的主从集群上。优点是尽量分散了流量,并且不同的主从集群之间互不影响。 * 主从集群就意味着每一个分区在从库都有对应的一份数据。举个例子来说,如果是一主两从,那么就意味着每一个分区都有一个主分区和两个从分区。使用 MySQL 的主从机制也意味着我们不需要管理主从选举的问题,这样能够很大程度上减轻落地的难度。 发送消息 """""""" * 就生产者来说,它应该主动推送消息到 broker 上,因为消息产生速率完全跟 broker 没有关系,让 broker 来主动拉取的话,broker 不好控制拉的频率和拉的数量。 * [批量发送]为了优化发送性能,可以支持批量发送功能。也就是说,生产者可以考虑凑够一个批次之后再发送。这个批次大小可以让生产者来控制。当然生产者也要考虑兜底措施,也就是说如果在一段时间之内,没有凑够一批数据也要发送,防止消息长时间停留在生产者内存里面,出现消息丢失的问题。Kafka 有类似的机制,比如说生产者可以通过 linger.ms 来控制生产者最终等待多长时间。 * [直接插入消息]在 MySQL 的实现里面,消息最终是存储在数据库里面,你既可以通过 broker 来访问这个数据库,也可以让生产者直接访问这个数据库。如果要追求极致的性能,那么可以考虑让生产者直接把消息插入到数据库里。生产者需要引入一个本地依赖,本地依赖会根据消息的 topic、分区找到对应的数据库配置,初始化连接池。而发送消息就是调用本地依赖的本地方法,这个方法会执行一个 INSERT 语句,插入消息。这样做的好处就是省略了一次网络中间通信。同样地,也可以使用批量插入来进一步提高性能。在消费者端也可以采用这样的措施。 消费消息 """""""" * 在消费消息的时候,同样需要引入消费者组和消费者的概念。一个业务方就是一个消费者组,一个消费者组里面可以有多个消费者。在 Kafka 里面,每一个分区有一个消费者,但是一个消费者可以消费多个分区。那么我这里也保留了这种设计,每一个分区是一张表,这张表对于一个消费者组来说,只能有一个消费者读取消息。 * 因为消费者才知道自己的消费速率,所以消费者这边用拉模型,例如每 1s 从消息队列中拉取 50 条消息。 记录偏移量 ++++++++++ * 为了存储每一个消费者消费的偏移量,我需要一张新的表。比如说在 create_order 这个例子里面,有一张表叫做 create_order_consumers。这张表有三个关键列:消费者组名字(consumer_group)、分区(partition)和偏移量(commited_offset)。而每次消费者提交消息,对于 borker 来说就是更新这张表的偏移量。 * 对于消费者来说,它也可以消费指定偏移量的消息,比如最开始消费到了偏移量 1000 的消息,现在因为业务出了问题,要从偏移量 100 的消息重新消费。这种时候,只需要更新 commited_offset 为 100 就可以了。 * 可以预见每一个 topic 都不会有很多消费者,所以记录消费偏移量的表不会有很多数据。而且在执行更新的时候,也只会使用到行锁,所以性能会很好。也可以参考 Kafka 之类的设计,使用别的中间件来记录消费偏移量,比如说 ZooKeeper。 * 此外,更新偏移量的操作并发非常高,那么可以考虑使用 Redis 来记录消费偏移量,然后异步刷新到数据库中。但是这个方案存在的风险就是 Redis 可能突然宕机,导致最新的偏移量没有更新到 MySQL 中。比如说数据库记录的消费偏移量是 950,而消费者已经消费到了偏移量 1000,但是因为 Redis 突然宕机,以至于数据库里的偏移量还没更新为 1000。当 Redis 再次恢复之后,消费者就只能从 950 的位置重新消费。这种情况下,消费者做到幂等就可以。 直接拉取消息 ++++++++++++ * 还有一个优化消费者性能的地方,就是提供一个本地依赖,让消费者通过这个本地依赖直连数据库,直接从数据库中读取消息。同样地,消费者也通过这个本地依赖来提交消息。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/5oOuWz.png 29| 高性能: Kafka 为什么性能那么好 ---------------------------------- Kafka 分段与索引 ^^^^^^^^^^^^^^^^ * 为了加快段文件内的查找,每一个段文件都有两个索引文件。 * 一个是偏移量索引文件,存储着部分消息偏移量到存储位置的映射,类似于 这种二元组。这个 offset 不是全局 offset,是相对于这个文件第一条消息的偏移量。也就是说假如第一条消息的全局偏移量是 1000,那么偏移量为 1002 的消息的索引项是 <2, pos1>。 * 一个是时间索引文件,存储着时间戳到存储位置的映射,类似于 二元组。 零拷贝 ^^^^^^ * 零拷贝(zero copy)是中间件广泛使用的一个技术,它能极大地提高中间件的性能。所谓的零拷贝,就是指没有 CPU 参与的拷贝。 * DMA (Direct Memory Access)是一个独立于 CPU 的硬件 * NIC(Network Interface Card)就是指网卡。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/JxVGgO.png 一般的读写操作:要从磁盘读取内容,然后发送到网络上,那么基本流程如图 * 一般的读写操作需要四个步骤。 * 1.应用进入内核态,从磁盘里读取数据到内核缓存,也就是读缓存。这一步应用就是发了一个指令,然后是 DMA 来完成的。 * 2.应用把读缓存里的数据拷贝到应用缓存里,这个时候切换回用户态。 * 3.应用进入内核态,把应用缓存里的数据拷贝到内核缓存里,也就是写缓存。 * 4.应用把数据从写缓存拷贝到 NIC 缓存里,这一步应用也就是发了一个指令,DMA 负责执行。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/rQ004r.png 共有四次内核态与用户态的切换。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/osPHYS.png 零拷贝,对应的 ``内核态-用户态`` 切换,也只剩下了两次。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/d61uH9.png 和最原始的操作比起来,零拷贝少了两次内核态与用户态的切换,还少了两次 CPU 拷贝。 批量操作的优势 ^^^^^^^^^^^^^^ * 批量操作的优势主要体现在两个方面:一个是更少的系统调用和内核态与用户态的切换,还有一个是高效利用网络带宽。 * [更少的系统调用和内核态与用户态的切换]客户端每次都只发一个请求到服务端上,需要发 100 个请求。即使用了零拷贝技术,那么客户端发送 100 个请求,需要 100 次系统调用,200 次内核态与用户态的切换。而如果客户端一次性发送 100 个请求,那么它只需要 1 次系统调用,2 次内核态与用户态的切换。 * [高效利用网络带宽]在网络传输的时候,每一次发送都有一个固定开销,比如说协议头的部分,这个开销大小和具体的协议设计有关。假如说每个请求大小是 1KB,在网络传输的时候,分 100 次传输 1KB 和 1 次传输 100KB,后者也是明显快很多的。前者需要传输 100 次协议头,而后者只需要传输 1 次协议头。 思路 ^^^^ * [零拷贝]零拷贝是中间件设计的通用技术,是指完全没有 CPU 参与的读写操作。我以从磁盘读数据,然后写到网卡上为例介绍一下。首先,应用程序发起系统调用,这个系统调用会读取磁盘的数据,读到内核缓存里面。同时,磁盘到内核缓存是 DMA 拷贝。然后再从内核缓存拷贝到 NIC 缓存中,这个过程也是 DMA 拷贝。这样就完成了整个读写操作。和普通的读取磁盘再发送到网卡比起来,零拷贝少了两次 CPU 拷贝,和两次内核态与用户态的切换。 * [page cache]Kafka 充分利用了 page cache。Kafka 写入的时候只是写入到了 page cache,这几乎等价于一个内存写入操作,然后依靠异步刷新把数据刷新到磁盘上。而 page cache 是可以存放很多数据的,也就是说 Kafka 本身调用了很多次写入操作之后,才会真的触发 IO 操作,提高了性能。而且,Kafka 是基于 JVM 的,那么利用 page cache 也能缓解垃圾回收的压力。大多数跟 IO 操作打交道的中间件都有类似的机制,比如说 MySQL、Redis。 * [顺序写]在计算机里面,普遍认为写很慢,但是实际上是随机写很慢,但是顺序写并不慢。即便是机械硬盘的顺序写也并不一定会比固态硬盘的顺序写慢。Kafka 在写入数据的时候就充分利用了顺序写的特性。它针对每一个分区,有一个日志文件 WAL(write-ahead log),这个日志文件是只追加的,也就是顺序写的,因此发消息的性能会很好。MySQL、Redis 和其他消息中间件也采用了类似的技术。 * [分区多影响写入性能]Kafka 是每一个分区都有一个日志文件,如果分区特别多,就可能导致 Kafka 的写性能衰退。Kafka 的顺序写要求的是分区内部顺序写,不同的分区之间就不是顺序写的。所以如果一个 topic 下的分区数量不合理,偏多的话,写入性能是比较差的。举个例子,假如说要写入 100M 的数据,如果只有一个分区,那就是直接顺序写入 100M。但是如果有 100 个分区,每个分区写入 1M,它的性能是要差很多的。之前阿里云中间件团队测试过,在一个 topic 八个分区的情况下,超过 64 个 topic 之后,Kafka 性能就开始下降了。 * [分区]Kafka 的分区机制也能提高性能。假如说现在 Kafka 没有分区机制,只有 topic,那么可以预计的是不管是读还是写,并发竞争都是 topic 维度的。而在引入了分区机制之后,并发竞争的维度就变成分区了。如果是操作不同的分区,那么完全不需要搞并发控制。 * [分段与索引]在 Kafka 中,每一个分区都对应多个段文件,放在同一个目录下。Kafka 根据 topic 和分区就可以确定消息存储在哪个目录内。每个段文件的文件名就是偏移量,假设为 N,那么这个文件第一条消息的偏移量就是 N+1。所以 Kafka 根据偏移量和文件名进行二分查找,就能确定消息在哪个文件里。然后每一个段文件都有一个对应的偏移量索引文件和时间索引文件。Kafka 根据这个索引文件进行二分查找,就很容易在文件里面找到对应的消息。如果目标消息刚好有这个索引项,那么直接读取对应位置的数据。如果没有,就找到比目标消息偏移量小的,最接近目标消息的位置,顺序找过去。整个过程非常像跳表。 * [批量处理]Kafka 还采用了批量处理来提高性能。Kafka 的客户端在发送的时候,并不是说每来一条消息就发送到 broker 上,而是说聚合够一批再发送。而在 broker 这一端,Kafka 也是同样按照批次来处理的,显然即便同样是顺序写,一次性写入数据都要比分多次快很多。除了 Kafka,很多高并发、大数据的中间件也采用类似的技术,比如说日志采集与上报就采用批量处理来提升性能。批量处理能够提升性能的原因是非常直观的,有两方面。一方面是减少系统调用和内核态与用户态切换的次数。比方说 100 个请求发送出去,即便采用零拷贝技术,也要 100 次系统调用 200 次内核态与用户态切换。而如果是一次性发送的话,那么就只需要 1 次系统调用和 2 次内核态与用户态切换。另外一方面,批量处理也有利于网络传输。在网络传输中,一个难以避免的问题就是网络协议自身的开销。比如说协议头开销。那么如果发送 100 次请求,就需要传输 100 次协议头。如果 100 个请求合并为一批,那就只需要一个协议头。 * [批量处理的兜底技术]不过批次也要设计合理。正常来说批次总是越大越好,但是批次太大会导致一个后果,就是客户端难以凑够一个批次。比如说 100 条消息一批和 1000 条消息一批,后者肯定很难凑够一个批次。一般来说批量处理都是要兜底的,就是在固定时间内如果都没有凑够某个批次,那么就直接发送。比如说 Kafka 里面生产者就可以通过 linger.ms 参数来控制生产者最终等多长时间。时间到了,即便只有一条消息,生产者也会把消息发送到 broker 上。 * [压缩]Kafka 为了进一步降低网络传输和存储的压力,还对消息进行了压缩。这种压缩是端到端的压缩,也就是生产者压缩,broker 直接存储压缩后的数据,只有消费者才会解压缩。它带来的好处就是,网络传输的时候传输的数据会更少,存储的时候需要的磁盘空间也更少。当然,缺点就是压缩还是会消耗 CPU。如果生产者和消费者都是 CPU 密集型的应用,那么这种压缩机制反而加重了它们的负担。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/NzTumo.png 30| Kafka 综合运用: 怎么在实践中保证 Kafka 高性能 ------------------------------------------------- 基本思路 ^^^^^^^^ * 我这个系统有一个关键点,就是一个高并发的消息队列使用场景。也就是说,它要求我们做到高效发送、高效消费,不然就会有问题,比如说出现消息积压或者生产者阻塞的问题。那么优化的整体思路就是从消息队列的生产者、broker 和消费者这三方出发。 优化措施 ^^^^^^^^ 优化生产者 """""""""" * [优化 acks]之前我们有一个系统在一个高并发场景下会发送消息到 Kafka。后来我们就发现这个接口在业务高峰的时候响应时间很长,客户端经常遇到超时的问题。后来我去排查才知道,写这段代码的人直接复制了已有的发送消息代码,而原本人家的业务追求的是消息不丢,所以 acks 设置成了 all。实际上我们这个业务并没有那么严格的消息不丢的要求,完全可以把 acks 设置为 0。这么一调整,整个接口的响应时间就显著下降了,客户端那边也很少再出现超时的问题。不过追求消息不丢失的业务场景就不能把 acks 设置为 0 或者 1,这时候就只能考虑别的优化手段,比如说优化批次。 * [优化批次]我之前遇到过一个生产者发送消息的性能问题。后来我们经过排查之后,发现是因为发送性能太差,导致发送缓冲池已经满了,阻塞了发送者。这个时候我们注意到其实发送速率还没有达到 broker 的阈值,也就是说,broker 其实是处理得过来的。在这种情况下,最直接的做法就是加快发送速率,也就是调大 batch.size 参数,从原本的 100 调到了 500,就没有再出现过阻塞发送者的情况了。当然,批次也不是说越大越好。因为批次大了的话,生产者这边丢失数据的可能性就比较大。而且批次大小到了一个地步之后,性能瓶颈就变成了 broker 处理不过来了,再调大批次大小是没有用的。最好的策略,还是通过压测来确定合适的批次大小。 * [优化缓冲池]发送者被阻塞也可能是因为缓冲池太小,那么只需要调大缓冲池就可以。比如说是因为 topic、分区太多,每一个分区都有一块缓冲池装着批量消息,导致缓冲池空闲缓冲区不足,这一类不是因为发送速率的问题导致的阻塞,就可以通过调大缓冲池来解决。所以发送者阻塞要仔细分析,如果是发送速率的问题,那么调大发送缓冲区是治标不治本的。如果发送速率没什么问题,确实就是因为缓冲池太小引起的,就可以调大缓冲池。如果现实中,也比较难区别这两种情况,就可以考虑先调大批次试试,再调整缓冲池。 * [启用压缩]为了进一步提高 Kafka 的吞吐量,我也开启了 Kafka 的压缩功能,使用了 LZ4 压缩算法。or 为了进一步提高 Kafka 的吞吐量,我将压缩算法从 Snappy 换到了 LZ4。 优化 broker """"""""""" * [优化 swap]为了优化 Kafka 的性能,可以调小 vm.swappiness。比如说调整到 10,这样就可以充分利用内存;也可以调整到 1,这个值在一些 linux 版本上是指进行最少的交换,但是不禁用交换。目前我们公司用的就是 10。物理内存总是有限的,所以直接禁用的话容易遇到内存不足的问题。我们只是要尽可能优化内存,如果物理内存真的不够,那么使用交换区也比系统不可用好。 * [优化网络读写缓冲区]另外一个优化方向是调大读写缓冲区。Socket 默认读写缓冲区可以考虑调整到 128KB;Socket 最大读写缓冲区可以考虑调整到 2MB,TCP 的读写缓冲区最小值、默认值和最大值可以设置为 4KB、64KB 和 2MB。不过这些值究竟多大,还是要根据 broker 的硬件资源来确定。 * [优化磁盘 IO]Kafka 也是一个磁盘 IO 密集的应用,所以可以从两个方向优化磁盘 IO。一个是使用 XFS 作为文件系统,它要比 EXT4 更加适合 Kafka。另外一个是禁用 Kafka 用不上的 atime 功能。相比于 EXT4,XFS 性能更好。在同等情况下,使用 XFS 的 Kafka 要比 EXT4 性能高 5% 左右。 * [优化主从同步]Kafka 的主从分区同步也可以优化。首先调整从分区的同步数据线程数量,比如说调整到 3,这样可以加快同步速率,但是也会给主分区和网络带宽带来压力。其次是调整同步批次的最小和最大字节数量,越大则吞吐量越高,所以都尽量调大。最后也可以调整从分区的等待时间,在一批次中同步尽可能多的数据。 * [优化 JVM]Kafka 是运行在 JVM 上的,所以理论上来说任何优化 Java 性能的措施,对 Kafka 也一样有效果。 方案总结 ^^^^^^^^ * 当你选择压缩算法的时候,你需要权衡压缩比和压缩速率,根据需求做选择。 * 操作系统交换区可以看作“虚拟内存”。如果触发交换,性能就会显著下降。 * 优化生产者有三个措施:优化 acks、优化批次和启用压缩。 * 优化 broker 有五个措施:优化 swap、优化网络读写缓冲区、优化磁盘 IO、优化主从同步、优化 JVM。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/B659P0.png 模拟面试| 消息队列面试思路一图懂 -------------------------------- 22| 消息队列: 消息队列可以用来解决什么问题 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你用过消息队列吗?主要用来解决什么问题?异步、削峰和解耦你能各举一个例子吗? * 你用的是哪个消息队列?为什么使用它而不用别的消息队列? * 为什么你一定要用消息队列?不用行不行?不用有什么缺点? * 在对接多个下游的时候,直接用 RPC 调用行不行?为什么? * 为什么说使用消息队列可以提高性能? * 为什么说使用消息队列可以提高扩展性? * 为什么说使用消息队列可以提高可用性? * 为什么秒杀场景中经常用消息队列?怎么用的? * 订单超时取消可以怎么实现? * 你了解事件驱动吗? * 什么是 SAGA 事务?怎么利用事件驱动来设计一个 SAGA 事务框架? 23| 延迟消息: 怎么在 Kafka 上支持延迟消息 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 什么是延迟消息?你有没有用过?可以用来解决什么问题? * Kafka 支不支持延迟消息?为什么 Kafka 不支持? * RabbitMQ 支不支持延迟消息?怎么支持的? * RabbitMQ 的延迟消息解决方案有什么缺点?让你来改进,你会怎么办? * 什么是死信队列?什么是消息 ttl? * 如果要让 Kafka 支持延迟消息你会怎么做?你有几种方案?各有什么优缺点? * 在你的延迟消息队列方案里面,时间有多精确?比如说我希望在 10:00:00 发出来,你能保证这个一定恰好在这个时刻发出来吗?误差有多大?你能进一步提高时间精确性吗? * 在分区设置不同延迟时间的方案里,能不能支持随机延迟时间? * 在分区设置不同延迟时间的方案里,如果要是发生了 rebalance,会有什么后果? * 当你从准备转发消息到业务 topic(biz_topic)的时候失败了,有什么后果?怎么办? * 在你使用 MySQL 支持延迟消息的方案里,你怎么解决性能问题? * 如果要分库分表来解决 MYSQL 的性能问题,你会怎么分库分表?是分库还是分表? * 如果要是不同业务 topic 的并发量有区别,你分库分表怎么解决这种负载不均匀的问题? * 如果延迟消息还要求有序性,该怎么办? * 如果你已经将消息转发到了 biz_topic 上,但是更新数据库状态失败了怎么办? * 除了用 MySQL,可以考虑用别的中间件来实现延迟消息吗? 24| 消息积压: 业务突然增长, 导致消息消费不过来怎么办 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 一个分区可以有多个消费者吗?一个消费者可以消费多个分区吗? * 你业务里面的消息有多少个分区?你怎么计算出来的?它能撑住多大的读写压力? * 你遇到过消息积压吗?怎么发现的?为什么会积压?最后怎么解决的? * 为什么会出现消息积压?只要我容量规划好,肯定不会有消息积压,对不对? * 消息积压可以考虑怎么解决? * 增加消费者数量能不能解决消息积压问题? * 能不能通过限制发送者,让他们少发一点来解决消息积压问题? * 现在我发现分区数量不够了,但是运维又不准我增加新的分区,该怎么办? * 异步消费有什么缺陷? * 你怎么解决异步消费的消息丢失问题?你的方案会引起重复消费吗? * 在异步消费一批消息的时候,要是有部分消费失败了,怎么办?要不要提交? 25| 消息顺序: 保证消息有序, 一个 topic 只能有一个 partition 吗 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 消息在 Kafka 分区上是怎么存储的? * 什么是有序消息?用于解决什么问题? * Kafka 上的消息是有序的吗?为什么? * 要想在 Kafka 上保证消息有序,应该怎么做? * 什么是全局有序?要保证全局有序,在 Kafka 上可以怎么做? * 要保证消息有序,一个 topic 只能有一个 partition 吗? * 异步消费的时候怎么保证消息有序? * 在你使用的多分区方案中,有没有可能出现分区间负载不均衡的问题?怎么解决? * 增加分区有可能让你的消息失序吗?怎么解决? * 你还知道哪些消息队列是支持有序消息的? * 要做到跨 topic 的消息也有序,难点在哪里? 26| 消息不丢失: 生产者收到写入成功响应后消息一定不会丢失吗 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 什么是 ISR?什么是 OSR? * 一个分区什么情况下会被挪进去 ISR,什么时候又会被挪出 ISR? * 生产者的 acks 参数有什么含义?你用的是多少? * 怎么感觉业务选择合适的 acks 参数? * 消息丢失的场景有哪些? * 你遇到过消息丢失的问题吗?是什么原因引起的?你怎么排查的?最终怎么解决的? * 当生产者收到发送成功的响应之后,消息就肯定不会丢失吗? * acks 设置为 all,消息就一定不会丢失吗? * 什么是事务消息?Kafka 支持事务消息吗? * 怎么在 Kafka 上支持事务消息? * 什么是本地消息表?拿来做什么的? * 你是怎么保证你在执行了业务操作之后,消息一定发出去了? * 怎么保证生产者收到发送成功的响应之后,消息一定不会丢失?需要调整哪些参数? * 什么是 unclean 选举?有什么问题?你用的 Kafka 允许 unclean 选举吗? * 在你设计的回查方案里面,你怎么知道应该回查哪个接口?你这个能同时支持 HTTP 和 RPC 吗?能方便扩展到别的协议吗? * 你的回查机制有没有可能先收到提交消息?再收到准备消息?怎么保证两者的顺序? * 如果你已经把消息发到了业务 topic 上,但是你标记已发送失败了,怎么办? 27| 重复消费: 高并发场景下怎么保证消息不会重复消费 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 什么是布隆过滤器? * 什么是 bit array? * 为什么说尽量把消费者设计成幂等的? * 什么场景会造成重复消费? * 什么是恰好一次语义,Kafka 支持恰好一次语义吗? * 利用唯一索引来实现幂等的方案里,你是先插入数据到唯一索引,还是先执行业务?为什么 * 如果先插入唯一索引成功了,但是业务执行失败了,怎么办? * 如果不能使用本地事务,你怎么利用唯一索引来实现幂等?中间可能会有什么问题?你怎么解决? * 利用唯一索引来解决幂等问题,有什么缺陷? * 高并发场景下,怎么解决幂等问题? * 在你的高并发幂等方案里面,为什么要引入 Redis? * Redis 里面的 Key 过期时间该怎么确定? * 布隆过滤器 + Redis + 唯一索引里面,去掉布隆过滤器行不行?去掉 Redis 呢?去掉唯一索引呢? * 布隆过滤器 + Redis + 唯一索引方案中能不能使用本地布隆过滤器?怎么用? * 布隆过滤器 + Redis + 唯一索引有什么缺陷? 28| 架构设计: 如果让你设计一个消息队列, 你会怎么设计架构 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * Kafka 为什么要引入分区?只有 topic 行不行? * Kafka 为什么要强调把 topic 的分区分散在不同的 broker 上? * Kafka 为什么要引入消费者组概念?只有消费者行不行? * Kafka 为什么要引入 topic? * 如果让你来设计一个消息队列,你会怎么设计架构? * 在你的设计里面,你能支持延迟消息吗? * 在你的设计里面,怎么保证消息有序? * 在你的设计里面,会出现消息丢失的问题吗? * 在你的设计里面,什么场景会引起重复消息? * 在你的设计里面,你觉得性能瓶颈可能出现在哪里? * 在你的设计里面,你还可以考虑怎么提高性能? 29| 高性能: Kafka 为什么性能那么好 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 为什么 Kafka 性能那么好? * 为什么零拷贝性能那么好? * 写磁盘一定很慢吗?顺序写为什么很快? * 批量操作为什么快?节省了什么资源? * 什么是上下文切换?为什么上下文切换慢? * 分区过多有什么问题?topic 过多有什么问题? * 分区有什么好处? * 实际中使用批量发送之类的技术,可能出现什么问题?怎么解决? * 什么是 page cache ?为什么要引入 page cache? 30| Kafka 综合运用: 怎么在实践中保证 Kafka 高性能 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 什么是交换区? * 如果来不及发送,那么性能瓶颈可能在哪里? * 怎么优化发送者的发送性能? * 批次发送的时候,多大的批次才是最合适的? * 使用压缩有什么优缺点?怎么选择合适的压缩算法? * 怎么优化 broker? * 怎么优化 broker 所在的操作系统? * 什么是 TCP 读写缓冲区?怎么调优? * 哪些参数可以影响 Kafka 的主从同步?你优化过吗? * 你有优化过 Kafka 的 JVM 吗?怎么优化的? 消息队列面试一图懂 ^^^^^^^^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/16HIvA.png 缓存 (9讲) ========== 31| 缓存过期: 为什么 Redis 不立刻删除已经过期的数据 --------------------------------------------------- 缓存在面试中也分成两个方向:: 1. 一个是理论上缓存的设计,包括 Redis 的原理 2. 一个是在实践中使用缓存的案例,聚焦在怎么使用缓存,怎么解决一致性问题 缓存命中率 ^^^^^^^^^^ * 缓存命中率是我们用来衡量缓存效果的关键指标。它的计算方式是缓存命中次数除以查询总次数。在实践中,你要努力把缓存命中率提高到 90% 以上。 实现过期机制的一般思路:: 1. 定时删除: 是指针对每一个需要被删除的对象启动一个计时器,到期之后直接删除 2. 延迟队列: 也就是把对象放到一个延迟队列里面。当从队列里取出这个对象的时候,就说明它已经过期了,这时候就可以删除 3. 懒惰删除: 是指每次要使用对象的时候,检查一下这个对象是不是已经过期了。如果已经过期了,那么直接删除 4. 定期删除: 是指每隔一段时间就遍历对象,找到已经过期的对象删除掉 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/OFzaWM.png 基本思路 ^^^^^^^^ * 优化过期时间有两个方向。 * 第一个是调大过期时间,提高缓存命中率,并提高性能。早期我优化过一个缓存的过期时间,从十分钟延长到了二十分钟,缓存命中率从 80% 提升到了 90%。当然,代价就是 Redis 中缓存了更多的 key,占用了更多内存。 * 第一个是减少过期时间,从而减少 Redis 的消耗。我刚进我们公司的时候,发现我们公司的过期时间基本上都是统一的半小时,而没有考虑具体的业务特征。后来我排查之后,发现很多业务根本用不了半小时。比如说我把一个业务的过期时间降低到 10 分钟,缓存命中率基本上没有变化。经过这样的排查之后,Redis 的开销降了 30%。 为什么不立刻删除 """""""""""""""" * 理论上来说,并不是做不到,只不过代价比较高昂不值得而已。 * 最简单的做法就是使用定时器,但是定时器本身开销太大,还得考虑在更新过期时间的时候重置定时器。 * 另外一种思路就是使用延迟队列,但是延迟队列本身开销也很大,修改过期时间也要调整延迟队列,还要引入大量的并发控制。 * 综合来看,并不值得。而定期删除和懒惰删除的策略虽然看上去可能浪费内存,但是这个浪费很少,并且对响应时间也没那么大的影响。 Redis 是怎么控制定期删除开销的 """""""""""""""""""""""""""""" * 在每一个定期删除循环中,Redis 会遍历 DB。如果这个 DB 完全没有设置了过期时间的 key,那就直接跳过。否则就针对这个 DB 抽一批 key,如果 key 已经过期,就直接删除。 * 如果在这一批 key 里面,过期的比例太低,那么就会中断循环,遍历下一个 DB。如果执行时间超过了阈值,也会中断。不过这个中断是整个中断,下一次定期删除的时候会从当前 DB 的下一个继续遍历。 * 总的来说,Redis 是通过控制执行定期删除循环时间来控制开销,这样可以在服务正常请求和清理过期 key 之间取得平衡。 * 随机只是为了保证每个 key 都有一定概率被抽查到。假设说我们在每个 DB 内部都是从头遍历的话,那么如果每次遍历到中间,就没时间了,那么 DB 后面的 key 你可能永远也遍历不到。 * 在一些本地缓存的实现里面,也基本上会控制住这个开销。但是做法会比较简单。一种做法是循环的每个迭代都检测执行时间,超过某个阈值了就中断循环。另外一种做法是遍历够了就结束,比如说固定遍历 10000 个。当然也可以考虑两种策略混合使用。 如何控制定期删除的频率 """""""""""""""""""""" * 在 Redis 里面有一个参数叫做 hz,它代表的是 Redis 后台任务运行的频率。正常来说,这个值不需要调,即便调整也不要超过 100。与之相关的是 dynamic-hz 参数。这个参数开启之后,Redis 就会在 hz 的基础上动态计算一个值,用来控制后台任务的执行频率。 从库处理过期 key """""""""""""""" * 在 Redis 的 3.2 版本之前,如果读从库的话,是有可能读取到已经过期的 key。后来在 3.2 版本之后这个 Bug 就被修复了。不过从库上的懒惰删除特性和主库不一样。主库上的懒惰删除是在发现 key 已经过期之后,就直接删除了。但是在从库上,即便 key 已经过期了,它也不会删除,只是会给你返回一个 NULL 值。 持久化处理过期 key """""""""""""""""" * Redis 里面有两种持久化文件,RDB 和 AOF。 亮点方案 ^^^^^^^^ 如何确定过期时间 """""""""""""""" * 一般我们是根据缓存容量和缓存命中率确定过期时间的。正常来说,越高缓存命中率,需要越多的缓存容量,越长的过期时间。所以最佳的做法还是通过模拟线上流量来做测试,不断延长过期时间,直到满足命中率的要求。当然,也可以从业务场景出发。比如说,当某个数据被查询出来以后,用户大概率在接下来的三十分钟内再次使用这个对象,那么就可以把过期时间设置成 30 分钟。 * 如果公司的缓存资源不足,那么就只能缩短过期时间,当然代价就是缓存命中率降低。 * 缓存命中率要根据用户体验来确定。比如说要求 90% 的用户都能直接命中缓存,以保证响应时间在 100ms 以内,那么命中率就不能低于 90%。又或者公司规定了接口的 99 线或者平均响应时间,那么根据自己接口命中缓存和不命中缓存的响应时间,就可以推断出来命中率应该多高。 * 举个例子,如果公司要求平均响应时间是 300ms,命中缓存响应时间是 100ms,没命中缓存的响应时间是 1000ms,假设命中率是 p,那么 p 要满足 100×p+1000×(1−p)=300。 确定过期时间的案例 """""""""""""""""" * 理论上是要根据用户体验来确定过期时间,更加直观的做法是根据重试的时间、数据的热度来确定过期时间。 * [高并发幂等方案中 Redis 的过期时间]之前我设计过一个支持高并发的幂等方案,里面用到了 Redis。这个 Redis 会缓存近期已经处理过的业务 key,那么为了避免穿透这个缓存,缓存的过期时间就很关键了。如果过短,缓存命中率太低,请求都落到数据库上,撑不住高并发;如果过长,那么会浪费内存。所以这个过期时间是和重复请求相关的,例如在我的某个业务里面,重试是很快的,基本上在 10 分钟内就能重试完毕,那么我就把这个 Redis 的 key 的过期时间设置为 10 分钟。类似的思路也可以用于重试机制。比如说如果流程很漫长,那么可以考虑缓存中间结果,比如说中间某个步骤计算的结果。当触发重试请求的时候,就直接利用中间结果来继续执行。而这些中间结果的过期时间,就会触发重试的时间。 * [热点数据过期时间]也可以考虑根据数据是否是热点来确定过期时间。热点数据我们就会设置很长的过期时间,但是非热点数据,过期时间就可以设置得短一些。比如说我们的业务每个小时都会计算一些榜单数据,那么这些榜单对应的缓存过期时间就是一个小时。又比如说当某个大 V 发布了一个新作品之后,这个新作品的缓存时间可以保持在数小时。因为我们可以预期大 V 的粉丝会在这几小时内看完这个新作品。而一个已经发布很久的作品,即便要缓存,缓存时间也要设置得比较短,因为这个时候并没有什么人来看。 * [预加载与超短过期时间]早期我们有一个业务场景,就是用户会搜索出一个列表页,然后用户大概率就会点击列表页前面的某些数据。因此我做了一个简单的性能优化,就是预加载缓存。当用户访问列表页的时候,我会异步地把列表页的第一页的数据加载出来放到缓存里面。因为我可以预计的是,接下来用户会直接使用查看列表页中内容的详情信息。那么就会直接命中缓存,而不必再次查询。当然,因为用户也不一定就会访问,而且就算访问了也就是只访问一两次,因此过期时间可以设置得很短,比如说用一分钟。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/2h9Bci.png 32| 缓存淘汰策略: 怎么淘汰缓存命中率才不会下降 ---------------------------------------------- 淘汰算法 ^^^^^^^^ * LRU(Least Recently Used)是指最近最少使用算法。 * LFU(Least Frequently Used)是最不经常使用算法 * 最佳置换算法(OPT) * 先进先出置换算法(FIFO) 基本思路 ^^^^^^^^ * 为了进一步提高系统的性能,我还尝试过优化缓存。早期我们有一个业务,用到了一个本地缓存。这个本地缓存使用的淘汰算法是 LRU,最开始我们都觉得这个算法没什么问题。后来业务反馈,说有几个大客户一直抱怨自己的查询时快时慢。一听到时快时慢,我就可以确定应该是缓存出了问题。 * 经过排查我们发现原来这个缓存执行 LRU 的时候有时会把大客户的数据淘汰掉。而偏偏大客户的数据实时计算很慢,所以一旦没有命中缓存,响应时间就会暴增。后来我进一步梳理业务,一方面考虑进一步增大缓存的可用内存。另外一方面,设计了灵活的淘汰策略,在淘汰的时候优先淘汰小客户的数据。这样做的好处就是优先保证了大客户的用户体验,平均响应时间下降了 40%。而小客户因为本身计算就很快,所以影响也不是很大。 * 使用缓存一定要注意控制缓存的内存使用量,不能因为某一个业务而直接把所有的内存都耗尽。解决缓存淘汰的问题,应该优先考虑增加内存,降低缓存淘汰的几率。不过毕竟内存也不是无限的,最终都还是要选择合适的淘汰策略。比如说我们公司的 Redis 使用了 volatile-lru 淘汰策略。这些 LRU 或者 LFU 之类的算法都是普适性很强的算法,但是我也用过一些更加针对业务的淘汰算法。比如说按照优先级淘汰,大对象优先淘汰、小对象优先淘汰、代价低优先淘汰。大多数时候,不论是 Redis 还是本地缓存,这种业务针对性特别强的算法,都得自己实现。 亮点方案 ^^^^^^^^ * 之前我在业务里面使用过一个按照优先级来淘汰的策略。我们的业务有一个特点,就是数据有很鲜明的重要性之分。所以对于我们来说,应该尽可能保证优先级高的数据都在缓存中。所以在触发了淘汰的时候,我们希望先淘汰优先级比较低的缓存。所以我在 Redis 上利用有序集合设计了一个控制键值对数量,并且按照优先级来淘汰键值对的机制。这个有序集合是使用数据的优先级来排序的,也就是用优先级作为 score。 * 增加一个键值对就要执行一个 lua 脚本。在这个脚本里面,它会先检测有序集合里面的元素个数有没有超过允许的键值对数量上限,如果没有超过,就写入键值对,再把 key 加入有序集合。如果超过了上限,那么就从有序集合里面拿出第一个 key,删除这个 key 对应的键值对。 * 同时监听 Redis 上的删除事件,每次收到删除事件,就把有序集合中对应的 key 删除。在这个基础上,我可以根据不同的业务特征来计算优先级,从而实现大对象先淘汰、小对象先淘汰、热度低先淘汰等算法。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/Qc5I6Y.png 33| 缓存模式: 缓存模式能不能解决缓存一致性问题 ---------------------------------------------- * 我对缓存模式有比较深刻的理解,平时会用缓存模式来解决很多问题,比如说缓存穿透、雪崩和击穿。缓存模式有 Cache Aside、Read Through、Write Through、Write Back、Singleflight。除此之外,我还用过删除缓存和延迟双删。 Cache Aside ^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/EXJ2p1.png Cache Aside 这个模式,就是把缓存看作一个独立的数据源。 * Cache Aside 是最基本的缓存模式,在这个模式下,业务代码就是把缓存看成是和数据库一样的独立的数据源,然后业务代码控制怎么写入缓存,怎么写入数据库。一般来说,都是优先写入数据库的。 * 先写数据库是因为大多数业务场景下数据都是以数据库为准的,也就是说如果写入数据库成功了,就可以认为这个操作成功了。即便写入缓存失败,但是缓存本身会有过期时间,那么它过期之后重新加载,数据就会恢复一致。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/4tYo59.png 不管是先写数据库还是先写缓存,Cache Aside 都不能解决数据一致性问题。 .. note:: Cache Aside 就能满足百分之99的场景了 Read Through ^^^^^^^^^^^^ * 这个缓存模式也叫做读穿透。它的核心是当缓存里面没有数据的时候,缓存会代替你去数据库里面把数据加载出来,并且缓存起来。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/y595H4.png * Read Through 也是一个很常用的缓存模式。Read Through 是指在读缓存的时候,如果缓存未命中,那么缓存会代替业务代码去数据库中加载数据。 * 这种模式有两个异步变种,一种是异步写回缓存,一种是完全异步加载数据,然后写回缓存。当然,不管是什么变种,Read Through 都不能解决缓存一致性的问题。 亮点: 异步方案 """""""""""""" .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/Hx2bw9.png 缓存可以在从数据库加载了数据之后,立刻把数据返回给业务代码,然后开启一个线程异步更新缓存。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/Q7TYhc.png 第二个变种是直接让整个加载和回写缓存的过程都异步执行。也就是说,如果缓存未命中,那么就直接返回一个错误或者默认值,然后缓存异步地去数据库中加载,并且回写缓存。和第一个变种比起来,这种变种的缺陷是业务方在当次调用中只能拿到错误或者默认值。 .. note:: 如果业务方对响应时间的要求非常苛刻,那么就可以考虑使用变种二。代价就是业务方会收到错误响应或者默认值。而变种一其实收益很小,只有在缓存操作很慢的时候才会考虑。比如说缓存大对象,又或者要把一个大对象序列化之后再存储到缓存里面。 Write Through ^^^^^^^^^^^^^ * 写穿透,是指当业务方写入数据的时候,只需要写入缓存。缓存会代替业务方去更新数据库。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/Ju8SrF.png * Write Through 就是在写入数据的时候,只写入缓存,然后缓存会代替我们的去更新数据库。但是,Write Through 没有要求先写数据库还是先写缓存,不过一般也是先写数据库。 * 其次,Write Through 也没有讨论如果缓存中原本没有数据,那么写入数据的时候,要不要更新缓存。一般来说,如果预计写入的数据很快就会读到,那么就要刷新缓存中的数据。 * Write Through 也有对应的异步变种方案。当然,这些变种也都没有解决缓存一致性的问题。 亮点: 异步方案 """""""""""""" .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/TY5T5a.png Write Through 也是可以考虑异步的。也就是在写入缓存之后,缓存立刻返回结果。但这种模式是有可能丢数据的,也就是当业务代码收到成功响应之后,缓存崩溃了,那么数据其实并没有写入到数据库中。很少用,要用也是用一个和它很像的 Write Back 方案。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/KKjQh3.png 同步写入到数据库中,但是会异步刷新缓存。适合用于缓存写入操作且代价高昂的场景。比如说前面提到的,写入大对象或者需要序列化大对象再写入缓存。 Write Back ^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/G1YF9B.png 当你写入数据的时候,你只是写到了缓存。当缓存过期的时候,才会被刷新到数据库。Write Back 有一个硬伤,就是如果缓存突然宕机,那么还没有刷新到数据库的数据就彻底丢失了。这也限制了 Write Back 模式在现实中的应用。不过要是缓存能够做到高可用,也就不容易崩溃,也可以考虑使用。Write Back 最大的优点是除了数据丢失这一点,它能解决数据一致性的问题。 亮点: 能否解决数据一致性问题 """""""""""""""""""""""""""" * 在使用 Redis 更新数据的时候业务代码只更新缓存,所以对于业务方来说必然是一致的。 .. note:: Write Back 除了有数据丢失的问题,在缓存一致性的表现上,比其他模式要好。 Refresh Ahead ^^^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/8f9ys0.png Refresh Ahead 是指利用 CDC(Capture Data Change)接口异步刷新缓存的模式。这种模式在实践中也很常见,比如说利用 Canal 来监听数据库的 binlog,然后 Canal 刷新 Redis。这种模式也有缓存一致性的问题,也是出在缓存未命中的读请求和写请求上。 Singleflight ^^^^^^^^^^^^ * Singleflight 主要是为了控制住加载数据的并发量。 * Singleflight 模式是指当缓存未命中的时候,访问同一个 key 的线程或者协程中只有一个会去真的加载数据,其他都在原地等待。 删除缓存 ^^^^^^^^ * 删除缓存是业务中比较常见的用法,就是在更新数据的时候先更新数据库,然后将缓存删除。 * 删除缓存之后会使缓存命中率下降,也算是一个隐患。如果偶尔出现写频繁的场景,导致缓存一直被删除,那么就会使性能显著下降。缓存未命中回查数据库叠加写操作,数据库压力会很大。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/XEy10a.png 删除缓存和别的模式一样,也有一致性问题。但是它的一致性问题是出在读线程缓存未命中和写线程冲突的情况下。 延迟双删 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/uIloxq.png 延迟双删类似于删除缓存的做法,它在第一次删除操作之后设定一个定时器,在一段时间之后再次执行删除。第二次删除就是为了避开删除缓存中的读写导致数据不一致的场景。 * 这么多模式里面,我比较喜欢延迟双删,因为它的一致性问题不是很严重。虽然会降低缓存的命中率,但是我们的业务并发也没有特别高,写请求是很少的。命中率降低一点点是完全可以接受的。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/KXsx4k.png 34| 缓存一致性问题: 高并发服务如何保证缓存一致性 ------------------------------------------------ double-check 模式 ^^^^^^^^^^^^^^^^^ * double-check 是并发里为了兼顾并发安全和性能经常采用的一种代码模式。它的基本思路可以总结为检查、加锁、检查,所以也叫做 double-check。double-check 经常用在使用读写锁的场景 * 比如,先加读锁检测数据是否存在,如果存在就直接返回,否则就释放读锁,加写锁,再次检查数据是否存在,存在就直接返回,不存在就根据业务计算数据并返回。 基本思路 ^^^^^^^^ 不一致的根源 """""""""""" * 要想彻底解决数据一致性问题,就首先要搞清楚数据不一致的两个来源。第一个是操作部分失败,第二个是并发更新。 * [源自操作部分失败]在最简单的模型里面,就是更新数据库和更新缓存两个步骤。理论上来说要保证数据一致性,就必须要保证这两个更新要么都成功,要么都失败,不能有中间状态。也就是说,这是一个分布式事务问题。在能够彻底解决操作部分失败的问题之前,都只能说尽可能避免不一致,并且尽快达成一致,这也就包括重试失败操作、数据自动校验并修复等措施。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/ZYMrlR.png 源自操作部分失败 * [源自并发操作]解决这一类的数据不一致,核心是确保同一个时刻只有一个线程在更新数据库和缓存。在分布式环境下,这也就意味着即便你有几百个实例,依旧只能有一个实例上的一个线程在更新数据。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/it4LEG.png 源自并发操作 解决方案 """""""" * 要想解决部分失败的问题,只能考虑分布式事务,而且如果你追求的是强一致性,那么就需要用强一致性的分布式事务。显然排除 XA 这个存在争议的方案,目前并没有解决方案。 * 要想解决并发操作带来的问题,可以使用缓存模式、分布式锁、消息队列或者版本号。 * [消息队列]这个方案也是追求最终一致性的 * [版本号]这个方案的缺点是需要维护版本号,最好是在数据库里面增加一个版本字段。 * [多级缓存]引入了本地缓存和 Redis 缓存之后,数据不一致性的概率就更加大了 亮点方案 ^^^^^^^^ 一致性哈希和缓存 """""""""""""""" * 在节点上线或者下线的时候,就会引起不一致的问题,在新节点上线的一小段时间内,不要读写缓存,等待老节点上的请求自然返回。 分布式锁方案 """""""""""" * 思路一:先本地事务,后分布式锁 * 思路二:先删除缓存,再提交事务 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/RXldDR.png 引起不一致的两个根源:操作部分失败和并发操作。 35| 缓存问题: 怎么解决缓存穿透,击穿和雪崩问题 --------------------------------------------- 缓存穿透 ^^^^^^^^ * 缓存穿透是指数据既不在缓存中,也不在数据库中。 * 最常见的场景就是有攻击者伪造了大量的请求,请求某个不存在的数据。这会造成两个后果。 * 1.缓存里面没有对应的数据,所以查询会落到数据库上。 * 2.数据库也没有数据,所以没有办法回写缓存,下一次请求同样的数据,请求还是会落到数据库上。 缓存击穿 ^^^^^^^^ * 缓存击穿是指数据不在缓存中,导致请求落到了数据库上。 缓存雪崩 ^^^^^^^^ * 缓存雪崩是指缓存里大量数据在同一时刻过期,导致请求都落到了数据库上。 解决缓存穿透 ^^^^^^^^^^^^ * 有两种解决思路。 * 1.回写特殊值: 在缓存未命中,而且数据库里也没有的情况下,往缓存里写入一个特殊的值。这个值就是标记数据不存在,那么下一次查询请求过来的时候,看到这个特殊值,就知道没有必要再去数据库里查询了。 * 缺点: 如果攻击者每次都用不同的且都不存在的 key 来请求数据,那么这种措施毫无效果。并且,因为要回写特殊值,那么这些不存在的 key 都会有特殊值,浪费了 Redis 的内存。这可能会进一步引起另外一个问题,就是 Redis 在内存不足,执行淘汰的时候,把其他有用的数据淘汰掉。 * 2.布隆过滤器: 流程是业务代码收到请求之后,要先问一下布隆过滤器有没有这个 key。如果说没有,那就不用继续往后执行了。如果布隆过滤器说有,那么就继续往后执行,去查询缓存和数据库,并且在查询到了数据的时候,回写到缓存里面。 * 变种: 也可以考虑先查询缓存,当缓存没有数据的时候,再去查询布隆过滤器。如果布隆过滤器说有数据,再去查询数据库。这两种模式没有太大的差别。先查询布隆过滤器,保护效果会更好,也就是提前挡住了非法请求。而先查询缓存,对正常请求更加友好,因为正常请求大概率命中缓存,直接返回数据,也就不用查询布隆过滤器了。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/TUW4xL.png 使用布隆过滤器 解决缓存击穿 ^^^^^^^^^^^^ * 解决缓存击穿是很容易的,只需要用到我们在缓存模式里面提到的 singleflight 模式。 解决缓存雪崩 ^^^^^^^^^^^^ * 解决思路自然就有两个,一个是不允许一次性加载一大批数据到缓存,而这显然不现实,因为批量加载属于业务要求;另外一个思路就是设置不同的过期时间。 * 核心:偏移量要跟过期时间成正比,不能过低或者过高。比如说如果过期时间是 15 分钟,那么随机偏移量在 0~180 秒都可以。如果数据量不多,那么 0~60 秒也可以。而如果过期时间很长,比如说 4 个小时,也可以把偏移量控制在 0~10 分钟。如果过期时间很短,比如说只有 10s,这个时候偏移量就只能在 0~3 秒内了。 限流 ^^^^ * 缓存穿透和击穿只有在高并发下才会成为一个问题,所以一个很自然的想法就是使用限流。限流可以考虑在两个地方使用:服务层面和数据库层面。 * 数据库层面上的限流总的来说是必不可少的。不管是缓存崩溃,还是穿透或者击穿,限流都能保护住数据库。 亮点方案 ^^^^^^^^ * Redis 本身也有可能崩溃,又或者因为网络问题连不上这个集群。那么亮点方案——集群互为备份 * 假设说我有两个业务,那么我准备两个 Redis 集群,业务 1 主要用集群 1,集群 2 作为备份。业务 2 主要使用集群 2,集群 1 作为备份。 * 第一,业务 1 会和集群 1 保持心跳。当发现连不上 Redis 之后,就可以执行容错方案,这个时候业务 1 会保持和集群 1 的心跳。 * 第二,触发容错之后,业务 1 根据流量价值分成两部分。对于非核心业务来说,直接触发熔断,不会查询集群 2,也不会查询数据库,这是舍小保大。对于核心业务来说,按照预先设置的流量比例,查询集群 2,并回查数据库,其余请求一样熔断。如果当前流量比例查询集群 2 没有引起任何的问题,数据库也没有问题,那么就增大流量比例。 * 第三,当集群 1 重新恢复心跳之后,业务 1 还是逐步把集群 2 上的流量转发到集群 1 上。 思路总结 ^^^^^^^^ * 解决缓存穿透:回写特殊值和布隆过滤器两个方案 * 解决缓存击穿:singleflight 模式 * 解决缓存雪崩:过期时间增加随机偏移量 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/5ZQBAG.png 36| Redis 单线程: 为什么 Redis 用单线程而 Memcached 用多线程 ------------------------------------------------------------ * Redis 是线程安全的,没有并发问题。可以用来实现一个分布式锁 Redis 是单线程的含义 ^^^^^^^^^^^^^^^^^^^^ * 我们通常说的 Redis 单线程,其实是指处理命令的时候 Redis 是单线程的。但是 Redis 的其他部分,比如说持久化其实是另外的线程在处理。因此本质上,Redis 是多线程的。特别是 Redis 在 6.0 之后,连 IO 模型都改成了多线程的模型,进一步发挥了多核 CPU 的优势。 * Redis 的高性能源自两方面,一方面是 Redis 处理命令的时候,都是纯内存操作。另外一方面,在 Linux 系统上 Redis 采用了 epoll 和 Reactor 结合的 IO 模型,非常高效。 epoll 模型 ^^^^^^^^^^ * epoll 里面有两个关键结构。一个是红黑树,每一个节点都代表了一个文件描述符。另外一个是双向链表,也叫做就绪列表。 * epoll_create:也就是创建一个 epoll 结构 * epoll_ctl:管理 epoll 里面的那些文件描述符,简单说就是增删改 epoll 里面的文件描述符。 * epoll_wait:根据你的要求,返回符合条件的文件描述符,也就是查。 37| 分布式锁: 如何保证Redis分布式锁的高可用和高性能 --------------------------------------------------- 加锁 ^^^^ * 利用 Redis 来实现分布式锁的时候,所谓的锁就是一个普通的键值对。而加锁就是使用 SETNX 命令,排他地设置一个键值对。如果 SETNX 命令设置键值对成功了,那么说明加锁成功。如果没有设置成功,说明这个时候有人持有了锁,需要等待别人释放锁。而相应地,释放锁就是删除这个键值对。 释放锁 ^^^^^^ * 在释放锁的时候,要先确认锁是不是自己加的,防止因为系统故障或者有人手动操作了 Redis 导致锁被别人持有了。确认锁的方法也很简单,就是比较一下键值对里的值是不是自己设置的。这也要求在加锁设置键值对的时候使用唯一的值,比如说用 UUID。 去分布式锁 ^^^^^^^^^^ * 分布式锁不管怎么优化,都有性能损耗。所以原则上来说,能不用分布式锁就不用分布式锁。 * 第一种思路就是可以尝试用数据库乐观锁来取代分布式锁。 * 第二种思路是利用一致性哈希负载均衡算法。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/eauZol.png 38| 缓存综合应用: 怎么用缓存来提高整个应用的性能 ------------------------------------------------ 一致性哈希 + 本地缓存 + Redis 缓存 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/eBV0pa.png 使用了本地缓存和 Redis,在前面加一个一致性哈希负载均衡,确保同一个业务的请求总是落到同一个节点上。 Redis 缓存降级成本地缓存 ^^^^^^^^^^^^^^^^^^^^^^^^ * 我之前维护了一个强调高可用和高性能的服务。这个服务最开始使用的缓存方案是比较典型的,就是访问 Redis,然后访问数据库。后来有一次我们这边网络出了问题,连不上 Redis,导致压力瞬间都到了数据库上,数据库崩溃。 * 我后面做了两个事情,防止再一次出现类似的问题。第一个事情就是在数据库查询上加上了限流。另外一个事情就是引入了本地缓存。但是本地缓存一开始是没有启用的。我会实时监控 Redis 的状态,一旦发现 Redis 已经崩溃,就会启用本地缓存。启用本地缓存之后,好处是保住了数据库,并且响应时间还是很好。缺点就是本地缓存会面临更加严重的数据一致性问题。 * 为了缓解数据不一致性的问题,我进一步引入了一致性哈希负载均衡算法,确保同一个业务的请求肯定落到同一个服务端节点上,这样就可以提高本地缓存的命中率,并且降低本地缓存的消耗。 * 在启用了本地缓存之后,还要监控 Redis 的状态。当 Redis 恢复过来,就可以逐步将本地缓存上的流量转发到 Redis 上。之所以不是立刻全部转发过去,是因为刚恢复的时候 Redis 上面可能什么数据都没有,导致缓存未命中,回查数据库。这可能会引起数据库的问题。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/oxflgK.png 模拟面试| 缓存面试思路一图懂 ---------------------------- 31 为什么 Redis 不立刻删除已经过期的数据 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * Redis 是怎么删除过期 key 的? * Redis 为什么不立刻删除已经过期的 key? * Redis 为什么不每个 key 都启动一个定时器,监控过期时间? * Redis 是如何执行定期删除的? * 为什么 Redis 在定期删除的时候不一次性把所有的过期 key 都删除掉? * 当你从 Redis 上查询数据的时候,有可能查询到过期的数据吗? * 当 Redis 生成 RDB 文件的时候,会怎么处理过期的 key? * 当 Redis 重写 AOF 文件的时候,会怎么处理过期的 key? * Redis 定期删除的循环是不是执行得越频繁就越好? * 如果设计一个本地缓存,你会怎么实现删除过期 key 的功能? * 你是怎么确定过期时间的?过期时间太长会怎样,太短又会怎样? 32 缓存淘汰策略: 怎么淘汰缓存命中率才不会下降 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你知道什么是 LFU,什么是 LRU 吗?可不可以手写一个? * 什么情况下使用 LFU,什么情况下使用 LRU? * Redis 支持哪些淘汰策略?你们公司的 Redis 上的淘汰策略使用了哪个?为什么用这个? * 你使用的本地缓存是如何控制内存使用量的? * 你业务里面的缓存命中率有多高?还能不能进一步提高?怎么进一步提高? * 假如说 A 和 B 两个业务共用一个 Redis,那么有办法控制 A 业务的 Redis 内存使用量吗?怎么控制? * 现在我的业务里面有普通用户和 VIP 用户。现在我希望在缓存内存不足的时候,优先淘汰普通用户的数据,该怎么做? 33 缓存模式: 缓存模式能不能解决缓存一致性问题 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 什么是 Cache Aside,它能不能解决数据一致性问题? * 什么是 Read Through,它能不能解决数据一致性问题? * 什么是 Write Through,它能不能解决数据一致性问题? * 什么是 Write Back,它有什么缺点,能不能解决一致性问题? * 什么是 Refresh Ahead,它能不能解决一致性问题? * 什么是 Singleflight 模式?你用它解决过什么问题? * 在具体的工作场景中,你是怎么更新数据的?会不会有数据不一致的问题?怎么解决? * 什么是延迟双删,使用延迟双删能不能解决数据一致性问题? * 你知道哪些缓存模式,用过哪些模式? 34 缓存一致性问题: 高并发服务如何保证缓存一致性 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 为什么会有数据不一致的问题? * 你在使用缓存的时候怎么解决数据不一致的问题? * 当你的数据不一致的时候,你多久能够发现? * 如果你使用了本地缓存和 Redis,那么更新数据的时候你怎么更新? * 使用分布式锁能不能解决数据一致性问题,有什么缺点? * 你能保证更新数据库和更新缓存同时成功吗?如果不能,你怎么解决? * 你有什么方法可以解决并发更新导致的数据不一致性问题? 35 缓存问题: 怎么解决缓存穿透,击穿和雪崩问题 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 什么是缓存穿透、击穿和雪崩? * 你平时遇到过缓存穿透、击穿和雪崩吗?什么原因引起的?最终是怎么解决的? * 你还遇到过什么跟缓存有关的事故?最终都是怎么解决的? * 在你的系统里面,如果 Redis 崩溃了会发生什么? * 怎么在 Redis 崩溃之后保护好数据库? 36 Redis 单线程: 为什么 Redis 用单线程而 Memcached 用多线程 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 操作系统中的上下文切换有什么开销? * Redis 真的是单线程的吗? * Redis 为什么后面又引入了多线程? * Redis 后面的引入的多线程模型是怎么运作的?相比原本的单线程模型有什么改进? * 同样是缓存,为什么 Memcached 使用了多线程? * 什么是 epoll?和 poll、select 比起来,有什么优势? * 什么是 Reactor 模式? * 为什么 Redis 的性能那么好? * 你可以说说 Redis 的 IO 模型吗? * 为什么 Redis 可以用单线程,但是 Kafka 之类的中间件确不能使用单线程呢? 37 分布式锁: 如何保证 Redis 分布式锁的高可用和高性能 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 什么是分布式锁?你用过分布式锁吗? * 你使用的分布式锁性能如何,可以优化吗? * 怎么用 Redis 来实现一个分布式锁? * 怎么确定分布式锁的过期时间? * 如果分布式锁过期了,但是业务还没有执行完毕,怎么办? * 加锁的时候得到了超时响应,怎么办? * 加锁的时候如果锁被人持有了,这时候怎么办? * 分布式锁为什么要续约?续约失败了怎么办?如果重试一直都失败,怎么办? * 怎么减少分布式锁竞争? * 你知道 redlock 是什么吗? 38 缓存综合应用: 怎么用缓存来提高整个应用的性能 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你是如何利用缓存来提高系统性能的? * 当你的缓存崩溃了的时候,你的系统会怎么样? * 你们公司的 Redis 是如何部署的,性能怎么样? * 假如说有一个服务 A 要调用服务 B,那么能不能让 A 把 B 的结果缓存下来,这样下次就不用调用了?这种做法有什么优缺点? * 为什么要做缓存预加载,怎么做预加载? 缓存面试一图懂 ^^^^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/35xX93.png NoSQL (5讲) =========== 39| Elasticsearch高可用: 怎么保证Elasticsearch的高可用 ------------------------------------------------------ 40| Elasticsearch查询: 怎么优化 Elasticsearch 的查询性能 -------------------------------------------------------- 41| MongoDB: MongoDB 是怎么做到高可用的 --------------------------------------- 为什么用 MongoDB ^^^^^^^^^^^^^^^^ * MongoDB 是灵活的文档模型。也就是说,如果我预计我的数据可以被一个稳定的模型来描述,那么我会倾向于使用 MySQL 等关系型数据库。而一旦我认为我的数据模型会经常变动,比如说我很难预料到用户会输入什么数据,这种情况下我就更加倾向于使用 MongoDB。 * MongoDB 更容易进行横向扩展。虽然关系型数据库也可以通过分库分表来达成横向扩展的目标,但是比 MongoDB 要困难很多,后期运维也要复杂很多。而这一切在 MongoDB 里面都是自动的,你基本不需要操心。 MongoDB 的分片机制 ^^^^^^^^^^^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/MX3wIc.png 每一个分片集合都被分成若干个分片,每个分片又由多个块(chunk)组成。在最新版本的默认情况下,一个块的大小是 128 MB。 * 如果一个块满足了下面这两个条件里的任何一个,就会被拆分成两个块。 * 1. [数据太多]整个块的数据量过多了。比如说默认一个块是 128MB,但是这个块上放的数据超过了 128 MB,那么就会拆分。 * 2. [文档太多]块上面放了太多文档,这个阈值是平均每个块包含的文档数量的 1.3 倍。也就是说,如果平均每个块可以放 1000 个文档,如果当前块上面放了超过 1300 个文档,那么这个块也会被切分。 .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/W8GCX0.png 发现数据不均匀之后,迁移数据的过程也叫做再平衡(rebalance)。再平衡过程本质上就是挪动块。 MongoDB 设定了一些阈值,超过了这个阈值就会触发再平衡的过程:: 块数量 阈值 少于20 2 20-79 4 >=80 8 再平衡过程就包含七个步骤:: 1. 平衡器发送 moveChunk 命令到源分片上 2. 源分片执行 moveChunk 命令,这个时候读写块 A 的操作都是源分片来负责的 3. 目标分片创建对应的索引 4. 目标分片开始同步块 A 的数据 5. 当块 A 最后一个文档都同步给目标分片之后,目标分片会启动一个同步过程,把迁移过程中的数据变更也同步过来 6. 整个数据同步完成之后,源分片更新元数据,告知块 A 已经迁移到了目标分片 7. 当源分片上的游标都关闭之后,它就可以删除块 A 了 MongoDB 的配置服务器 ^^^^^^^^^^^^^^^^^^^^ * 我们这个系统有一个关键组件,就是 MongoDB。但是在最开始的时候,MongoDB 都还没有启用主从,也就是一个单节点的。因此每年总会有那么一两次,MongoDB 崩溃不可用。所以我做了一件很简单的事情,就是把 MongoDB 改成了主从同步,最开始的时候业务量不多,为了节省成本,我们就用了推荐的配置一主两从。这种改变的好处就是当主节点崩溃之后,从节点可以选举出一个新的主节点。 * 最开始的时候,我们公司只是部署了一主两从,两个从节点都会同步数据。后面为了进一步提高可用性,我引入了仲裁节点。这些仲裁节点被部署在轻量级的服务器上,成本非常低。在引入了这些仲裁节点之后,就算有一个从节点崩溃了,整个集群也基本没什么影响,因为这个时候还是有足够的节点可以投票。 * 我们公司本身业务规模比较大,对 MongoDB 的依赖也很严重,所以我们还部署了多数据中心的主从结构。我们有两个数据中心(可以同城,可以异地),其中一个数据中心,部署了一主一从,另外一个数据中心部署了两个从节点。那么万一一个数据中心崩溃了,另外一个数据中心也还是可用的。 * 同时为了保证在主从选举的时候优先选择同一个数据中心的节点,我们还调整了从节点的优先级。也就是说,我们先把同一个机房的节点都设置成比较高的优先级,那么主节点大概率就会从这个机房选出来。 引入分片 ^^^^^^^^ * 从理论上来说,分片既可以提高性能,又可以提高可用性。 思路总结 ^^^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/gkCGAs.png 42| MongoDB高性能: 怎么优化MongoDB的查询性能 -------------------------------------------- .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/pIMHfG.png 模拟面试| NoSQL面试思路一图懂 ----------------------------- 39| Elasticsearch 高可用: 怎么保证 Elasticsearch 的高可用 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * Elasticsearch 的节点有什么角色?一个节点可以扮演多个角色吗? * 在实践中,怎么合理安排不同节点扮演的角色? * 什么是候选主节点和投票节点?投票节点可以被选为主节点吗?为什么要引入投票节点? * 可以说一下你们公司的 Elasticsearch 是如何部署的吗?性能如何? * 你用 Elasticsearch 解决过什么问题?为什么用 Elasticsearch?可以用别的框架吗? * Elasticsearch 为什么引入分片?为了解决什么问题? * 当一个写入请求发送到 Elasticsearch 之后,发生了什么? * Elasticsearch 是实时的吗? * Elasticsearch 的 Translog 是拿来干什么的?它可以保证数据一定不丢失吗? * 什么是 Commit Point?用来干什么? * Elasticsearch 在合并段的时候,会影响到已有的查询吗?一个查询怎么知道应该用合并前的段,还是应该用合并后的段? * 如果我的写入数据流量很大,怎么保证我的 Elasticsearch 不会崩溃? * 你知道什么是协调节点吗?它的作用是什么?怎么保证协调节点高可用? 40| Elasticsearch 查询: 怎么优化 Elasticsearch 的查询性能 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你的业务写入和查询的性能如何?Elasticsearch 的性能瓶颈是多少? * 如何设计 Ealsticsearch 的索引? * 你有没有优化过 Elasticsearch 的查询性能?怎么优化?为什么可以这么优化? * 为什么 Elasticsearch 的分页查询也那么慢?可以怎么优化? * 你有没有优化过 Elasticsearch 的 JVM?怎么优化的? * 如果 Elasticsearch 经常出现 Full GC,怎么排查和优化? * 怎么为 Elasticsearch 选择适合垃圾回收算法? * swap 对 Elasticsearch 有什么影响?应该怎么调整? * 为什么 Elasticsearch 容易出现文件描述符耗尽的问题?可以怎么优化? 41| MongoDB: MongoDB 是怎么做到高可用的 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你们公司的 MongoDB 是如何部署的?可用性有多高? * 你用 MongoDB 解决过什么问题?你为什么要用 MongoDB?用 MySQL 行不行? * 和关系型数据库比起来,MongoDB 有哪些优势? * MongoDB 是如何分片的? * MongoDB 的块是什么? * 什么情况下会触发块迁移?怎么迁移? * MongoDB 的负载均衡(再平衡)是指什么? * MongoDB 的配置服务器有什么作用? * MongoDB 的复制机制是怎样的? * 为什么 MongoDB 的 oplog 总是很多? * 怎么控制 MongoDB 的写入语义?你用的是什么语义?为什么用这个语义? * 有没有遇到过配置服务器崩溃的问题?怎么提高配置服务器的可用性? * 当 MongoDB 的主节点崩溃之后,如何选出一个新的主节点? * 怎么样可以让 MongoDB 在主从选举的时候优先选择同机房的从节点? 42| MongoDB 高性能: 怎么优化 MongoDB 的查询性能 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * 你的业务里面使用 MongoDB 的性能如何?能撑住多大的读写流量? * 你有没有遇到过 MongoDB 的性能问题?后面是如何解决的? * 当我一个查询请求落到了 MongoDB 之上后,MongoDB 是怎么执行这个查询的? * mongos 是什么?拿来干什么?怎么优化它的性能? * 怎么设计 MongoDB 的索引?怎么判定一个索引是否合适? * 什么是 ESR 规则?为何要遵守 ESR 规则?不遵守行不行? * 大文档有什么问题?可以怎么解决大文档引发的问题? * 什么时候要嵌入文档?有什么优势? * 怎么优化 MongoDB 的排序(分页)查询? * 为什么要尽可能只查询必要的字段? * 怎么优化 MongoDB 所在的操作系统?这些优化为什么会有效果? 一图懂 ^^^^^^ .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/lq68fi.png .. figure:: https://img.zhaoweiguo.com/uPic/2023/11/vcf1D9.png 结束语 ====== 未来掌握在自己手中 ------------------ * 夯实基础:万丈高楼平地起 * 博闻广识:眼界决定边界 * 创新:立于不败之地