极客时间-后端工程师的高阶面经邓明50讲学习笔记
00 分钟
2023-10-21
2023-10-21
type
status
date
slug
summary
tags
category
icon
password
notion image

JK-后端面经

1开篇词

准备面试亮点

打开面试问题思路,脱颖而出

2服务注册发现

服务上线

  • 服务端上线,向注册中心注册服务
  • 服务端与注册中心保持心跳
  • 客户端第一次调用,缓存注册中心的服务列表
  • 客户端与注册中心保持心跳与数据同步
  • 客户端向服务端发送请求

服务优雅下线

  • 服务端通知注册中心准备下线
  • 注册中心通知客户端某服务准备下线,别再发送请求
  • 服务端等待一段时间,下线

服务注册发现的高可用

  • 服务崩溃检测
    • 心跳断了就认为服务断了
    • 服务立刻下线,立刻重试+间隔重试判断是否是网络抖动问题
    • 如果不重试,就无法避免网络偶尔不稳定问题。 如果立刻重试,不下线,重试3次,每次间隔10秒,30秒钟,有上万请求发到崩溃的服务器上。 所以考虑下线+立刻重试+间隔重试 立刻重试:大概率网络仍然不稳定,还是出问题
  • 客户端容错机制
    • 服务端崩溃到客户端感知的延时时间:服务端与注册中心的心跳间隔+注册中心通知客户端某服务端下线(毫秒内,可以忽略不计)
    • 客户端被通知某服务端下线,移除可用列表,但客户端会依旧发送心跳给下线服务端,心跳如果成功,会将服务端放回服务列表,如果连续几次心跳失败后,认为服务崩溃
    • 客户端与注册中心心跳失败后,也会连续发送心跳,看是否能成功,长期连接不上,客户端会断开与注册中心通信,使用本地缓存的服务列表。
  • 注册中心选型
    • CAP
      • 注册中心应该考虑高可用,AP模型
      • ZK 问题:作为CP模型,当master下线,master选举,会出现30~120S不可用。
      • Nacos优点:支持AP+CP。而且还有服务健康监测,动态配置服务。
    • 通用选型考虑因素
      • 性能
      • 使用语言,能否二次开发
      • 社区活跃度
      • 中间件成熟度
 

3负载均衡

静态算法

  • 轮询与加权轮询
    • 所有的候选节点轮流作为负载均衡的目标节点
    • 这个算法就不再是节点轮流,而是根据权重来轮流。
  • 平滑的加权轮询
    • 每个节点有两个权重,初始权重与当前权重
    • 对于一个节点来说,每次被挑选之后,它的 当前权重 就会下降, 那么下一次就会减少选中它的概率。
  • 随机与加权随机
    • 随机可以看作是随便挑选一个作为目标节点,加权随机则是利用不同的权重来设置选中的概率。权重越大,那么被选中的机会也就越大。
  • 哈希与一致性哈希
    • 哈希环的概念,服务端节点会落在环的某些位置上。客户端 根据请求参数,计算一个哈希值。这个哈希值会落在哈希环的某个位置。从这个位置出发,顺时针查找,遇到的第一个服务端节点就是目标节点。
    • 一致性哈希负载均衡算法就像是钟表,它的过程就有点儿像你的朋友约你吃火锅,说下一个整点到重庆火锅店集合。那么你看一下现在的时间,是下午三点四十五分,那么自然下一个整点就是下午四点了。

动态算法(实时计算)

  • 最少连接数
    • 客户端和各个节点的连接数量,从中挑选出连接数数量最少的节点
  • 最少活跃请求数
    • 当前活跃请求数来代表服务端节点的负载。所谓的活跃请求,就是已 经接收但是还没有返回的请求。
  • 最快响应时间
    • 最快响应时间算法就是客户端维持每个节点的响应时间,而后每次挑选响应时间最短的。
    • 注意:最近的响应时间权重会更高些,算法体现了采集指标随着时间准确性衰减的特性

自己设计负载均衡算法

  • 比如CPU密集型应用,每次筛选CPU负载最低的应用
  • 难点:
    • 1采集所有服务端CPU负载数据
    • 2通过客户端采集数据,导致客户端1实际上不知道客户端2的连接数情况
  • 方案:
    • 1服务端响应时,附带回传所需数据
    • 2通过监控平台如Prometheus获得指标数据

面试建议:

  • 先描述各种基本算法以及简要分析,然后再加上一句总结引导。
  • 一般来说,加权类的算法都要考虑权重的设置和调整。
  • 准备了一个负载均衡引发的线上事故案例
    • 我们公司用的是轮询来作为负载均衡。不过因为轮询没有实际查询服务端节点 的负载,所以难免会出现偶发性的负载不均衡的问题。 比如说我们之前发现线上的响应时间总体来说是非常均匀的,但是每隔一段时 间就会出现响应时间特别慢的情况。而且时间间隔是不固定的,慢的程度也不 一样,所以就很奇怪。后来我们经过排查之后,发现是因为当一个大请求落到 一个节点的时候,它会占据大量的内存和 CPU。如果这时候再有请求打到同一 个节点上,这部分请求的响应时间就会非常慢。
  • 解决方案:
    • 业务拆分
      • (业务拆分)这个大请求其实是一个大的批量请求。后来我们限制一批最多只 能取100个就解决了这个问题。
    • 隔离角度
      • (隔离角度)我们稍微魔改了一下负载均衡算法,不再是单纯的轮询了。我们 每天计算一批大客户,这部分大客户的请求会在负载均衡里面被打到专门的几 个节点上。虽然大客户的请求依旧很慢,但是至少别的客户不会再受到他们的 影响了。
  • 总结升华:
    • 负载均衡算法有些时候用得好,是能够解决一些技术问题的,比如说缓存。
    • 在性能非常苛刻的时候,我们会考虑使用本地缓存。但是使用本地缓存的数据 一致性问题会非常严重,而我们可以尝试将一致性哈希负载均衡算法和本地缓 存结合在一起,以提高缓存命中率,并且降低本地缓存的总体内存消耗。比如 说针对用户的本地缓存,我们可以使用用户 ID 来计算哈希值,那么可以确保同 一个用户的本地缓存必然在同一个节点上。不过即便是采用了一致性哈希负载 均衡算法,依旧不能彻底解决数据一致性的问题,只能缓解一下。
    • 例外:
      • 应用发布,新老节点替换 服务扩容时
        • 会导致缓存与数据不一致
        • 与缓存数据存在多个节点上
  • 如果面试官问你怎么设置权重或者怎么调整权重,抓住关键词成加败减就可以了。
    • 权重代表节点的处理能力,当然在一些场景下它也代表节点的可用性或者重要 性。所以权重根据节点的实际情况来设置值就可以。权重的要点在于体现不同 节点的差异性,它的绝对值并不重要。 一般来说为了进一步提高可用性,加权类的负载均衡算法都会考虑根据调用结 果来动态调整权重。如果调用成功了,那么就增加权重;如果调用失败了,那 么就减少权重。 这里调用成功与否是一种非业务相关的概念,也就是说即便拿到了一个失败的 响应,但是本身也算是调用成功了。调用失败了大多数时候是指网络错误、超 时等。而在实际落地的时候,也可以考虑如果是网络引起的失败,那么权重下 调就多一点,因为这一类的错误意味着问题更加严重。如果是超时这种,那么 权重就下调少一点,因为这种错误是比较容易恢复过来的。
    • 权重的调整要设置好上限和下限。那么你可以揭开这个业界经常忽略的问题,关键词是上下限。
    • 调整权重的算法都要考虑安全问题,即权重的调整应该有上限和下限。比如说 一般下限不能为0,因为一个节点的权重为 0 的话,它可能永远也不会被选中, 又或者和 0 的数学运算会出现问题导致负载均衡失败。上限一般不超过初始权 重的几倍,比如说两倍或者三倍,防止该节点一直被连续选中。 当然,如果在实现的时候使用了 uint 或者 Int8 之类的数字,还要进一步考虑 溢出的问题。之前挺多公司因为没有控制上下限而引起了线上故障。

4熔断

在Java中,常用的熔断框架有Hystrix和Resilience4j。

定义:

  • 拒绝新的请求,直到微服务恢复

目的:

  • 熔断可以给服务端恢复的机会。

怎么判断微服务出现了问题?

  • 一是阈值如何选择;
    • 根据它的整体响应时间设定一个阈值,原则上阈值应该明显超过正常响应时间。比如你经过一段时间的观测之后,发现这个服务的 99 线是 1s,那么你可以考虑将熔断阈值设定为 1.2s。
  • 二是超过阈值之后,要不要持 续一段时间才触发熔断。
    • 要求响应时间超过一段时间之后才触发熔断。这主要是出于两个考虑,一个是响应时间可能是偶发性地突然增长;另外一个则是防止抖动。
    • “一段时间”究竟有多长?
      • 根据经验来设定一个值,比如说三十秒或者一分钟。

怎么知道微服务恢复了?

  • 大多数情况下就是触发熔断之后保持一段时间, 比如说一分钟,一分钟之后就认为服务已经恢复正常,继续处理新请求。
  • 抖动问题:
    • 抖动就是服务频繁地在正常-熔断两个状态之间切换。
  • 服务端触发熔断之后,客户端就直接不再请求这个节点了,而是换一个节点。等到恢复了之后,客户端再逐步对这个节点放开流量。
  • 解决这个抖动问题,就需要在恢复之后控制住流量。比如说按照 10%、20%、30%……逐步递增,而不是立刻恢复 100% 的流量。

面试建议:

  • “怎么保障微服务可用性”
    • 假如说你准备用响应时间来作为指标,那么你可以这么回答,关键词是持续超过阈值。
    • 为了保障微服务的可用性,我在我的核心服务里面接入了熔断。针对不同的服 务,我设计了不同的微服务熔断策略。
    • 比如说最简单的熔断策略就是根据响应时间来进行。当响应时间超过阈值一段 时间之后就会触发熔断。我一般会根据业务情况来选择这个阈值,例如,如果 产品经理要求响应时间是1s,那么我会把阈值设定在1.2s。如果响应时间超过 1.2s,并且持续三十秒,就会触发熔断。在触发熔断的情况下,新请求会被拒 绝,而已有的请求还是会被继续处理,直到服务恢复正常。
  • 衍生问题:
    • 这阈值还可以怎么确定?那么你就回答还可以根据观测到的响应时间数据来确定。
    • 这个持续三十秒是如何计算出来的?这个问题其实可以坦白回答是基于个人经验,然后你解释一下过长或者过短的弊端就可以了。
    • 为什么多了0.2s?那么你可以解释是留了余地,防止偶发性的响应时间变长的情况。
    • 怎么判断服务已经恢复正常了?那么你可以回答等待一段固定的时间,然后尝试逐渐放开流量。
  • 根据自己的业务特征选用了一些比较罕见的指标,或者你设计的触发熔断的 条件比较有特色,那么也可以用自己的实际方案。
    • 这里我给你另外一个微创新的方案,关键词是缓存崩溃。
    • 我还设计过一个很有趣的熔断方案。我的一个接口并发很高,对缓存的依赖度 非常严重。所以我的熔断策略是要是缓存不可用,比如说 Redis 崩溃了,那么 我就会触发熔断。这里如果我不熔断的话,请求会因为 Redis 崩溃而全部落到 MySQL 上,基本上会压垮 MySQL。 在触发熔断之后,我会额外开启一个线程持续 不断地 ping Redis。如果 Redis 恢复了,那么我就会退出熔断状态,新来的请 求就不会被拒绝了。
  • 引导点:
    • 缓存问题:在这里我提到了 Redis 失效,这种情况类似于缓存雪崩,那么你很自然地就可以把话题引导到如何处理缓存击穿、穿透、雪崩这些经典问题上。 高可用 MySQL:我在这里使用的是熔断来保护 MySQL,类似地,你也可以考虑用限流来保护MySQL。
  • 如果面试官了解抖动问题,那么他就肯定会追问“你是一次性 放开全部流量吗?”,那么你就可以阐述抖动的问题,然后总结一下。
    • 我这种逐步放开流量的方案其实还是有缺陷的,还有一些更加高级的做法,但 是需要负载均衡来配合。
    • 整体思路是利用负载均衡来控制流量。如果一个服务端节点触发了熔断,那么 客户端在做负载均衡的时候就可以将这个节点挪出可用列表,后续请求会发给 别的节点。在经过一段时间之后,客户端可以尝试发请求给该节点。如果该节点正确处理了,那客户端就可以加大流量。否则客户端就要再一次等待一段时间。
    • 万一所有可用节点都触发熔断了,应该怎么办?
    • 这个方案是需要兜底的,比如说如果因为某些原因数据库出问题,导致某个服 务所有的节点都触发了熔断,那么客户端就完全没有可用节点了。不过这个问 题本身熔断解决不了,负载均衡也解决不了,只能通过监控告警之后人手工介 入处理了。

5降级

定义:

  • 就比如在双十一之类的大促高峰,平台是会关闭一些服务的,比如退款服务。

好处

  • 一方面是腾出了服务器资源,可以给订单服务或者支付服务;另 外一方面是减少了对公共组件的压力,比如说减少了对数据库的写入压力。

如何降级?

  • 跨服务降级
    • 当资源不够的时候可以暂停某些非核心服务,将腾出来的资源给其他更加重要、更 加核心的服务使用。
    • 跨服务降级常见的做法有三个:
      • 整个服务停掉,例如前面提到的停掉退款服务。
      • 停掉服务的部分节点,例如十个节点,停掉其中五个节点,这五个节点被挪作他用。
      • 停止访问某些资源。例如日志中心压力很大的时候,发信号给某些不重要的服务,让它们停止上传日志,只在本地保存日志。
  • 提供有损服务
    • 在没有触发降级的时候,App 首页是针对你个人用户画像的个性化推荐。而在触发了降级之后,则可能是使用榜单数据,或者使用一个运营提前配置好的静态页面。
    • 常见的降级思路:
      • 返回默认值,这算是最简单的一种状况。 禁用可观测性组件,正常来说在业务里面都充斥了各种各样的埋点。这些埋点本身其实是会带来消耗的,所以在性能达到瓶颈的时候,就可以考虑停用,或者降低采样率。 同步转异步,即正常情况下,服务收到请求之后会立刻处理。但是在降级的情况下,服务在收到请求之后只会返回一个代表“已接收”的响应。后续服务会异步地开启线程来处理,或者依赖于定时任务来处理。 简化流程,如果你处理一个请求需要很多步骤,后续如果有一些步骤不关键的话,可以考虑不执行,或者异步执行。例如在内容生产平台,一般新内容要被推送到推荐系统里面。那么在降级的情况下你可以不推,而后可以考虑异步推送过去,也可以考虑等系统恢复之后再推送过去。

面试建议:

  • 我在公司也用了降级来保护我维护的服务。举例来说,正常情况下我的服务都 会全量采集各种监控指标。那么在系统触及性能瓶颈的时候,我就会调整采集 的比率。甚至在关键的时候,我会直接停用掉所有的指标采集,将资源集中在 提供服务上。
  • 亮点方案
    • 我在公司维护了一个服务,它的接口可以分成两类:一类是给 B 端商家使用的 录入数据的接口,另外一类是给 C 端用户展示这些录入的数据。所以从重要性 上来说,读服务要比写服务重要得多,而且读服务也是一个高并发的服务。
除了这种 B 端录入 C 端查询的场景,还有很多类似的场景也适用。 于是我接入了一个跨服务的降级策略。当我发现读服务的响应时间超过了阈值 的时候,或者响应时间开始显著上升的时候,我就会将针对 B 端商家用户的服 务临时停掉,腾出来的资源都给 C 端用户使用。对于 B 端用户来说,他们这个 阶段是没有办法修改已经录入的数据的。但是这并不是一个特别大的问题。当 C 端接口的响应时间恢复正常之后,会自动恢复 B 端商家接口,商家又可以修 改或者录入数据了。 - 虽然整体来说写服务 QPS 占比很低,但是对于数据库来说,一次写请求对性能 的压力要远比一次读请求大。所以暂停了写服务之后,数据库的负载能够减轻 不少。 - 在内容生产平台,作者生产内容,C 端用户查看生产的内容。那么在资源不足的情况下可以考虑停掉内容生产端的服务,只保留 C 端用户查看内容的功能。 如果你的用户分成普通用户和 VIP 用户,那么你也可以考虑停掉给普通用户的服务。甚至,如果一个服务既提供给普通用户,也提供给 VIP 用户,你可以考虑将普通用户请求拒绝掉,只服务 VIP 用户。 - 这个方案就是典型的跨服务降级。跨服务降级可以在大部分合并部署的服务里 面使用,一般的原则就是 B、C端合并部署降级 B 端;付费服务和非付费服务 降级非付费服务。当然也可以根据自己的业务价值,将这些部署在同一个节点 上的服务分成三六九等。而后在触发降级的时候从不重要的服务开始降级,将 资源调配给重要服务。
  • 快慢路径降级慢路径
    • 只查缓存。
      • 我还用过另外一个降级方案。正常来说在我的业务里面,就是查询缓存,如果 缓存有数据,那么就直接返回。如果缓存没有,那么就需要去数据库查询。如 果此时系统的并发非常高,那么我就会采取降级策略,将请求标记为降级请 求。降级请求只会查询缓存,而不会查询数据库。如果缓存没有,那就直接返 回错误。这样能够有效防止因为少部分请求缓存未命中而占据大量系统资源, 导致系统吞吐量下降和响应时间显著升高。
    • 快慢路径。
      • 这种思路其实可以在很多微服务里面应用。如果一个服务可以分成快路径和慢 路径两种逻辑,那么在降级之前就可以先走快路径,再走慢路径。而触发了降 级之后,就只允许走快路径。在前面的例子里面,从缓存里加载数据就是快路 径,从数据库里面加载数据就是慢路径。 慢路径还可以是发起服务调用或者复杂计算。比如说一个服务快路径是直接查询缓存,而慢路径可能是发起很多微服务调用,拿到所有响应之后一起计算, 算出来一个结果并缓存起来。那么在降级的时候,可以有效提高吞吐量。不过 这种吞吐量是有损的,毕竟部分请求如果没有在缓存中找到数据,那么就会直 接返回失败响应。

6限流

静态算法

  • 令牌桶
    • 系统会以一个恒定的速率产生令牌,这些令牌会放到一个桶里面,每个请求只有拿到了令牌才会被执行。每当一个请求过来的时候,就需要尝试从桶里面拿一个令牌。如果拿到了令牌,那么请求就会被处理;如果没有拿到,那么这个请求就被限流了。
  • 漏桶
    • 漏桶是指当请求以不均匀的速度到达服务器之后,限流器会以固定的速率转交给业务逻辑。
  • 固定窗口
    • 固定窗口是指在一个固定时间段,只允许执行固定数量的请求。比如说在一秒钟之内只能执行 100 个请求。
  • 滑动窗口
    • 滑动窗口类似于固定窗口,也是指在一个固定时间段内,只允许执行固定数量的请求。区别就在于,滑动窗口是平滑地挪动窗口,而不像固定窗口那样突然地挪动窗口。

动态算法

  • 自适应限流算法,典型的是 BBR 算法。
  • BBR算法的主要思想是:通过观察网络的传输速率(bandwidth)和往返时间(round-trip time,RTT)来理解网络的当前状态,然后根据这些信息来调整数据的发送速率。这种方法与传统的TCP拥塞控制算法(如Cubic和Reno)有所不同,后者主要是通过观察数据包的丢失来判断网络的拥塞程度。

限流对象

  • 集群限流
    • redis或网关
  • 单机限流
  • 业务对象限流
    • VIP 用户不限流而普通用户限流。 针对 IP 限流。用户登录或者参与秒杀都可以使用这种限流,比方说设置一秒钟最多只能有50 个请求,即便考虑到公共 IP 的问题,正常的用户手速也是没那么快的。 针对业务 ID 限流,例如针对用户 ID 进行限流。

限流后的做法

  • 直接拒绝
  • 同步阻塞等待一段时间。
  • 同步转异步。
  • 调整负载均衡算法。

面试建议

  • 在讨论对外的API,如HTTP 接口或者公共API时,可以强调使用限流来保护系统。 在讨论TCP拥塞控制时,你可以提起在服务治理上限流也借鉴了TCP拥塞控制的一些思想。 在讨论Redis或者类似产品的时候,你可以提你用 Redis 实现过集群限流。
    • 使用Redis实现集群限流的常见方法是使用Redis的滑动窗口算法。以下是一个基本的实现步骤:
  1. 定义一个滑动窗口:滑动窗口是一个时间段,例如1秒钟,用于限制这个时间段内的请求次数。
  1. 每次请求时,向Redis中的一个列表添加一个元素:这个元素可以是当前的时间戳。列表的名称可以是根据请求的特性来定义的,例如用户ID或IP地址。
  1. 移除窗口之外的元素:使用Redis的LTRIM命令,移除列表中,时间戳小于(当前时间-窗口大小)的元素。
  1. 检查列表的长度:使用Redis的LLEN命令,检查列表的长度。如果长度超过了你设定的最大请求次数,那么就拒绝新的请求。
  • 算法、限流对象和限流后的做法,最后再把话题引到计算阈值上。
    • 限流是为了保证系统可用性,防止系统因为流量过大而崩溃的一种服务治理手 段。从算法上来说,有令牌桶、漏桶、固定窗口和滑动窗口算法。还有动态限 流算法,或者说自适应限流算法,比较有名的就是参考了 TCP 拥塞控制算法 BBR 衍生出来的算法,比如说 B 站开源的 Kratos 框架就有一个实现。这些算 法之间比较重要的一个区别是能否处理小规模的突发流量。 从限流对象上来说,可以是集群限流或者单机限流,也可以是针对具体业务来 做限流。比如说在登录的时候,我们经常针对 IP 进行限流。又或者在一些增值 服务里面,非付费用户也会被限流。 触发限流之后,具体的措施也可以非常灵活。被限流的请求可以同步阻塞一段 时间,也可以考虑同步转异步。如果负载均衡算法灵活的话,也可以做一些调 整,减少发到该节点的概率。 用好限流的一个重要前提是能够设置准确的阈值,例如每秒钟限制在 100 个请 求还是限制在 200 个请求。如果阈值过低,那么系统资源就容易闲置浪费;如 果阈值太高,那么系统可能撑不住那么多流量,导致崩溃。
  • IP限流
    • 我在我们公司的登录接口里面就引入了限流机制。正常情况下,一个用户在一 秒钟内最多点击一次登录,所以针对每一个 IP,我限制它最多只能在一秒内提 交 50 次登录请求。这个 50 充分考虑到了公共 IP 的问题,正常用户是不可能 触发这个阈值的。这个限流虽然很简单,但是能够有效防范一些攻击。不过限 流再怎么防范,还是会出现系统撑不住流量的情况。
  • 突发流量
    • 漏桶做不到
    • 令牌桶可以
    • 漏桶算法非常均匀,但是令牌桶相比之下就没那么均匀。令牌桶本身允许积攒 一部分令牌,所以如果有偶发的突发流量,那么这一部分请求也能得到正常处 理。但是要小心令牌桶的容量,不能设置太大。不然积攒的令牌太多的话就起 不到限流效果了。例如容量设置为 1000,那么要是积攒了 1000 个令牌之后真 的突然来了 1000 个请求,它们都能拿到令牌,那么系统可能撑不住这突如其 来的 1000 个请求。
  • 毛刺问题
    • 固定窗口和滑动窗口
    • 假如一个窗口大小是一分钟 1000 个请求,你预计这1000 个请求会均匀分散在这一分钟内。 那么有没有可能第一秒钟就来了 1000 个请求?当然可能。那当下这一秒系统有没有可能崩溃?自然也是可能的。
    • 所以固定窗口和滑动窗口的窗口时间不能太长。比如说以秒为单位是合适的,但是以分钟作为单位就是不合适的。
  • 请求大小
    • 限流和负载均衡有点儿像,基本没有考虑请求的资源消耗问题。所以负载均衡 不管怎么样,都会有偶发性负载不均衡的问题,限流也是如此。例如即便我将 一个实例限制在每秒 100 个请求,但是万一这个 100 个请求都是消耗资源很多 的请求,那么最终这个实例也可能会承受不住负载而崩溃。动态限流算法一定 程度上能够缓解这个问题,但是也无法根治,因为一个请求只有到它被执行的 时候,我们才知道它是不是大请求。
  • 计算阈值
    • 总体上思路有四个:看服务的观测数据、压测、借鉴、手动计算。
    • 我们公司有完善的监控,所以我可以通过观测到的性能数据来确定阈值。比如 说观察线上的数据,如果在业务高峰期整个集群的 QPS 都没超过 1000,那么 就可以考虑将阈值设定在 1200,多出来的 200 就是余量。 不过这种方式有一个要求,就是服务必须先上线,有了线上的观测数据才能确定阈值。并且,整个阈值很有可能是偏低的。因为业务巅峰并不意味着是集群 性能的瓶颈。如果集群本身可以承受每秒3000个请求,但是因为业务量不够, 每秒只有 1000 个请求,那么我这里预估出来的阈值是显著低于集群真实瓶颈 QPS 的。
    • 压测
      • 不过我个人觉得,最好的方式应该是在线上执行全链路压测,测试出瓶颈。即 便不能做全链路压测,也可以考虑模拟线上环境进行压测,再差也应该在测试 环境做一个压力测试。
    • 性能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得到阈值。
    • 升华
      • 最好还是把阈值做成可以动态调整的。那么在最开始上线的时候就可以把阈值 设置得比较小。后面通过观测发现系统还很健康,就可以继续上调阈值。

7隔离

定义:

  • 在出现故障的时候,隔离可以把影响限制在一个可以忍受的范围内。
  • 隔离是通过资源划分,在不同服务之间建立边界,防止相互影响的一种治理措施。

目的:

  • 提升可用性,也就是说防止被影响或防止影响别人。这部分也叫做故障隔离。
  • 提升性能,这是隔离和熔断、降级、限流不同的地方,一些隔离方案能够提高系统性能,而且有时候甚至能做到数量级提升。
  • 提升安全性,也就是为安全性比较高的系统提供单独的集群、使用更加严苛的权限控制、迎合当地的数据合规要求等。

一般原则

  • 核心与核心隔离,核心与非核心隔离

机房隔离

  • 与多活区别
    • 隔离指的是不同服务分散在不同的机房, 多活强调的是同一个服务在不同的城市、不同的机房里面有副本。

实例隔离

  • 实例隔离是指某个服务独享某个实例的全部资源。
  • 实例隔离就是指服务独享了这个实例,没有和其他组 件共享。
  • 其他情况:
    • 虽然你买了很多实例,但是这些实例在云厂商 那里都是同一个物理机虚拟出来的。

分组隔离

  • 定义
    • 是指一起部署的服务上有多个接 口或者方法,那么就可以利用分组机制来达成隔离的效果。
  • B 端一个组,C 端一个组。 普通用户一个组,VIP 用户一个组。 读接口一个组,写接口一个组。这种也叫做读写隔离。比如说在生产内容的业务里面,没有实行制作库和线上库分离的话,那么就可以简单地把读取内容划分成一个组,编辑内容划分成另外一个组。 快接口一个组,慢接口一个组。这个和前面的读写隔离可能会重叠,因为一般来说读接口就是比较快。

连接池隔离和线程池隔离

  • 一般的做法都是给核心服务单独的连接池和线 程池。
  • 独享线程池
  • 共享线程池

线程池

  • ThreadPoolExecutor executor = new ThreadPoolExecutor( 5, // corePoolSize 10, // maximumPoolSize 60, // keepAliveTime TimeUnit.SECONDS, // unit new LinkedBlockingQueue(), // workQueue Executors.defaultThreadFactory(), // threadFactory new ThreadPoolExecutor.AbortPolicy() // handler );
    • Java的ThreadPoolExecutor提供了以下四种拒绝策略:
  1. AbortPolicy:直接抛出异常,这是默认策略。
  1. CallerRunsPolicy:只用调用者所在的线程来运行任务。
  1. DiscardOldestPolicy:丢弃队列里最旧的未处理任务,然后尝试再次提交当前任务。
  1. DiscardPolicy:直接丢弃任务,但是不抛出异常。
  • 这个线程池的基本大小是5,最大大小是10,空闲线程的存活时间是60秒,工作队列是一个LinkedBlockingQueue,线程工厂是默认的线程工厂,处理程序是AbortPolicy(当线程池饱和时抛出异常)。

第三方依赖隔离

  • 定义
    • 是指为核心服务或者热点专门提供数据库集群、消息队列集群等第三方依赖集 群。

面试建议:

  • 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 端线上库几乎都是读流量,性能很好。

子主题

8超时控制

超时控制目标

  • 一是确保客户端能在预期的时间内拿到响应
  • 二是及时释放资源
    • 及时释放资源是提高系统可用性的有效做法

定义

  • 指在规定的时间内完成操作,如果不能完成,那么就返 回一个超时响应。

超时控制形态

  • 形态上分为两种
    • 调用超时控制,比如说你在调用下游接口的时候,为这一次调用设置一个超时时间。
    • 链路超时控制,是指整条调用链路被一个超时时间控制。比如说你的业务有一条链路是 A调用 B,B 调用 C。如果链路超时时间是 1s,首先 A 调用 B 的超时时间是 1s,如果 B 收到请求的时候已经过去了 200ms,那么 B 调用 C 的超时时间就不能超过 800ms

确定超时时间

  • 4 种确定超时时间的方式
    • 根据用户体验来确定、根据被调用接口的响应时间来确定、根据压测结果来确定、根据代码来确定。

根据用户体验

  • 一般的做法就是根据用户体验来决定超时时间。

根据响应时间

  • 所谓的 99 线是指 99% 的请求,响应时间都在这个值以内。比如说 99 线为 1s,那么意味着99% 的请求响应时间都在 1s 以内。999 线也是类似的含义。

压力测试

  • 你可以通过压力测试来找到被调用接口的 99 线和 999 线。而且压力测试应该尽可 能在和线上一样的环境下进行

根据代码计算

  • 假如说你现在有一个接口,里面有三次数据库操 作,还有一次访问 Redis 的操作和一次发送消息的操作,那么你接口的响应时间就应该这样计算:
  • 接口的响应时间 = 数据库响应时间 × 3 + Redis响应时间 + 发送消息的响应

超时中断业务

  • 所谓的中断业务是指,当调用一个服务超时之后,这个服务还会继续执行吗? 答案是基本上会继续执行,除非服务端自己主动检测一下本次收到的请求是否已经超时了。
  • 举例来说,如果你的业务逻辑有 A、B、C 三个步骤。假如说你执行到 B 的时候超时了,如果你的代码里面没有检测到,那么还是会继续执行 C。但是如果你主动检测了超时,那么你就可以在 B 执行之后就返回。

监听超时时间

  • 主流客户端
  • 客户端
    • 框架客户端监听超时时间的情况下,如果在发起请求之前,就已经超时了,那么框架客户端根本不会发起请求,而是直接返回超时响应。
    • 如果框架客户端已经发出了请求,之后触发了超时时间,那么框架客户端就会直接返回一个超时错误给应用代码。后续服务端返回了响应,框架客户端会直接丢弃。
  • 服务端
    • 如果在收到请求的时候就已经超时了,那么框架服务端根本 不会调用服务端应用代码,而是直接给框架客户端返回一个超时响应。
    • 如果在等待业务响应的时候触发了超时,框架服务端会立刻释放连接,继续处理下一个请求。那么当应用返回响应的时候,它会直接丢弃,不会再回写响应

面试建议:

  • 问一下你在调用别的服务的时候有没有设置超时时间?
  • 我会设置超时时间,一般来说设置超时时间是为了用户体验和及时释放资源。比如说我有一个接口是提供给首页使用的,整个接口要求的超时时间是不超过 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 一收到请求,就会认为这个请求已经超时了。
    • 你提到了难以知道 10ms 的问题,那么面试官自然就会问你该怎么知道网络 传输耗时 10ms。换句话来说,你怎么计算请求的网络传输时间。
      • 计算网络传输时间最好的方式就是使用性能测试。在模拟线上环境的情况下,让客户端发送平均大小的请求到服务端,采集传输时间,取一个平均值作为网络传输时间。另外一个方式就是不管。比如说正常情况下,A 调用 B,A 和 B 都在同一个机房,网络传输连 1ms 都不用。相比我们超时时间动辄设置为几百毫秒,这一点时间完全可以忽略不计。不过万一服务涉及到了跨机房,尤其是那种机房在两个城市的,城市还离得远的,这部分时间就要计算在 内。
      • 你还可以额外强调一下,性能测试要完全模拟线上环境,否则计算就会有偏差。
      • 性能测试一定要尽可能模拟线上环境,尤其是线上环境可能会有更加复杂的网关和防火墙设置,这部分也会影响传输速率。
    • 链路超时还有一个弊端,也是面试官经常问的,就是如果 A 调用 B,B 调用 C 的这条链路的超时时间设置为 1s,但是 B 这个服务的提供者就说自己是不可能在 1s 内返回响应的,那么该怎么办?
      • 这个时候最好的做法是强制要求 B 优化它的性能。比如说产品经理明确说这条链路就是要在 1s 内返回,那么 B 就应该去优化性能,而不是在这里抱怨不可能在 1s 内返回。不过要是 A 本身超时时间可以妥协的话,那么 A 调大一点也可以。
      • 可以考虑请 B 的维护者喝杯奶茶,吃顿小烧烤,基本上都能解决问题。实在不行,就只能走官方渠道,找领导和产品经理出面,去找 B 的维护者的上级。不过闹到这一步关系就会比较僵,还是优先考虑请奶茶小烧烤的方案。

子主题

9调用第三方

我的系统对可用性要求非常高,为此我综合使用了熔断、限流、降级、超时控

制等措施。并且,我这个系统还有一个特别之处,就是它需要和很多第三方平 台打交道。所以要想保证系统的可用性,我就需要保证和第三方打交道是高可 用的。

子主题

面试建议:

  • 同步转异步
    • 正常来说我们推送数据都是尽可能实时推过去,但是有些时候业务方推过来的 数据太多,又或者第三方崩溃,那么我就会临时将数据存起来。后面第三方恢 复过来了,再逐步将数据同步过去。这算是比较典型的同步转异步用法。
  • 解耦
    • 我们这种容错机制其实完全可以做成利用消息队列来彻底解耦的形式。在这种 解耦的架构下,业务方不再是同步调用一个接口,而是把消息丢到消息队列里面。然后我们的服务不断消费消息,调用第三方接口处理业务。等处理完毕再将响应通过消息队列通知业务方。
  • 升华
    • 同步调用与异步解耦两种方式,可以看作是对接不同业务方的通用范式。一般 而言但凡能异步解耦的,我绝不搞什么同步调用。
  • 自动替换
    • 为了进一步提高可用性,降低因为第三方故障引起事故的概率,我在调用第三 方这里引入了自动替换机制。我们本来有多个第三方,相互之间是可以替换 的,于是我就做了一个简单的自动切换机制。当我发现第三方接口出现故障的 时候,就会切换到一个新的第三方
  • 压测支持
    • 早期为了弄清楚服务的吞吐量和响应时间瓶颈,我搞过一些压测。但这些流量 不能真的调用第三方,所以我为了解决压测这个问题,设计了两个东西。 一个是模拟第三方的响应时间。不过这种模拟是比较简单的,就是在代码里面 睡眠一段时间,这段时间是第三方接口的平均响应时间加上一个随机偏移量计 算得出的。另一个是在并发非常高的情况下,会触发我的容错机制。 而且我这里留好了接口,万一我们公司要做全链路压测了,我这边也可以根据链路元数据将压测流量转发到 mock 逻辑,而真实业务请求则会发起真实调用。

10综合服务治理

面试建议:

  • 发现问题
    • 某某业务是我们公司的核心业务,它的核心困难是需要保证高可用。在我刚入 职的时候,这个系统的可用性还是比较低的。比如说我刚入职的第一个月就出 了一个比较严重的线上故障,别的业务组突然上线了一个功能,带来了非常多 的 Redis 大对象操作,以至于 Redis 响应非常慢,把我们的核心服务搞超时 了。 后面经过调研,我总结下来,系统可用性不高主要是这三个原因导致的。
  1. 缺乏监控和告警,导致我们难以发现问题,难以定位问题,难以解决问题。
  1. 缺乏服务治理,导致某一个服务出现故障的时候,整个系统都不可用了。
  1. 缺乏合理的变更流程。我们每次复盘 Bug 时候,都觉得如果有更加合理的变 更流程的话,那么大部分事故都是可以避免的。
  • 计划方案
    • 针对这些具体的点,我的可用性改进计划分成了几个步骤。
  1. 引入全方位的监控与告警,这一步是为了快速发现问题和定位问题。
  1. 引入各种服务治理措施,这一步是为了提高服务本身的可用性,并且降低不 同服务相互之间的影响。 如果你们公司有非常完善的基础设施和强大的技术实力,那么你可以加上像全链路压测、混沌工程、故障演练等高端方案,作为你整个计划中的一部分。 落地实施然后你再讲落地实施。落地实施的时候你要补充细节,同时也可以掺杂一些落地过程中的痛点。
  1. 为所有第三方依赖引入高可用方案,这一步是为了提高第三方依赖的可用 性。
  1. 拆分核心业务与非核心业务的共同依赖。这一步是为了进一步提高核心业务 的可用性。
  1. 规范变更流程,降低因为变更而引入 Bug 的可能性。
  • 落地实施
    • 在第一个步骤里面,就监控来说,既要为业务服务添加监控和告警,又要为第 三方依赖增加监控,比如说监控数据库、Redis 和消息队列。而告警则要综合 考虑告警频率、告警方式以及告警信息的内容是否足够充足,减少误报和谎 报。本身这个东西并不是很难,就是非常琐碎,要一个个链路捋过去,一个个 业务查漏补缺。 就第二个步骤来说,服务治理包括的范围比较广,我使用过的方案也比较多, 比如说限流熔断等等。 第三个步骤遇到了比较大的阻力,主要是大部分第三方依赖的高可用方案都需 要资金投入。比如说最开始我们使用的 Redis 就是一个单机 Redis,那么后面 我尝试引入 Redis Cluster 的时候,就需要部署更多的实例。 第四个步骤也是执行得不彻底。现在的策略就是新的核心业务会启用新的第三 方依赖集群,比如说 Redis 集群,但是老的核心业务就保持不动。 第五个步骤是我在公司站稳脚跟之后跟领导建议过几次,后来领导就制定了新 的规范,主要是上线规范,包括上线流程、回滚计划等内容。
  • 其他
    • 依赖第三方
      • 不过我的服务还依赖于一些同事提供的服务,而他们的服务可用性就还是比较 差。我这边只能是说尽量做到容错,比如说提供有损服务。后面要想进一步提 高可用性,还是得推动同事去提高可用性。
    • 面试官质疑你为什么三个九也敢说是高可用
      • 我也一直在想办法进一步提高可用性,但是整个系统要做到四个九还是非常难 的,需要整个公司技术人员一起努力才能达到。我在公司的影响力还局限在我 们部门,困难比较多,暂时做不到那么高的可用性。
  • 后续改进
    • 目前我的服务,尤其是一些老服务,相互之间还是在共享一些基础设施。一个 出问题就很容易牵连其他服务,所以我还需要进一步将这些老服务解耦。
    • 比如说,我一定要让我的全部服务都使用我自己所在组的数据库实例,省得因 为别组的同事搞崩了数据库,牵连到我的业务。大家一起用一个东西,出了事 别人死不认账,甩锅都甩不出去。
  • 异步/解耦。
    • 我还全面推行了异步/解耦。我将核心业务的逻辑一个个捋过去,再找产品经理 确认,最终将所有的核心业务中能够异步执行的都异步执行,能够解耦的都解 耦。这样在我的业务里面,需要同步执行的步骤就大大减少了。而后续异步执 行的动作,即便失败了也可以引入重试机制,所以整个可用性都大幅度提升 了。 比如说在某个场景下,整个逻辑可以分成很明显的两部分,必须要同步执行的 A 步骤和可以异步执行的 B 步骤。那么在 A 步骤成功之后,再发一条消息到消 息队列。另外一边消费消息,执行 B 步骤。
  • 自动故障处理
    • 为了进一步提高整个集群服务的可用性,我跟运维团队进行密切合作,让他们 支持了自动扩容。整个设计方案是允许不同的业务方设置不同的扩容条件,满 足条件之后运维就会自动扩容。比如说我为我的服务设置了 CPU 90% 的指 标。如果我这个服务所有节点的 CPU 使用率都已经超过了 90%,并且持续了 一段时间,那么就会触发自动扩容,每次扩容会新增一个节点。
    • CPU 使用率长期处于高位,基本上代表节点处于高负载状态。并且我强调的是 集群里面的节点都超过了这个指标,防止单一节点超过该指标之后引起不必要 的扩容。比如说,万一某个节点非常不幸,处理的都是复杂的请求,那么它就 会处于高负载的状态,但是其他节点其实负载还很低。那么这个时候扩容,并 没有什么效果。

子主题

11模拟面试

子主题

12数据库索引

为什么选择B+树而不是B树

  • 与B+ 树相比,B 树的数据存储在全部节点中,对范围查询不友好。非叶子节点存储了数据,导致内存中难以放下全部非叶子节点。如果内存放不下非叶子节点,那么就意味着查询非叶子节点的时候都需要磁盘 IO。

B+树的优势

  • B+ 树的高度和二叉树之类的比起来更低,树的高度代表了查询的耗时,所以查询性能更好。
  • B+ 树的叶子节点都被串联起来了,适合范围查询。
  • B+ 树的非叶子节点没有存放数据,所以适合放入内存中。

为什么数据库不使用索引?

  • 使用了 !=、LIKE 之类的查询。
  • 字段区分度不大。比如说你的 status 列只有 0 和 1 两个值,那么数据库也有可能不用。
  • 使用了特殊表达式,包括数学运算和函数调用。
  • 数据量太小,或者 MySQL 觉得全表扫描反而更快的时候。
  • 有一种说法是含有 NULL 的列上的索引会失效,不过这个说法并不准确,实际 上 MySQL 还是会尽可能用索引的。

索引与 NULL

  • 首先 MySQL 本身会尽可能使用索引,即便索引的某个列里面有零值,并且 IS NULL 和 IS NOT NULL 都可以使用索引。
  • 其次 MySQL 的唯一索引允许有多行的值都是 NULL。也就是说你可以有很多行唯一索引的列的值都是 NULL。但是不管怎么说,使用 NULL 都是一个比较差的实践。

子主题

13SQL优化

执行计划

  • Subtopic
  • 在MySQL中,EXPLAIN的结果包括以下列:
  • id:查询的标识符。
  • select_type:查询的类型(例如:SIMPLE,SUBQUERY,UNION等)。
  • table:输出结果集的表。
  • type:连接类型(例如:ALL,index,range,ref等)。
  • possible_keys:可能应用在这个表中的索引。
  • key:实际使用的索引。
  • key_len:使用的索引的长度。
  • ref:哪些列或常量被用于查找索引列。
  • rows:MySQL认为必须检查的用来返回请求数据的行数。
  • Extra:关于MySQL如何解析查询的额外信息。
  • Extra 是否使用覆盖索引

选择索引列

  • 外键,一般都会用于关联、过滤数据,所以正常来说都会为表的外键创建索引。 频繁出现在 WHERE 中的列,主要是为了避免全表扫描。 频繁出现在 ORDER BY 的列,这是为了避免数据库在查询出来结果之后再次排序。 频繁出现在关联查询的关联条件中的列。不过一般我们都不建议使用关联查询,所以几乎可以忽略这个。 区分度很高的列。比如每一行的数据都不同的列,并且在创建组合索引的时候,区分度很高的列应该尽可能放到左边。

大表表结构变更

  • 所以当你发现你的 MySQL 性能不行了,准备新加一个索引的时候,如果这个表的数据很多,那么在你执行加索引的命令的时候,整张表可能都会被锁住几分钟甚至几个小时。
  • 停机变更,就是把业务停下来,然后更新表结构。如果做得更加精细一点,那么就可以说只把和这个表有关的功能下线,但不需要将整个服务或者系统下线。
  1. 在业务低谷变更,比停机更新好一点,但是业务依旧受到了影响。而且万一你以为在低谷能完成变更,结果并没有,那么你就面临着业务在高峰期也不能用的问题。
  1. 创建新表,这是不停机又不想业务受到影响的方案。具体来说就是创建一张新表,这张新表就是你准备用的新的表定义。然后将旧表的数据迁移过去,我们在后面会专门讨论数据迁移方案。

面试建议:

  • 我们公司有 SQL 的慢查询监控,当我们发现接口响应时间比较差的时候,就会 去排查 SQL 的问题。我们主要是使用 EXPLAIN 命令来查看 SQL 的执行计划, 看看它有没有走索引、走了什么索引、是否有内存排序、去重之类的操作。 初步判定了问题所在之后,我们尝试优化,包括改写 SQL 或者修改、创建索 引。之后再次运行 SQL 看看效果。如果效果不好,就继续使用 EXPLAIN 命 令,再尝试修改。如此循环往复,直到 SQL 性能达到预期
  • 覆盖索引
    • 比如说你执行最多的 SELECT 语句是 SELECT A, B, C 三个 列,而且 WHERE 里面也只有这三个列的条件,那么就可以考虑直接创建一个 <A, B, C>组合索引。
    • 原来我们有一个执行非常频繁的 SQL。这个 SQL 查询全部的列,但是业务只会 用到其中的三个列 A B C,而且 WHERE 条件里面主要的过滤条件也是这三个 列组成的,所以我后面就在这三个列上创建了一个组合索引。 对于这个高频 SQL 来说,新的组合索引就是一个覆盖索引。所以我在创建了索 引之后,将 SQL 由 SELECT * 改成了 SELECT A, B, C,完全避免了回表。 这么一来,整个查询的查询时间就直接降到了 1ms 以内。
  • 优化order by
    • 我在公司优化过一个 SQL,这个 SQL 非常简单,就是将某个人的数据搜索出 来,然后按照数据的最后更新时间来排序。SQL 大概是 SELECT * FROM xxx WHERE uid = 123 ORDER BY update_time。 如果用户的数据比较多,那么这个语句执行的速度还是比较慢的。后来我们做 了一个比较简单的优化,就是用 uid 和 update_time 创建一个联合索引。从数 据库原理上说,在 uid 确认之后,索引内的 update_time 本身就是有序的,所 以避免了数据库再次排序的消耗。这样一个优化之后,查询时间从秒级降到了 数十毫秒。
  • 优化 COUNT
    • 预估值
      • 我的这个场景对数据的准确性不是很高,所以我用了一个奇诡的方法,即用 EXPLAIN your_sql,之后用 EXPLAIN 返回的预估行数。比如说 SELECT COUNT() FROM xxx WHERE uid= 123,就可以用 EXPLAIN SELECT FROM xxx WHERE uid = 123 来拿到一个预估值。
    • 精确值
      • 不过如果需要精确值,那么就可以考虑使用 Redis 之类的NoSQL来直接记录总 数。或者直接有一个额外的表来记录总数也可以
  • 索引提示优化
    • 早期我们有一个表结构定义,上面有 A、B 两个索引。原本按照预期,我们是 认为这个查询应该会走 A 这个索引。结果实际用 EXPLAIN 命令之后,MySQL 却使用了 B 索引。所以我使用了 FORCE INDEX 之后强制查询使用 A 索引,果 然查询的响应时间降低到了毫秒级。
  • 用 WHERE 替换 HAVING
    • 如果不是使用聚合函数来作为过滤条件,最好还是将过滤条件优先写到 WHERE 里面。
    • 早期我们有一个历史系统,里面有一个 SQL 是很早以前的员工写的,比较随 意。他将原本可以用在 WHERE 里面的普通的相等判断写到了 HAVING 里面。 后来我将这个条件挪到了 WHERE 之后查询时间降低了40%。
  • 优化分页中的偏移量
    • 有一些 SQL 在不断执行中会产生极大的偏移量,比如说非常简单的文章列表分页,一页50条数据,那么当你要拿第 101 页的数据,需要写成 LIMIT 5000, 50。5000 就是偏移量。实际执行中数据库就需要读出 5050 条数据,然后将前面的 5000 都丢掉,只保留 50 条。
    • 在我们的系统里面,最开始有一个分页查询,那时候数据量还不大,所以一直 没出什么问题。后来数据量大了之后,我们发现如果往后翻页,页码越大查询 越慢。问题关键就在于我们用的 LIMIT 偏移量太大了。
    • 所以后来我就在原本的查询语句的 WHERE 里面加上了一个 WHERE id > max_id 的条件。这个 max_id 就是上一批的最大 ID。这样我就可以保证LIMIT 的偏移量永远是 0。这样修改之后,查询的速度非常稳定,一直保持在毫秒级。
    • select * from gen_studentinfo where sex = 1 and id > (select id from gen_studentinfo where sex = 1 limit start,1) limit 10;

子主题

14数据库锁

定义:

  • 加锁锁住的其实是索引项,更加具体地来说,就是锁住了叶子节点

释放锁时机

  • 只有在执行 Rollback 或者 Commit 的时候,锁才会被释放掉。

乐观锁与悲观锁

  • 乐观锁是直到要修改数据的时候,才检测数据是否已经被别人修改过。 悲观锁是在初始时刻就直接加锁保护好临界资源。

行锁与表锁

  • 如果你的查询没有命中任何的索引,那么 InnoDB 引擎是用不了行锁的,只能使用 表锁。

共享锁与排它锁

  • 共享锁是指一个线程加锁之后,其他线程还是可以继续加同类型的锁。 排它锁是指一个线程加锁之后,其他线程就不能再加锁了。
  • 这两个概念非常接近读锁和写锁。因为读锁本身就是共享的,而写锁就是排它的。

记录锁row lock、间隙锁Gap Lock和临键锁Next-key lock

  • 记录锁是指锁住了特定的某一条记录的锁。
  • 举个例子,如果数据库中只有 id 为(1,4,7)的三条记录,也就是 id= 3 这个条件没有命中任何数据,那么这条语句会在(1,4)加上间隙锁。所以你可以看到,在生产环境里面遇到了未命中索引的情况,对性能影响极大。
  • 间隙锁 间隙锁是锁住了某一段记录的锁。直观来说就是你锁住了一个范围的记录。
  • 记录锁和记录锁是排它的,但是间隙锁和间隙锁不是排它的。
  • 临键锁
    • 临键锁不仅仅是会用记录锁锁住命中的记录,也会用间隙锁锁住记录之间 的空隙。临键锁和数据库隔离级别的联系最为紧密,它可以解决在可重复读隔离级别之下的幻读问题。
    • 间隙锁是左开右开,而临键锁是左开右闭。还是用前面的例子来说明。如果 id 只有(1,4,7)三条记录,那么临键锁就将(1,4]锁住。
    • 临键锁是互斥的
  • 遇事不决临键锁。你可以认为,全部都是加临键锁的,除了下面两个子句提到的例外情况。 右边缺省间隙锁。例如你的值只有(1,4,7)三个,但是你查询的条件是 WHERE id <5,那么加的其实是间隙锁,因为 7 本身不在你的条件范围内。 等值查询记录锁。这个其实针对的是主键和唯一索引,普通索引只适用上面两条。

面试建议:

  • 索引: MySQL 的 InnoDB 引擎是借助索引来实现行锁的。 性能问题:锁使用不当引起的性能问题。 乐观锁:比如说原子操作中的 CAS 操作,你可以借助 CAS 这个关键词,聊一聊在 MySQL 层面上怎么利用类似的 CAS 操作来实现一个乐观锁。 语言相关的锁:比如说 Go 语言的 mutex 和 Java 的 Lock,都可以引申到数据库的锁。 死锁:聊一聊公司的数据库死锁案例。
  • MySQL 里面的锁机制特别丰富,这里我以 InnoDB 引擎为例。首先,从锁的 范围来看,可以分成行锁和表锁。其次,从排它性来看,可以分成排它锁和共 享锁。还有意向锁,结合排它性,就分为排它意向锁和共享意向锁。还有三个 重要的锁概念,记录锁、间隙锁和临键锁。记录锁,是指锁住某条记录;间隙 锁,是指锁住两条记录之间的位置;临键锁可以看成是记录锁与间隙锁的组合情况。 还有一种分类方法,是乐观锁和悲观锁。那么在数据库里面使用乐观锁,本质 上是一种应用层面的 CAS 操作。
  • 亮点方案
    • 早期我发现我们的业务有一个神奇的性能问题,就是响应时间偶尔会突然延 长。后来经过我们排查,确认响应时间是因为数据库查询变慢引起的。但是这 些变长的查询,SQL 完全没有问题,我用 EXPLAIN 去分析,都很正常,也走 了索引。 直到后面我们去排查业务代码的提交记录,才发现新加了一个功能,这个功能 会执行一个 SQL,但是这个 SQL 本身不会命中任何索引。于是数据库就会使用 表锁,偏偏这个 SQL 因为本身没有命中索引,又很慢,导致表锁一直得不到释 放。结果其他正常的 SQL 反而被它拖累了。最终我们重新优化了这个使用表锁 的 SQL,让它走了一个索引,就解决了这个问题。
  • 早期我优化过一个死锁问题,是临键锁引起的。业务逻辑很简单,先用 SELECT FOR UPDATE 查询数据。如果查询到了数据,那么就执行一段业务逻辑,然后 更新结果;如果没有查询到,那么就执行另外一段业务逻辑,然后插入计算结 果。 那么如果 SELECT FOR UPDATE 查找的数据不存在,那么数据库会使用一个临 键锁。此时,如果有两个线程加了临键锁,然后又希望插入计算结果,那么就 会造成死锁。 我这个优化也很简单,就是上来先不管三七二十一,直接插入数据。如果插入 成功,那么就执行没有数据的逻辑,此时不会再持有临键锁,而是持有了行 锁。如果插入不成功,那么就执行有数据的业务逻辑。 此外,还有两个思路。一个是修改数据库的隔离级别为 RC,那么自然不存在临 键锁了,但是这个修改影响太大,被 DBA 否决了。另外一个思路就是使用乐观 锁,不过代码改起来要更加复杂,所以就没有使用。

子主题

15MVCC协议

MySQL 的隔离级别有四个。

  • 读未提交(Read Uncommitted)是指一个事务可以看到另外一个事务尚未提交的修改。
  • 读已提交(Read Committed,简写 RC)是指一个事务只能看到已经提交的事务的修改。 这意味着如果在事务执行过程中有别的事务提交了,那么事务还是能够看到别的事务最新提交的修改。
  • 可重复读(Repeatable Read,简写 RR)是指在这一个事务内部读同一个数据多次,读到的结果都是同一个。这意味着即便在事务执行过程中有别的事务提交,这个事务依旧看不到别的事务提交的修改。这是 MySQL 默认的隔离级别。
  • 串行化(Serializable)是指事务对数据的读写都是串行化的。

脏读、幻读和不可重复读

  • 脏读是指读到了别的事务还没有提交的数据。之所以叫做“脏”读,就是因为未提交数据可能会被回滚掉。
  • 不可重复读是指在一个事务执行过程中,对同一行数据读到的结果不同。
  • 幻读是指在事务执行过程中,别的事务插入了新的数据并且提交了,然后事务在后续步骤中读到了这个新的数据

子主题

版本链

  • 为了实现 MVCC,InnoDB 引擎给每一行都加了两个额外的字段trx_id 和 roll_ptr。
  • trx_id:事务ID,也叫做事务版本号。MVCC 里面的 V 指的就是这个数字。每一个事务在开始的时候就会获得一个 ID,然后这个事务内操作的行的事务 ID,都会被修改为这个事务的 ID。 roll_ptr:回滚指针。InnoDB 通过 roll_ptr 把每一行的历史版本串联在一起。
  • 这个版本链存储在所谓的 undolog 里面

Read View

  • 当事务内部要读取数据的时候,Read View 就被用来控制这个事务应该读取哪个 版本的数据。
  • Read View 只用于已提交读和可重复读两个隔离级别,它用于这两个隔离级别的不同点就在于什么时候生成 Read View。 已提交读:事务每次发起查询的时候,都会重新创建一个新的 Read View。 可重复读:事务开始的时候,创建出 Read View。

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 或 者事务这两个维度上指定隔离级别。
  • 子主题

16数据库事务

undo log

  • undo log 是指回滚日志,用一个比喻来说,就是后悔药,它记录着事务执行过程中被修改的数据。当事务回滚的时候,InnoDB 会根据 undo log 里的数据撤销事务的更改,把数据库恢复到原来的状态。
  • 记录的是反向操作
  • 对于 INSERT 来说,对应的 undo log 应该是 DELETE。 对于 DELETE 来说,对应的 undo log 应该是 INSERT。 对于 UPDATE 来说,对应的 undo log 也应该是 UPDATE。比如说有一个数据的值原本是3,要把它更新成5。那么对应的 undo log 就是把数据更新回 3。

redo log

  • InnoDB 引擎在数据库发生更改的时候,把更改操作记录在 redo log 里,以便在数据库发生崩溃或出现其他问题的时候,能够通过 redo log 来重做。
  • 为了解决这个问题,InnoDB 引擎就引入了 redo log。相当于InnoDB 先把 buffer pool 里面的数据更新了,再写一份 redo log。等到事务结束之后,就把buffer pool的数据刷新到磁盘里面。万一事务提交了,但是 buffer pool 的数据没写回去,就可以用 redo log 来恢复。
  • redo log 本身也是先写进 redo log buffer,后面再刷新到操作系统的 pagecache,或者一步到位刷新到磁盘。

binlog

  • binlog 是用于存储 MySQL 中二进制日志(Binary Log)的操作日志文件,它是 MySQL Server 级别的日志,也就是说所有引擎都有。它记录了MySQL 中数据库的增删改操作,因此 binlog 主要有两个用途,一是在数据库出现故障时恢复数据。二是用于主从同步,即便是 Canal 这一类的中间件本质上也是把自己伪装成一个从节点。
  • 基于SQL语句的复制 statement (statement-based replication, SBR), 基于行的复制 (row-based replication, RBR), 混合模式复制 (mixed-based replication, MBR)。

ACID 特性

  • 原子性:事务是一个不可分割的整体,它在执行过程中不能被中断或推迟,它的所有操作都必须一次性执行,要么都成功,要么都失败。 一致性:事务执行的结果必须满足数据约束条件,不会出现矛盾的结果。注意这里的一致性和我们讨论的分布式环境下的一致性语义有所差别,后者强调的是不同数据源之间数据一致。 隔离性:事务在执行的时候可以隔离其他事务的干扰,也就是不同事务之间不会相互影响。 持久性:事务执行的结果必须保证在数据库里永久保存,即使系统出现故障或者数据库被删除,事务的结果也不会丢失。

面试建议:

  • 在 redo log 刷新到磁盘之前,都是回滚。 如果 redo log 刷新到了磁盘,那么就是重放 redo log。
  • 没有 undo log 会怎样?没有 undo log 就没有后悔药,没有办法回滚。 没有 redo log 会怎样?没有 redo log 的话,写到 buffer pool,宕机了就会丢失数据。 为什么非得引入 redo log,干嘛不直接修改数据?直接修改数据就是随机写,性能极差。

子主题

17数据迁移

子主题

18分布式ID

雪花算法

  • 亮点1:调整分段
    • 大多数情况下,如果自己设计一个类似的算法,那么每个字段的含义、长度都是可以灵活控制的。比如说时间戳 41 比特可以改得更短或者更长,比如说 39 比特也能表示十几年,其实也够用了。
    • 机器 ID 虽然明面上是机器 ID,但是实际上并不是指物理机器,准确说是算法 实例。例如,一台机器部署两个进程,每个进程的 ID 是不同的;又或者进一步 切割,机器 ID 前半部分表示机器,后半部分可以表示这个机器上用于产生 ID 的进程、线程或者协程。甚至机器 ID 也并不一定非得表示机器,也可以引入一 些特定的业务含义。而序列号也是可以考虑加长或者缩短的。
    • 升华:
      • 雪花算法可以算是一种思想,借助时间戳和分段,我们可以自由切割 ID 的不同 比特位,赋予其不同的含义,灵活设计自己的 ID 算法。
  • 亮点2:序列号耗尽
    • 一般来说可以考虑加长序列号的长度,比如说缩减时间戳,然后挪给序列号 ID。当然也可以更加简单粗暴地将 64 位的 ID 改成 96 位的 ID,那么序列号 ID 就可以有三四十位,即便是国际大厂也不可能用完了。不过,彻底的兜底方 案还是要有的。我们可以考虑引入类似限流的做法,在当前时刻的 ID 已经耗尽 之后,可以让业务方等一下。我们的时间戳一般都是毫秒数,那么业务方最多 就会等一毫秒。
  • 亮点3:数据堆积
    • 在低频场景下,很容易出现序列号几乎没有增长,从而导致数据在经过分库分 表之后只落到某一张表里面的情况。为了解决这种问题,可以考虑这么做,序 列号部分不再是从 0 开始增长,而是从一个随机数开始增长。还有一个策略就 是序列号从上一时刻的序列号开始增长,但是如果上一时刻序列号已经很大了,那么就可以退化为从 0 开始增长。这样的话要比随机数更可控一点,并且性能也要更好一点。
    • 一般来说,这个问题只在利用 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 的随机数部分一模一样,这是一个很低的概率。
    • 解决方案其实也很简单,就是在插入数据的时候,如果返回了主键冲突错误, 那么重新产生一个,再次尝试就可以了。
  • 子主题
  • 第一段依旧是采用时间戳,但是第二段我们就换成了这个买家后四位,第三段我们采用随机数。
  • 子主题

19分库分表分页查询

子主题

分库分表的一般做法

  • 哈希分库分表:
  • 范围分库分表:
  • 中间表:

分库分表中间件的形态

  • SDK 形态:
    • SDK 形态性能最好,但是和语言强耦合。比如说 Java 研发的 ShardingSphere jar 包是没办法给 Go 语言使用的。
  • Proxy 形态:
    • Proxy 形态性能最差,因为所有的数据库查询都发给了它,很容易成为性能瓶颈。尤其是单机部署 Proxy 的话,还面临着单节点故障的问题。它的优点就是跟编程语言没有关系,所以部署一个 Proxy 之后可以给使用不同编程语言的业务使用。同时,Proxy 将自己伪装成一个普通的数据库之后,业务方可以轻易地从单库单表切换到分库分表,整个过程对于业务方来说就是换了一个数据源。
  • Sidecar 形态:
    • Sidecar 目前还没有成熟的产品,但是从架构上来说它的性能应该介于 SDK 和 Proxy 之间,并且也没有单体故障、集群管理等烦恼。

面试建议:

  • 我们假设之前是全局查询,现在我们采用禁用跨页查询的方案来优化。
  • 最开始我在公司监控慢查询的时候,发现有一个分页查询非常慢。这个分页查 询是按照更新时间降序来排序的。后来我发现那个分页查询用的是全局查询 法,因为这个接口原本是提供给 Web 端用的,而 Web 端要支持跨页查询,所 以只能使用全局查询法。当查询的页数靠后的时候,响应时间就非常长。 后来我们公司搞出 App 之后,类似的场景直接复用了这个接口。但是事实上在 App 上是没有跨页需求的。所以我就直接写了一个新接口,这个接口要求分页 的时候带上上一页的最后一条数据的更新时间。也就是我用这个更新时间构造 了一个查询条件,只查询早于这个时间的数据。那么分页查询的时候 OFFSET 就永远被我控制在 0 了,查询的时间就非常稳定了。
  • 加一个总结。
    • 分页查询在分库分表里面是一个很难处理的问题,要么查询可能有性能问题, 比如说这里使用的全局查询法,要么就是要求业务折中,比如说我优化后禁用 了跨页,以及要求数据平均分布的平均分页法,当然还有各方面都不错,但是 实现比较复杂的二次查询法、中间表法。
  • 全局查询
    • 分库分表中间件一般采用的都是全局排序法。假如说现在我们要查询的是 LIMIT x OFFSET y。那么分库分表中间件会把查询改写为 LIMIT x+y OFFSET 0,然后把查询请求发送给所有的目标表。在拿到所有的返回值之后,在内存中 排序,并且根据排序结果找出全局符合条件的目标数据。
    • 亮点,抓住受影响的三个方面:网络、内存、CPU。
    • 这个解决方案最大的问题是性能不太好。首先是网络传输瓶颈,比如说在 LIMIT 10 OFFSET 1000 这种场景下,如果没有分库分表,那么只需要传输 10 条数据。而在分库分表的情况下,如果命中了 N 个表,那么需要传输的就是 (1000 + 10) × N 条数据。而实际上最终我们只会用其中的 10 条数 据,存在巨大的浪费。 其次是内存瓶颈。收到那么多数据之后,中间件需要维持在内存中排序。CPU 也会成为瓶颈,因为排序本身是一个 CPU 密集的操作。所以在 Proxy 形态的 分库分表中间件里面,分页查询一多,就容易把中间件的内存耗尽,引发 OOM,又或者 CPU 100%。不过可以通过归并排序来缓解这些问题。
    • 关键点就是在拿到数据之后,使用归并排序的算 法。
    • 在分库分表里面,可以使用归并排序算法来给返回的结果排序。也就是说在改 写为 LIMIT x+y OFFSET 0 之后,每一个目标表返回的结果都是有序的,自然 可以使用归并排序。在归并排序的过程中,我们可以逐条从返回结果中读取, 这意味着没必要将所有的结果一次性放到内存中再排序。在分页的场景下,取 够了数据就可以直接返回,剩下的数据就可以丢弃了。
  • 平均分页。
    • 在一些可以接受分页结果不精确的场景下,可以考虑平均分页的做法。举个例 子来说,如果查询的是 LIMIT 4 OFFSET 2,并且命中了两张目标表,那么就可 以考虑在每个表上都查询 LIMIT 2 OFFSET 1。这些结果合并在一起就是 LIMIT 4 OFFSET 2 的一个近似答案。这种做法对于数据分布均匀的分库分表效果很 好,偏差也不大。
    • 更加通用的做法是根据数据分布来决定分页在不同的表上各自取多少条数据。 比如说一张表上面有 70% 的数据,但是另一张表上只有 30% 的数据,那么在 LIMIT 10 OFFSET 100 的场景下,可以在 70% 的表里取 LIMIT 7 OFFSET 70,在 30% 的表里取 LIMIT 3 OFFSET 30。所以,也可以把前面平均分配的 方案看作是各取 50% 的特例。
  • 禁用跨页查询

20分布式事务

子主题

两个部门的用kafka作包裹状态数据同步

21你按照买家分库分表,那我卖家怎么

查?

在分库分表之后,为了充分满足不同情况下的查询需求,我们公司综合使用了

三种方案:引入中间表、二次分库分表和 Elasticsearch。对于卖家查询来说, 我们直接复制了一份数据,按照卖家 ID 分库分表。对于一些复杂的查询来说, 就是利用 Elasticsearch。还有一些查询是通过建立中间表来满足,比如说商品 ID 和订单 ID 的映射关系。

我们的数据同步方案是使用监听 binlog 的方案。买家库插入数据之后,就会同

步一份到卖家库和 Elasticsearch 上。这个过程是有可能失败的,那么在失败之 后会有重试机制,如果重试都失败了,那么就只能人手工介入处理了。

子主题

22分库分表容量预估

子主题

23数据库综合应用

子主题

24数据库模拟面试

Subtopic

25消息队列解决什么问题

子主题

消息队列的三个用途:

  • 解耦、异步、削峰
  • 并且额外加了几个,比如日志处理和消息通讯。

消息队列的使用场景

  • 日志处理、消息通讯、秒杀场景和订单超时取消

亮点:

  • 基于事件驱动的 SAGA 分布式事务方案。

事件驱动

  • 事件驱动(Event-Driven)可以说是一种软件开发模式,也可以看作是一种架构。它的核心思想是通过把系统看作一系列事件的处理过程,来实现对系统的优化和重构。
  • 你可以直观地理解成,整个系统不同组件之间的通信是通过事件来完成的。也就是组件 1 发送一个事件到消息队列上,然后组件 2 消费这个消息。组件 2 消费完成后再发出一个消息到消息队列。每一个事件就是一个消息。
  • 优点
    • 低耦合性:各个组件只依赖消息队列,组件之间通过消息的定义间接地耦合在一起。换句话来说,组件只需要知道消息的定义,并不需要知道发送消息的组件是哪个。
    • 可扩展性:事件驱动的应用程序具有很强的扩展性,可以通过添加新的事件处理程序、组件等来实现系统的扩展和升级。
    • 高可用:可以充分利用消息队列的可靠性、可重复消费等特性,来保证消息发送、消费高可用,从而保证整个系统的高可用。

26延迟消息

子主题

子主题

27消息顺序

子主题

基础方案:

  • 最开始我进公司的时候就遇到了一个 Kafka 的线上故障。我司有一个业务需要 用到有序消息,所以最开始的设计就是对应的 topic 只有一个分区,从而保证 了消息有序。 可是随着业务增长,一个分区很快就遇到了性能瓶颈。只有一个分区,也就意 味着只有一个消费者,所以在业务增长之后,就开始出现了消息积压。另外一 方面,这个分区所在的broker的负载也明显比其他服务器要大,偶尔也会有一 些性能抖动的问题。

亮点:

  • 后来我仔细看了我们的业务,实际上,我们的业务要求的不是全局有序,而是 业务内有序。 换句话来说,不一定非得用一个分区,而是可以考虑使用多个分区。所以我就 给这个 topic 增加了几个分区,同时也增加了消费者。优化完之后,到目前为 止,还没有出现过消息积压的问题。

28消息不丢失

子主题

子主题

29重复消费

子主题

30架构设计

子主题

31Kafka为什么性能那么好

子主题

Kafka 分段与索引

  • 即便在同一个分区内部,Kafka 也进一步利用了分段日志和索引来加速消息查找。
  • Subtopic
  • topic 加分区定目录,偏移量定文件,索引定位置。

零拷贝

  • 零拷贝(zero copy)是中间件广泛使用的一个技术,它能极大地提高中间件的性能。所谓的零拷贝,就是指没有 CPU 参与的拷贝。
  • 磁盘读到内核缓存,然后内核缓存直接写到 NIC 缓存

非零拷贝

  • 四次内核态与用户态的切换。
  • 这里面总共有四个步骤。
  1. 应用进入内核态,从磁盘里读取数据到内核缓存,也就是读缓存。这一步应用就是发了一个指令,然后是 DMA 来完成的。
  1. 应用把读缓存里的数据拷贝到应用缓存里,这个时候切换回用户态。
  1. 应用进入内核态,把应用缓存里的数据拷贝到内核缓存里,也就是写缓存。
  1. 应用把数据从写缓存拷贝到 NIC 缓存里,这一步应用也就是发了一个指令,DMA 负责执行。

批量操作的优势

  • 批量操作在高性能中间件里面也很常见。那么批量操作的优势究竟在哪里呢?主要体现在两个方面:一个是更少的系统调用和内核态与用户态的切换,还有一个是高效利用网络带宽。

面试建议:

  • 零拷贝是中间件设计的通用技术,是指完全没有 CPU 参与的读写操作。我以从 磁盘读数据,然后写到网卡上为例介绍一下。首先,应用程序发起系统调用, 这个系统调用会读取磁盘的数据,读到内核缓存里面。同时,磁盘到内核缓存 是 DMA 拷贝。然后再从内核缓存拷贝到 NIC 缓存中,这个过程也是 DMA 拷 贝。这样就完成了整个读写操作。和普通的读取磁盘再发送到网卡比起来,零 拷贝少了两次 CPU 拷贝,和两次内核态与用户态的切换。
  • 这里说的内核缓存,在 linux 系统上其实就是 page cache
  • Kafka 充分利用了 page cache。Kafka 写入的时候只是写入到了 page cache,这几乎等价于一个内存写入操作,然后依靠异步刷新把数据刷新到磁盘 上。而 page cache 是可以存放很多数据的,也就是说 Kafka 本身调用了很多 次写入操作之后,才会真的触发 IO 操作,提高了性能。而且,Kafka 是基于 JVM 的,那么利用 page cache 也能缓解垃圾回收的压力。大多数跟 IO 操作 打交道的中间件都有类似的机制,比如说 MySQL、Redis。
  • 不过使用 page cache 的缺陷就是如果消息还没刷新到磁盘上,服务器就宕机 了,那么整个消息就丢失了。

顺序写

  • 在计算机里面,普遍认为写很慢,但是实际上是随机写很慢,但是顺序写并不 慢。即便是机械硬盘的顺序写也并不一定会比固态硬盘的顺序写慢。
  • Kafka 在写入数据的时候就充分利用了顺序写的特性。它针对每一个分区,有 一个日志文件 WAL(write-ahead log),这个日志文件是只追加的,也就是 顺序写的,因此发消息的性能会很好。MySQL、Redis 和其他消息中间件也采 用了类似的技术。

亮点

  • 分区多影响写入性能
    • 但是 Kafka 的顺序写要求的是分区内部顺序写,不同的分区之间就不是顺序写 的。所以如果一个 topic 下的分区数量不合理,偏多的话,写入性能是比较差 的。 举个例子,假如说要写入 100M 的数据,如果只有一个分区,那就是直接顺序 写入 100M。但是如果有 100 个分区,每个分区写入 1M,它的性能是要差很 多的。因为一个 topic 至少有一个分区,topic 多也会影响 Kafka 的性能。最 好是在创建 topic 的时候就规划好分区,但是如果没规划好,还是得考虑解 决。
  • 如何解决分区多问题
    • 如果某个 topic 分区太多了用不上,就可以考虑不用其中的一些分区。假设说 我们现在有 32 个分区,但是事实上业务本身用不上那么多分区,那么就可以考 虑要求发送者只将消息发送到特定的 16 个分区上。当然,能够直接创建新 topic 是最好的。
  • topic 过多的问题,要稍微棘手一点,你可以考虑合并 topic。
    • topic 过多的话,可以考虑合并一些 topic,但这也是看业务的。比如说最开始 的设计是某个主业务下的子业务都有一个 topic,那么可以考虑这些子业务合并 使用一个 topic,然后在里面用 type 等字段来标记是归属于哪个子业务的。
  • 多少分区才算多
    • 多少分区才算多,以及多少分区才会引起性能下降,这和 topic 本身有关,也 和业务有关。 不过之前阿里云中间件团队测试过,在一个 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 密集型的应用,那么这种压缩机制反而加重了它们 的负担。

总结

  • 零拷贝、page cache、顺序写、分 区、分段与索引、批量处理、压缩

32怎么在实践中保证_Kafka_高性能

子主题

如何选择压缩算法?

  • 压缩比越高,压缩速率越低;压缩比越低,压缩速率越高。

操作系统交换区

  • 当操作系统发现可用的物理内存不足的时候,就会把物理内存里的一部分页淘汰出来,放到磁盘上,也就是放到 swap 分区。

面试准备

  • 性能优化,消息队列相关的优化是其中一个重要的点。
  1. TCP 协议,Linux 上的几个 TCP 参数以及如何调整这些参数来优化中间件性能,这里可以举 Kafka 的例子。
  1. swap,追求性能的中间件都会尝试优化 vm.swappiness 参数,你可以以 Kafka 为例。
  1. 从主从同步引申到 Kafka 主从分区同步,还有批量拉数据模型。
  1. 压缩,Kafka 的压缩功能,以及你实际使用的压缩算法。
  1. JVM 或者垃圾回收,可以用优化 Kafka 垃圾回收来证明自己的技术实力。

基本思路

  • 我这个系统有一个关键点,就是一个高并发的消息队列使用场景。也就是说, 它要求我们做到高效发送、高效消费,不然就会有问题,比如说出现消息积压 或者生产者阻塞的问题。那么优化的整体思路就是从消息队列的生产者、 broker 和消费者这三方出发。

优化生产者

  • 之前我们有一个系统在一个高并发场景下会发送消息到 Kafka。后来我们就发 现这个接口在业务高峰的时候响应时间很长,客户端经常遇到超时的问题。后 来我去排查才知道,写这段代码的人直接复制了已有的发送消息代码,而原本 人家的业务追求的是消息不丢,所以 acks 设置成了 all。实际上我们这个业务 并没有那么严格的消息不丢的要求,完全可以把 acks 设置为 0。这么一调整, 整个接口的响应时间就显著下降了,客户端那边也很少再出现超时的问题。
  • 不过追求消息不丢失的业务场景就不能把 acks 设置为 0 或者 1,这时候就只能 考虑别的优化手段,比如说优化批次。

优化批次

  • 我之前遇到过一个生产者发送消息的性能问题。后来我们经过排查之后,发现 是因为发送性能太差,导致发送缓冲池已经满了,阻塞了发送者。这个时候我 们注意到其实发送速率还没有达到 broker 的阈值,也就是说,broker 其实是 处理得过来的。在这种情况下,最直接的做法就是加快发送速率,也就是调大 batch.size 参数,从原本的 100 调到了 500,就没有再出现过阻塞发送者的情 况了。

批次也不是越大越好。

  • 当然,批次也不是说越大越好。因为批次大了的话,生产者这边丢失数据的可 能性就比较大。而且批次大小到了一个地步之后,性能瓶颈就变成了 broker 处 理不过来了,再调大批次大小是没有用的。

也别忘了占领“高地”。

  • 最好的策略,还是通过压测来确定合适的批次大小。

另外一种优化思路,刷个亮点。

  • 发送者被阻塞也可能是因为缓冲池太小,那么只需要调大缓冲池就可以。比如 说是因为 topic、分区太多,每一个分区都有一块缓冲池装着批量消息,导致缓 冲池空闲缓冲区不足,这一类不是因为发送速率的问题导致的阻塞,就可以通 过调大缓冲池来解决。
  • 所以发送者阻塞要仔细分析,如果是发送速率的问题,那么调大发送缓冲区是 治标不治本的。如果发送速率没什么问题,确实就是因为缓冲池太小引起的, 就可以调大缓冲池。如果现实中,也比较难区别这两种情况,就可以考虑先调 大批次试试,再调整缓冲池。

启用压缩

  • 为了进一步提高 Kafka 的吞吐量,我也开启了 Kafka 的压缩功能,使用了 LZ4 压缩算法。
  • 为了进一步提高 Kafka 的吞吐量,我将压缩算法从 Snappy 换到了 LZ4。

优化 broker

  • 优化 swap
    • 为了优化 Kafka 的性能,可以调小 vm.swappiness。比如说调整到 10,这样 就可以充分利用内存;也可以调整到 1,这个值在一些 linux 版本上是指进行最 少的交换,但是不禁用交换。目前我们公司用的就是 10。
  • 为什么不直接禁用 swap 呢?你就要回答以防万一。
    • 物理内存总是有限的,所以直接禁用的话容易遇到内存不足的问题。我们只是 要尽可能优化内存,如果物理内存真的不够,那么使用交换区也比系统不可用 好。
  • 优化网络读写缓冲区
    • net.core.rmem_default 和 net.core.wmem_default:Socket 默认读写缓冲区大小。 net.core.rmem_max 和 net.core.wmem_max:Socket 最大读写缓冲区。 net.ipv4.tcp_wmem 和 net.ipv4.tcp_rmem:TCP 读写缓冲区。它们的值由空格分隔的最小值、默认值、最大值组成。可以考虑调整为 4KB、64KB 和 2MB。
  • 调大读写缓冲区
    • 另外一个优化方向是调大读写缓冲区。Scoket 默认读写缓冲区可以考虑调整到 128KB;Socket 最大读写缓冲区可以考虑调整到 2MB,TCP 的读写缓冲区最 小值、默认值和最大值可以设置为 4KB、64KB 和 2MB。不过这些值究竟多 大,还是要根据 broker 的硬件资源来确定。
  • 优化磁盘 IO
    • Kafka 也是一个磁盘 IO 密集的应用,所以可以从两个方向优化磁盘 IO。一个 是使用 XFS 作为文件系统,它要比 EXT4 更加适合 Kafka。另外一个是禁用 Kafka 用不上的 atime 功能。
    • 相比于 EXT4,XFS 性能更好。在同等情况下,使用 XFS 的 Kafka 要比 EXT4 性能高 5% 左右。
  • 优化主从同步
    • num.replica.fetchers:从分区拉取数据的线程数量,默认是1。你可以考虑设置成 3。 replica.fetch.min.bytes:可以通过调大这个参数来避免小批量同步数据。 replica.fetch.max.bytes:这个可以调大,比如说调整到 5m,但是不要小于 message.max.byte,也就是不要小于消息的最大长度。 replica.fetch.wait.max.ms:如果主分区没有数据或者数据不够从分区的最大等待时间,可以考虑同步调大这个值和 replica.fetch.max.bytes。
    • Kafka 的主从分区同步也可以优化。首先调整从分区的同步数据线程数量,比 如说调整到 3,这样可以加快同步速率,但是也会给主分区和网络带宽带来压 力。其次是调整同步批次的最小和最大字节数量,越大则吞吐量越高,所以都 尽量调大。最后也可以调整从分区的等待时间,在一批次中同步尽可能多的数 据。
    • 不过调大到一定地步之后,瓶颈就变成了从分区来不及处理。或者调大到超过 了消息的并发量,那么也没意义了。
    • Kafka 这种机制可以看作是典型的批量拉数据模型。在这个模型里面,要着重 考虑的就是多久拉一次,没有怎么办,一次拉多少?在实现这种模型的时候, 让用户根据自己的需要来设定参数是一个比较好的实践。
  • 优化 JVM
    • 之前我们的 Kafka 集群还出过 GC 引发的性能问题。我们有一个 Kafka 的堆内 存很大,有 8G,但是垃圾回收器还是用的 CMS。触发了 full GC 之后,停顿 时间就会很长,导致 Kafka 吞吐量显著下降,并且有时候还会导致 Kafka 认为 主分区已经崩溃,触发主从选举。 在这种情况下,有两个优化思路,一个是考虑优化 CMS 本身,比如说增大老 年代,但是这个治标不治本,可以缓解问题,但是不能根治问题。所以综合之 下我选了另外一个方向,直接切换到 G1 回收器。G1 回收器果然表现得非常 好,垃圾回收频率和停顿时间都下降了。

面试方案总结

  • 当你选择压缩算法的时候,你需要权衡压缩比和压缩速率,根据需求做选择。
  1. 操作系统交换区可以看作“虚拟内存”。如果触发交换,性能就会显著下降。
  1. 优化生产者有三个措施:优化 acks、优化批次和启用压缩。
  1. 优化 broker 有五个措施:优化 swap、优化网络读写缓冲区、优化磁盘 IO、优化主从同步、优化 JVM。

33模拟面试,消息队列面试思路一图懂

子主题

34缓存过期

子主题

缓存命中率

实现过期机制的一般思路

  • 定时删除:是指针对每一个需要被删除的对象启动一个计时器,到期之后直接删除。 延迟队列:也就是把对象放到一个延迟队列里面。当从队列里取出这个对象的时候,就说明它已经过期了,这时候就可以删除。 懒惰删除:是指每次要使用对象的时候,检查一下这个对象是不是已经过期了。如果已经过期了,那么直接删除。 定期删除:是指每隔一段时间就遍历对象,找到已经过期的对象删除掉。
  • Subtopic
  • Redis,它们都使用了懒惰删除和定期删除结合的策略。

基本思路

  • 早期我优化过一个缓存的过期时间,从十分钟延长到了二十分钟,缓存命中率 从 80% 提升到了 90%。当然,代价就是 Redis 中缓存了更多的 key,占用了 更多内存。
  • 我刚进我们公司的时候,发现我们公司的过期时间基本上都是统一的半小时, 而没有考虑具体的业务特征。后来我排查之后,发现很多业务根本用不了半小 时。比如说我把一个业务的过期时间降低到 10 分钟,缓存命中率基本上没有变 化。经过这样的排查之后,Redis 的开销降了 30%。
  • 我使用的是 Redis,Redis 的过期删除机制简单来说就是懒惰删除和定期删除。 懒惰删除是指Redis 会在查询 key 的时候检测这个 key 是否已经过期,如果已 经过期,那么 Redis 就会顺手删除这个 key。 单纯使用懒惰删除肯定是不行的,因为一个 key 过期之后,可能一直没有被使 用过。所以 Redis 结合了定期删除策略。Redis 每运行一段时间,就会随机挑 选出一部分 key,查看是否过期,如果已经过期了,就把 key 删除掉。Redis 的定期删除要比我这里讲的复杂很多,毕竟 Redis 是一个追求高性能的中间 件,所以肯定要有复杂的机制控制住定期删除的开销。

为什么不立刻删除?

  • 性能
    • 理论上来说,并不是做不到,只不过代价比较高昂不值得而已。
    • 最简单的做法就是使用定时器,但是定时器本身开销太大,还得考虑在更新过 期时间的时候重置定时器。另外一种思路就是使用延迟队列,但是延迟队列本 身开销也很大,修改过期时间也要调整延迟队列,还要引入大量的并发控制。 综合来看,并不值得。而定期删除和懒惰删除的策略虽然看上去可能浪费内 存,但是这个浪费很少,并且对响应时间也没那么大的影响。

Redis 是怎么控制定期删除开销的?

  • 在每一个定期删除循环中,Redis 会遍历 DB。如果这个 DB 完全没有设置了过 期时间的 key,那就直接跳过。否则就针对这个 DB 抽一批 key,如果 key 已 经过期,就直接删除。 如果在这一批 key 里面,过期的比例太低,那么就会中断循环,遍历下一个 DB。如果执行时间超过了阈值,也会中断。不过这个中断是整个中断,下一次 定期删除的时候会从当前 DB 的下一个继续遍历。 总的来说,Redis 是通过控制执行定期删除循环时间来控制开销,这样可以在 服务正常请求和清理过期 key 之间取得平衡。

确保每个 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。 RDB 简单来说就是快照文件,也就是当 Redis 执行 SAVE 或者 BGSAVE 命令的时候,就会把内存里的所有数据都写入 RDB 文件里。后续主库可以载入这个文件来恢复数据,从库也可以利用这个文件来完成数据同步。对于 RDB 来说,一句话总结就是主库不读不写,从库原封不动。 也就是说,在生成 RDB 的时候,主库会忽略已经过期的 key。在主库加载 RDB 的时候,也会忽略 RDB 中已经过期的 key。而从库则是整个 RDB 都加载进来,因为从库在加载完 RDB之后,很快就能从主库里面收到删除的指令,从而删除这个过期的 key。 AOF 是之前我们就提到过的 Append Only File。Redis 用这个文件来逐条记录执行的修改数据的命令。不管 Redis 是定期删除,还是懒惰删除过期 key,Redis 都会记录一条 DEL 命令。

亮点方案

  • 如何确定过期时间?
    • 一般我们是根据缓存容量和缓存命中率确定过期时间的。正常来说,越高缓存 命中率,需要越多的缓存容量,越长的过期时间。所以最佳的做法还是通过模 拟线上流量来做测试,不断延长过期时间,直到满足命中率的要求。当然,也 可以从业务场景出发。比如说,当某个数据被查询出来以后,用户大概率在接 下来的三十分钟内再次使用这个对象,那么就可以把过期时间设置成 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 的粉丝会在这几小时内看完这个新作品。而 一个已经发布很久的作品,即便要缓存,缓存时间也要设置得比较短,因为这 个时候并没有什么人来看。
  • 预加载与超短过期时间
    • 内存换响应时间。
    • 早期我们有一个业务场景,就是用户会搜索出一个列表页,然后用户大概率就 会点击列表页前面的某些数据。因此我做了一个简单的性能优化,就是预加载 缓存。当用户访问列表页的时候,我会异步地把列表页的第一页的数据加载出 来放到缓存里面。因为我可以预计的是,接下来用户会直接使用查看列表页中 内容的详情信息。那么就会直接命中缓存,而不必再次查询。 当然,因为用户也不一定就会访问,而且就算访问了也就是只访问一两次,因 此过期时间可以设置得很短,比如说用一分钟。

35缓存淘汰策略

子主题

淘汰算法

  • LRU
    • LRU(Least Recently Used)是指最近最少使用算法。也就是说,缓存容量不足的时候,就从所有的 key 里面挑出一个最近一段时间最长时间未使用的 key。
  • LFU
    • LFU(Least Frequently Used)是最不经常使用算法,它是根据对象的使用次数来确定淘汰对象的,每次都是将使用次数最低的淘汰掉。所以基本的思路就是按照访问频率来给所有的对象排序。每次要淘汰的时候,就从使用次数最少的对象里面找出一个淘汰掉。

Redis 支持的淘汰算法

  • 子主题

面试建议:

  • 优先淘汰代价低的案例,给你展示如何回答。
  • 为了进一步提高系统的性能,我还尝试过优化缓存。早期我们有一个业务,用 到了一个本地缓存。这个本地缓存使用的淘汰算法是 LRU,最开始我们都觉得 这个算法没什么问题。后来业务反馈,说有几个大客户一直抱怨自己的查询时 快时慢。一听到时快时慢,我就可以确定应该是缓存出了问题。 经过排查我们发现原来这个缓存执行 LRU 的时候有时会把大客户的数据淘汰 掉。而偏偏大客户的数据实时计算很慢,所以一旦没有命中缓存,响应时间就 会暴增。 后来我进一步梳理业务,一方面考虑进一步增大缓存的可用内存。另外一方 面,设计了灵活的淘汰策略,在淘汰的时候优先淘汰小客户的数据。 这样做的好处就是优先保证了大客户的用户体验,平均响应时间下降了40%。 而小客户因为本身计算就很快,所以影响也不是很大。
  • 怎么安全使用缓存
    • 使用缓存一定要注意控制缓存的内存使用量,不能因为某一个业务而直接把所 有的内存都耗尽。
    • 解决缓存淘汰的问题,应该优先考虑增加内存,降低缓存淘汰的几率。不过毕 竟内存也不是无限的,最终都还是要选择合适的淘汰策略。比如说我们公司的 Redis 使用了 volatile-lru 淘汰策略。
    • 这些 LRU 或者 LFU 之类的算法都是普适性很强的算法,但是我也用过一些更 加针对业务的淘汰算法。比如说按照优先级淘汰,大对象优先淘汰、小对象优 先淘汰、代价低优先淘汰。大多数时候,不论是 Redis 还是本地缓存,这种业 务针对性特别强的算法,都得自己实现

亮点方案

  • 之前我在业务里面使用过一个按照优先级来淘汰的策略。我们的业务有一个特 点,就是数据有很鲜明的重要性之分。所以对于我们来说,应该尽可能保证优 先级高的数据都在缓存中。所以在触发了淘汰的时候,我们希望先淘汰优先级 比较低的缓存。所以我在 Redis 上利用有序集合设计了一个控制键值对数量, 并且按照优先级来淘汰键值对的机制。这个有序集合是使用数据的优先级来排 序的,也就是用优先级作为 score。
  • 增加一个键值对就要执行一个 lua 脚本。在这个脚本里面,它会先检测有序集 合里面的元素个数有没有超过允许的键值对数量上限,如果没有超过,就写入 键值对,再把 key 加入有序集合。如果超过了上限,那么就从有序集合里面拿 出第一个 key,删除这个 key 对应的键值对。
  • 怎么在键值对过期的时候,维护有序集合。
    • 同时监听 Redis 上的删除事件,每次收到删除事件,就把有序集合中对应的 key 删除。
  • 根据你计算优先级的不同方式,你可以将这个机制用于不同的场景。
    • 在这个基础上,我可以根据不同的业务特征来计算优先级,从而实现大对象先 淘汰、小对象先淘汰、热度低先淘汰等算法。
  • 先淘汰大对象
  • 先淘汰小对象
  • 低热度优先

36缓存模式

子主题

37缓存一致性问题

子主题

38缓存问题:怎么解决缓存穿透、击穿和雪崩问题?

子主题

39为什么_Redis_用单线程而_Memcached_用多线程

含义

  • 业界说 Redis 是单线程的,是指它在处理命令的时候,是单线程的。在 Redis 6.0 之 前,Redis 的 IO 也是单线程的,但是在 6.0 之后也改成了多线程。
  • 但是其他部分,比如说持久化、数据同步之类的功能,都是由别的线程来完成的。因此严格 来说,Redis 其实是多线程的。

子主题

我们通常说的 Redis 单线程,其实是指处理命令的时候Redis是单线程的。但是

Redis 的其他部分,比如说持久化其实是另外的线程在处理。因此本质上Redis 是多线程的。特别是 Redis 在 6.0 之后,连 IO 模型都改成了多线程的模型,进一步发挥了多核 CPU 的优势。

Redis 的高性能源自两方面,一方面是 Redis 处理命令的时候,都是纯内存操

作。另外一方面,在 Linux 系统上 Redis 采用了 epoll 和 Reactor 结合的 IO 模型,非常高效。

epoll 模型简单来说就是 epoll 会帮你管着一大堆的套接字。每次你需要做啥的时候,就问问哪些套接字可用。

Reactor 模式可以看成是一个分发器 + 一堆处理器。Reactor 模式会发起

epoll 之类的系统调用,如果是读写事件,那么就交给 Handler 处理;如果是 连接事件,就交给 Acceptor 处理。

整个过程是这样的:

  1. Redis 中的 Reactor 调用 epoll,拿到符合条件的文件描述符。
  1. 假如说 Redis 拿到了可读写的描述符,就会执行对应的读写操作。
  1. 如果 Redis 拿到了创建连接的文件描述符,就会完成连接的初始化,然后准 备监听这个连接上的读写事件。

Redis 使用单线程模式的理由有很多。首先有两个显著的优点:不会引入上下

文切换的开销,也没有多线程访问资源的竞争问题。其次 Redis 是一个内存数 据库,操作很快,所以它的性能瓶颈只可能出现在网络 IO 和内存大小上,是不 是多线程影响不大。最后,单线程模式比较好理解,调试起来也容易。

亮点二:Redis 为什么引入多线程?

  • Redis 在 6.0 引入多线程的原因只有一个,在高并发场景下可以利用多个线程 并发处理 IO 任务、命令解析和数据回写。这些线程也被叫做 IO 线程。默认情 况下,多线程模式是被禁用了的,需要显式地开启。

40分布式锁

子主题

http://kaito-kidd.com/2021/06/08/is-redis-distributed-lock-really-safe/

41|缓存综合应用:怎么用缓存来提高整个应用的性能?

子主题

42_模拟面试|缓存面试思路一图懂

子主题

43Elasticsearch高可用

子主题

Elasticsearch 节点角色

  • 候选主节点(Master-eligible Node):可以被选举为主节点的节点。主节点主要负责集群本身的管理,比如说创建索引。类似的还有仅投票节点(Voting-only Node),这类节点只参与主从选举,但是自身并不会被选举为主节点。 协调节点(Coordinating Node):协调节点负责协调请求的处理过程。一个查询请求会被发送到协调节点上,协调节点确定数据节点,然后让数据节点执行查询,最后协调节点合并数据节点返回的结果集。大多数节点都会兼任这个角色。 数据节点(Data Node):存储数据的节点。当协调节点发来查询请求的时候,也会执行查询并且把结果返回给协调节点。类似的还有热数据节点(Hot Data Node)、暖数据节点(Warm Data Node)、冷数据节点(Cold Data Node),你从名字就可以看出来,它们只是用于存储不同热度的数据。
  • 给节点设置不同的角色的原则就一条:有钱就专款专用,没钱就兼任。兼任就是指你一个节点扮演了多个角色。意思是,如果有钱有足够的资源,那么你就不要让节点兼任。如果没钱,那么就可以考虑兼任。但是不管怎样,兼任都有可能引起性能问题。

写入数据

  • 文档首先被写入到 Buffer 里面,这个是 Elasticsearch 自己的 Buffer。
  1. 定时刷新到 Page Cache 里面。这个过程叫做 refresh,默认是 1 秒钟执行一次。
  1. 刷新到磁盘中,这时候还会同步记录一个 Commit Point。

Translog

  • MySQL 里和 redo log 差不多的东西。也就是如果宕机了,Elasticsearch 可以用 Translog 来恢复数据。

Elasticsearch 索引与分片

  • 一个索引就是一个逻辑表。 分片就是分库分表。 每个分片都有主从结构,在分库分表里面,一般也是用主从集群来存储数据。

面试建议:

  • Elasticsearch 高可用的核心是分片,并且每个分片都有主从之分。也就是说, 万一主分片崩溃了,还可以使用从分片,从而保证了最基本的可用性。
  • 而且 Elasticsearch 在写入数据的过程中,为了保证高性能,都是写到自己的 Buffer 里面,后面再刷新到磁盘上。所以为了降低数据丢失的风险, Elasticsearch 还额外写了一个 Translog,它就类似于 MySQL 里的 redo log。后面 Elasticsearch 崩溃之后,可以利用 Translog 来恢复数据。
  • 我维护的业务对可用性的要求比较高,所以在 Elasticsearch 的基础上,我还做 了一些额外的优化,来保证 Elasticsearch 的高可用。

Elasticsearch 高可用方案

  • 限流保护节点
  • 之前我用 Elasticsearch 的插件机制,设计过一个限流插件。这个限流插件的功 能还是很简单的,就是根据 Elasticsearch 当前的内存使用率和 CPU 使用率来 判断是否需要执行限流。不管是内存使用率还是 CPU 使用率,只要超过阈值一 段时间,就触发限流。
  • 我还了解过 Elasticsearch 网关或者代理,希望能够借助这些产品来做 Elasticsearch 的治理。比如说借助网关来做熔断、限流、降级这种,但是市面 上相关的产品比较少,也担心引入网关之后的性能损耗,所以最终并没有实施 这个方案。
  • 我们这边为了保护 Elasticsearch,在客户端这边都是做了限流的。比如说某个 业务的查询都比较慢,对 Elasticsearch 的压力很大,那么限流的阈值就比较 小。
  • 不管如何,限流都只能算是治标。如果经常触发限流,或者发现 Elasticsearch 有性能问题,那么还是要及时扩容的。

利用消息队列削峰

  • 之前我优化过我们业务的架构,就是在数据同步到 Elasticsearch 之前,加入一 个消息队列来削峰。在早期的时候,我们都是双写,一方面写数据库,一方面 写 Elasticsearch。那么在业务高峰期,Elasticsearch 就会有性能瓶颈。 而实际上,我们的业务对实时性的要求不高。在这种情况下,我引入了消息队 列。业务方只是写入数据库就返回。然后我们监听 binlog,并且生成消息丢到 Kafka 上。在这种情况下,Elasticsearch 空闲的话,消费速率就高;如果 Elasticsearch 性能比较差,那么消费就比较慢。这样就起到了削峰和限流的效 果

还可以考虑引入降级,也就是在 Elasticsearch 真的有性能问题的时

候,关闭一部分消费者。
  • 而在这个架构的基础上,我还做了一个简单的降级。也就是我有两类消费者写 入数据到 Elasticsearch。一类是核心数据消费者,一类是非核心数据消费者。 那么如果我在监控到 Elasticsearch 性能已经比较差了,比如说写入的时候会遇 到超时问题,那么我就把非核心数据消费者停下来。等 Elasticsearch 恢复过来 再启动。

保护协调节点

  • 要想提高 Elasticsearch 的可用性,就要想办法防止协调节点在遇到大请求的时 候崩溃。最简单的做法就是使用纯粹的协调节点。比如说专门部署一批节点, 只扮演协调节点的角色。

隔离

  • 如果整个 Elasticsearch 除了这种纯粹的协调节点,还有一些兼任多个角色的协 调节点,那么就还可以考虑使用隔离策略。也就是说,如果客户端能够判定自 己是大请求,就将请求发送到纯粹的协调节点上,否则发送到其他兼任的协调 节点上。 这种做法的好处就是,大请求即便把协调节点打崩了,也只会影响到其他大请 求。但是占据绝大多数的普通请求,并不会受到影响。

只使用单一角色的节点以提高可用性。

  • 在资源足够的情况下,我是建议所有的节点都只扮演单一角色。这样做不仅仅 能够带来可用性的提升,也能带来性能的提升。

双集群

  • 提高 Elasticsearch 可用性还有一个方法,就是使用双集群。比如说直接使用付 费的 CCR 功能,不过我司比较穷,肯定是不愿意买的。
  • 使用消息队列来保持双写。 在查询的时候,优先使用 A 集群,当确认 A 集群出了问题的时候,切换到 B 集群。

消息队列双写

  • 我们采用的是一个比较简单的双集群方案。就是写入的时候并不是直接写入到 Elasticsearch,而是写入到消息队列,而后启动两个消费者,分别消费消息, 然后写到两个集群 AB 里面。关键在于查询的时候,要判断集群 A 有没有出问 题,出了问题就切换到集群 B。
  • 为了实现自动切换的效果,我们对 Elasticsearch 的客户端进行了二次封装。在 封装之后,正常情况下,会访问集群 A。同时客户端监控集群 A 的响应时间。 如果响应时间超出预期,又或者返回了比较多超时响应,客户端就会自动切换 到集群 B 上。

总结

  • 四个提高 Elasticsearch 可用性的方案。
    • 限流保护节点:你可以考虑使用 Elasticsearch 的插件机制、网关或者在客户端限流。也不仅仅是限流,你也可以用来熔断或者降级。 利用消息队列削峰:这个案例你也可以用在消息队列的面试中。 保护协调节点:因为查询总是先到协调节点,而后协调节点再分发个数据节点执行。并且协调节点自己还要处理结果集,所以它也是可用性的瓶颈。最佳策略就是让部分节点只充当协调节点。在这个基础上可以考虑根据请求大小、业务价值来对协调节点进行分组与隔离。 双集群:大部分时候,只有不差钱的公司才会考虑为了一点点的可用性提升而引入双集群,这里我介绍了钞能力 CCR 方案和使用消息队列完成双写的双集群方案。

44Elasticsearch查询

子主题

Elasticsearch 的索引机制

  • Elasticsearch 依赖于 Lucene 来维护索引,它的基本原理也很简单。
  • 每当写入一个新的文档的时候,根据文档的每一个字段,Elasticsearch 会使用分词器,把每个字段的值切割成一个个关键词,每一个关键词也叫做 Term。 切割之后,Elasticsearch 会统计每一个关键词出现的频率,构建一个关键词到文档 ID、出现频率、位置的映射,这个也叫做 posting list。

面试建议:

  • 优化方案
    • 你在面试的时候并不需要记住全部的优化方案,而是可以尝试记住里面几个典型的,或者根据我在这里写出来的思路,结合自己的业务设计几个面试方案。 实际上,Elasticsearch 里可以优化的点非常多,这里我筛选出了比较适合面试的方案。有一些优化的点就是简单调整一下参数,又不能引导到别的话题,这里我就没有罗列出来。你有兴趣可以自己进一步了解。
  • 优化分页查询
    • 在 Elasticsearch 里面,也有两种可行的优化手段。
  1. Scroll 和 Scroll Scan:这种方式适合一次性查询大量的数据,比如说导出数据之类的场景。这种用法更加接近你在别的语言或者中间件里面接触到的游标的概念。
  1. Search After:也就是翻页,你在查询的时候需要在当次查询里面带上上一次查询中返回的 search_after 字段。
      • 在面试的时候,我建议你使用 Search After 来回答,因为 Search After 适用的场景更加广泛。
      • 我还优化过 Elasticsearch 的分页查询,也就是用 Search After 来优化的。 Search After 就有点类似于分库分表中使用的禁用跳页查询的方案,也就是不 支持随机翻页。每次查询都带上上一次查询的 search_after 字段。 它的优点就是查询的数据量不再和偏移量有关,只和每一页的大小,以及命中 的分片数量有关。之前我在分库分表里面也优化过类似的分页查询,不过分库 分表本身没有 search_after 之类的字段,只能是我自己在业务层面上搞出来一 个类似的 search_after。
      • 注意,这里的 search_after 就类似于分库分表中禁用跳页查询里面加入的 WHERE id > $max_id 这种极值过滤条件。
  • 增大刷新间隔
    • 之前我优化 Elasticsearch 的时候,把 index.refresh_interval 调大到了 30。 调整之后,性能大概提升了 20%。
  • 批量提交
    • 早期我有一个业务,就是把数据库里的数据同步到 Elasticsearch。最开始的时 候是每次用户更新了数据,就直接更新 Elasticsearch。但是这样效果很不好, 因为用户基本上都是一条条数据更新。
    • 后来我考虑到这个业务对数据一致性的要求不是很高,我就把实时同步修改成了异步同步,也就是定时扫描数据库中发生变化的数据,然后批量提交变更数 据到 Elasticsearch。这个异步任务我设置的是每秒钟同步一次,和原来每一次 写请求都要操作 Elasticsearch 比起来,压力小多了。同时业务方的响应时间也 下降了 50%。
  • 利用批量提交来解决消息积压问题的方案。
    • 我们公司的很多业务都不是直接同步数据到 Elasticsearch,而是要经过一个 Kafka,然后由 Kafka 的消费者把数据写入到 ElasticSearch。但是随着业务的 增长,越来越多的数据要写入到 Elasticsearch,尤其是在业务高峰期,很容易 产生消息积压的问题。
    • 为了解决这个问题,我做了一个优化,就是批量消费,批量提交到 Elasticsearch。也就是说,早期 Kafka 的消费者是每次消费一条消息,就写一 条数据到 Elasticsearch。而我现在改成了一次拉取 100 条消息(这个应该是可 配置的),做成一个批次,提交给 Elasticsearch,就解决了消息积压的问题。
  • 调整批次。
    • 我们公司的日志都是要利用 Logstash 直接同步到 Elasticsearch 的。那么在业 务繁忙的时候,日志就有可能同步得很慢,或者 Elasticsearch 压力很大。在这 种情况下,我把日志同步到 Elasticsearch 的批次调大了一些,显著降低了 Elasticsearch 的负载。 而且,我还引入了一个降级机制。正常我们公司的日志是全量同步的,但是如 果发现 Elasticsearch 有问题,就会触发降级,触发降级的时候就会先丢弃 INFO 级别的日志。运行一段时间之后,如果 Elasticsearch 还是没能恢复正 常,就把WARN 级别的也丢弃。
  • 优化不必要字段
    • 之前我接手的一个历史系统需要把数据同步到 Elasticsearch 上支持搜索。但是 最开始的时候数据量并不是很大,所以是直接把全量数据同步到了 Elasticsearch 上的。后面业务起来之后,就发现我这个业务的数据占据了大量 的内存和磁盘空间。 我在梳理了业务之后发现其实不用全部同步过去的,因为要被搜索的字段只是 其中的一小部分,而另外一些字段同步过去只是白白加重了 Elasticsearch 的负 担。所以我后面就修改了同步的过程,那一部分数据就直接传入 null 了。查询 过程就相当于在 Elasticsearch 上根据各种输入查到业务的主键,如果还需要 Elasticsearch 中没有的字段,就回数据库再次查询。 当然,这样做的代价就是有一些查询需要再次查询数据库。但是我评估过这 个,受影响的请求不足 10%,所以这个结果还是可以接受的。
    • 不过后续我也在考虑重新创建一个索引,现在这种有字段但是不同步数据的方 式不太优雅。
    • 这种二次查询类似于分库分表中利用中间表来支持一些无分库分表键查询的解 决方案,都是要先在一个地方用查询条件拿到主键或者分库分表键,然后再到 具体的数据库上查询到完整的数据。
  • 冷热分离
    • 之前我在公司的时候做过一个 Elastisearch 优化,就是冷热数据分离存储。我 们的数据最开始都是存在统一规格的 Elasticsearch 上,然后这两年经济不太 好,就想着降本增效,于是我们就决定试试业界用得比较多的冷热分离方案。 基本的思路是整个 Elasticsearch 的节点分成冷热两类,数据最开始都是写入到 热节点上。等过一段时间之后,数据已经不热了,就迁移到冷节点上。 这部分我们是借助了新出来的索引生命周期特性来实现的。比如说我们的日 志,就是三天内的数据都在热节点上,三天之后就是迁移到了冷节点上。这个 过程都是自动的,不需要人工介入。
  • 优化垃圾回收
    • 长期以来我们使用 Elasticsearch 有一个很大的问题,就是触发垃圾回收的时 候,停顿时间比较长,会有一百多毫秒。这主要是因为我们的 Elasicsearch 用 的都还是非常古老的 CMS,而 CMS 在超过 8G 的堆上面,表现就比较差。
所以后面我就尝试将 Elasticsearch 换成了 G1,换了之后效果是非常不错,现 在停顿时间都可以控制在 15ms 以内。不过我也在调研 ZGC,ZGC 在特大堆 上的表现比 G1 还要好。不过目前这方面业界的实践不多,所以我也没有进一 步优化。
  • 优化 swap
    • 使用 Elasticsearch 的时候要把 swap 禁用,或者把 vm.swappness 设置得很 小,也可以把bootstrap.memory_lock 设置成 true。所有类似于 Elasticsearch 的中间件都可以采用这种优化手段,比如说 Kafka。
  • 文件描述符
    • 之前我们在使用 Elasticsearch 的时候,还遇到过文件描述符耗尽的问题。这是 因为我们用的是一个非常大的 Elasticsearch,很多业务共用,导致 Elasticsearch 打开的文件描述符非常多。
那一次的故障之后,一方面我们是调大了最大文件描述符的数量,另外一方面 也是考虑把业务逐步迁移到不同的 Elasticsearch 上。毕竟这一次故障,直接导 致了我们好几个核心业务都出问题了,足以说明还是要考虑隔离的。

面试思路总结

  • 优化查询本身:这可能涉及到改写 SQL、优化索引等。 优化中间件本身:通常也就是调整一下中间件的各种参数,只有一些大厂在具备足够强的实力的情况下,会考虑二次开发。如果中间件本身是基于 JVM 的,那么也可以优化JVM。 优化操作系统:目前你接触到的大多数中间件应该都是对内存、网络 IO 和磁盘 IO 有很强依赖,那么优化也就是调整跟这三个方面有关的参数。

45MongoDB_是怎么做到高可用的


评论