本文来自知乎陈鹏老师的精彩分享,作者是该系统的负责人,文章深入介绍了知乎 Redis 系统的方方面面,作为后端程序员值得仔细研究。
背景
知乎作为知名中文知识内容平台,每日处理的访问量巨大,如何更好的承载这样巨大的访问量,同时提供稳定低时延的服务保证,是知乎技术平台团队需要面对的一大挑战。
知乎存储平台团队基于开源 Redis 组件打造的 Redis 平台管理系统,经过不断的研发迭代,目前已经形成了一整套完整自动化运维服务体系,提供一键部署集群、一键自动扩缩容、Redis 超细粒度监控、旁路流量分析等辅助功能。
目前,Redis 在知乎规模如下:
- 机器内存总量约 70TB,实际使用内存约 40TB;
- 平均每秒处理约 1500 万次请求,峰值每秒约 2000 万次请求;
- 每天处理约 1 万亿次请求;
- 单集群每秒处理最高约 400 万次请求;
- 集群实例与单机实例总共约 800 个;
- 实际运行约 16000 个 Redis 实例;
- Redis 使用官方 3.0.7 版本,少部分实例采用 4.0.11 版本。
Redis at 智慧
根据业务需求,知乎将实例区分为 单机(Standalone) 和 集群(Cluster) 两种类型。
- 单机实例:用于容量与性能要求不高的小型存储。
- 集群实例:用于应对性能和容量要求较高的场景。
单机(Standalone)
对于单机实例,知乎采用原生主从(Master-Slave)模式实现高可用,常规模式下对外仅暴露 Master 节点。由于使用原生 Redis,所以单机实例支持所有 Redis 指令。
对于单机实例,知乎使用 Redis 自带的 哨兵(Sentinel) 集群对实例进行状态监控与 Failover。
Sentinel 是 Redis 自带的高可用组件,它会对 Redis 实例进行健康检查,当 Redis 发生故障后,Sentinel 会通过 Gossip 协议进行故障检测,确认宕机后会通过 Raft 协议提升 Slave 成为新的 Master。
通常情况下,我们仅使用 1 个 Slave 节点进行冷备。如果有读写分离请求,可以建立多个只读 Slave 来进行读写分离。通过向 Sentinel 集群注册 Master 节点实现实例的高可用。当提交 Master 实例的连接信息后,Sentinel 会主动探测所有的 Slave 实例并建立连接,定期检查健康状态。客户端通过多种资源发现策略如简单的 DNS 发现 Master 节点,将来有计划迁移到如 Consul 或 etcd 等资源发现组件。
当 Master 节点发生宕机时,Sentinel 集群会提升 Slave 节点为新的 Master,同时在自身的 pubsubchannel + switch-master 广播切换的消息。具体的 Failover 过程详见 Redis 官方文档。
Redis Sentinel 文档 [1]
实际使用中需要注意以下几点:
- 只读 Slave 节点可以按照需求设置
slave-priority参数为 0,防止故障切换时选择了只读节点而不是热备 Slave 节点; - Sentinel 进行故障切换后会执行
CONFIG REWRITE命令将SLAVEOF配置落地。如果 Redis 配置中禁用了CONFIG命令,切换时会发生错误,可以通过修改 Sentinel 代码来替换CONFIG命令; - Sentinel Group 监控的节点不宜过多,实测超过 500 个切换过程偶尔会进入 TILT 模式,导致 Sentinel 工作不正常,推荐部署多个 Sentinel 集群并保证每个集群监控的实例数量小于 300 个;
- Master 节点应与 Slave 节点跨机器部署,尽可能跨机架部署,不推荐跨机房部署 Redis 主从实例;
- Sentinel 切换功能主要依赖
down-after-milliseconds和failover-timeout两个参数,down-after-milliseconds决定了 Sentinel 判断 Redis 节点宕机的超时,知乎使用 30000 作为阈值;而failover-timeout决定了两次切换之间的最短等待时间,如果对于切换成功率要求较高,可以适当缩短failover-timeout到秒级保证切换成功。
单机网络故障等同于机器宕机,但如果机房全网发生大规模故障会造成主从多次切换,此时资源发现服务可能更新不够及时,需要人工介入。
最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。 这是大佬写的 7701页的BAT大佬写的刷题笔记,让我offer拿到手软
集群(Cluster)
当实例需要的容量超过 20G 或要求的吞吐量超过 20 万请求每秒时,我们会使用集群实例来承担流量。集群是通过中间件(客户端或中间代理等)将流量分散到多个 Redis 实例上的解决方案。
知乎的 Redis 集群方案经历了两个阶段:
- 客户端分片
- Twemproxy 代理
客户端分片(Before 2015)
知乎早期使用 redis-shard 进行客户端分片。redis-shard 库内部实现了 CRC32、MD5、SHA1 三种哈希算法,支持绝大部分 Redis 命令。使用者只需把 redis-shard 当成原生客户端使用即可,无需关注底层分片。
基于客户端的分片模式的优点:
- 性能好:客户端分片是集群方案中最快的,没有中间件,仅需客户端进行一次哈希计算,不需要经过代理;
- 无需 Proxy 机器:不需要额外部署和维护 Proxy;
- 自定义哈希算法:可以自定义更适合生产环境的哈希算法。
存在的缺点:
- 需要每种语言实现客户端逻辑:早期知乎使用 Python,后来业务线增加,语言增多(如 Go、Lua、C/C++、JVM 系(Java、Scala、Kotlin)等),维护成本过高;
- 无法使用 MSET、MGET 等多种同时操作多个 Key 的命令,需要使用 Hash tag 来保证多个 Key 在同一个分片上;
- 扩容困难:存储需要停机,使用脚本 Scan 所有的 Key 进行迁移,缓存只能通过传统的翻倍取模方式进行扩容;
- 连接数过多:每个客户端都要与所有的分片建立连接池,客户端基数过大时会造成 Redis 端连接数过多,Redis 分片过多时会造成 Python 客户端负载升高。
Twemproxy 集群(2015 - Now)
2015 年开始,知乎 Redis 需求暴增,原有的 redis-shard 模式无法满足扩容需求,于是选择了 Twemproxy 作为集群方案。Twemproxy 是由 Twitter 开源的代理软件,具有以下优点:
- 性能优异,支持内存池实现 Buffer 复用,代码质量很高;
- 支持多种哈希算法(fnv1a_64、murmur、md5 等);
- 支持一致性哈希、取模哈希和随机三种分布式算法。
Twemproxy 的缺点:
- 单核模型:性能瓶颈较明显;
- 传统扩容模式:只支持停机扩容。
知乎将集群实例分成两种模式:
- 缓存(Cache):可以丢失少量数据以保证可用性;
- 存储(Storage):要求保证数据一致性。
我们对缓存和存储采用了不同的策略
存储
对于存储,知乎采用了 fnv1a_64 算法结合 **modula(取模)**模式对 Key 进行分片,底层 Redis 使用单机模式结合 Sentinel 集群实现高可用。默认配置下,使用 1 个 Master 节点和 1 个 Slave 节点提供服务。如果业务有更高的可用性要求,可以拓展更多的 Slave 节点。
当集群中 Master 节点宕机时,按照单机模式下的高可用流程进行切换。Twemproxy 在连接断开后会进行重连。对于存储模式下的集群,我们不会设置 auto_eject_hosts,因此不会自动剔除节点。
对于存储实例,我们默认使用 noeviction 策略,即在内存使用超过规定的额度时直接返回 OOM 错误,不会主动进行 Key 的删除,保证数据的完整性。
由于 Twemproxy 仅进行高性能的命令转发,不进行读写分离,因此默认没有读写分离功能。在实际使用过程中,我们没有遇到集群读写分离的需求。如果需要读写分离,可以使用资源发现策略在 Slave 节点上架设 Twemproxy 集群,由客户端进行读写分离路由。
缓存
考虑到后端(如 MySQL、HBase、RPC 等)的压力,知乎绝大部分业务没有针对缓存进行降级。由于对缓存的可用性要求较高,缓存系统的设计没有像存储系统那样严格要求数据一致性。但如果按照存储的主从模式实现高可用,1 个 Slave 节点的部署策略在线上环境只能容忍 1 台物理节点宕机。若多个物理节点宕机,则至少需要 N 个 Slave 节点,这无疑是资源浪费。
因此,知乎采用了 Twemproxy 一致性哈希(Consistent Hashing) 策略,结合 auto_eject_hosts 自动弹出策略来构建 Redis 缓存集群。
对于缓存,仍然使用 fnv1a_64 算法 进行哈希计算,但分布算法使用了 Ketama(一致性哈希) 来进行 Key 的分布。缓存节点没有主从结构,每个分片仅有 1 个 Master 节点承载流量。
Twemproxy 配置 auto_eject_hosts 会在实例连接失败超过 server_failure_limit 次的情况下剔除节点,并在 server_retry_timeout 超时后进行重试。剔除后,配合 Ketama 一致性哈希算法重新计算哈希环,恢复正常使用。这样,即使一次宕机多个物理节点,服务依然能够保持。
在实际生产环境中需要注意以下几点:
- 剔除节点后会造成短时间的命中率下降,后端存储如 MySQL、HBase 等需要做好流量监测;
- 缓存后端分片不宜过大,建议分片维持在 20GB 以内,同时分片调度应尽量分散。这样即使宕机一部分节点,对后端的额外压力也不会太大;
- 机器宕机重启后,缓存实例需要清空数据后启动,否则原有的缓存数据和新建立的缓存数据会发生冲突,导致脏缓存。直接不启动缓存也是一种方法,但在分片宕机期间会导致周期性
server_failure_limit次数的连接失败; server_retry_timeout和server_failure_limit需要仔细调整。知乎使用 10 分钟和 3 次作为配置,即连接失败 3 次后剔除节点,10 分钟后重新连接。
Twemproxy 部署
在方案的早期,知乎使用数量固定的物理机部署 Twemproxy,通过物理机上的 Agent 启动实例,Agent 在运行期间会对 Twemproxy 进行健康检查与故障恢复。由于 Twemproxy 仅提供全量的使用计数,Agent 运行时还会进行定时的差值计算,来计算 Twemproxy 的 requests_per_second 等指标。
为了更好地故障检测和资源调度,知乎引入了 Kubernetes,将 Twemproxy 和 Agent 放入同一个 Pod 的两个容器内。底层 Docker 网段的配置使得每个 Pod 都能获得独立的 IP,方便管理。
最开始,知乎使用 DNS A Record 来进行客户端的资源发现。每个 Twemproxy 采用相同的端口号,一个 DNS A Record 后面挂接多个 IP 地址对应多个 Twemproxy 实例。
初期,这种方案简单易用,但到了后期流量日益上涨,单集群 Twemproxy 实例个数超过了 20 个。由于 DNS 采用的 UDP 协议有 512 字节的包大小限制,单个 A Record 只能挂接约 20 个 IP 地址,超过这个数字就会转换为 TCP 协议,导致客户端启动失败。
为了解决这个问题,知乎建立了多个 Twemproxy Group,提供多个 DNS A Record 给客户端进行轮询或随机选择。该方案可用,但不够优雅。
如何解决 Twemproxy 单 CPU 计算能力的限制
为了提升 Twemproxy 的性能,知乎修改了 Twemproxy 源码,加入了 SO_REUSEPORT 支持,使得同一容器内由 Starter 启动多个 Twemproxy 实例并绑定到同一个端口,由操作系统进行负载均衡。
Twemproxy 对外暴露一个端口,但内部由系统均摊到多个 Twemproxy 实例。Starter 会定时从每个 Twemproxy 的 stats 端口获取运行状态并进行聚合,此外 Starter 还承载了信号转发的职责。
为什么没有使用官方 Redis 集群方案
知乎在 2015 年调研过多种集群方案,综合评估后选择了 Twemproxy,而不是官方 Redis 集群方案或 Codis。主要原因如下:
MIGRATE 造成的阻塞问题
Redis 官方集群方案使用 CRC16 算法计算哈希值,并将 Key 分散到 16384 个 Slot 中。扩容时,Redis 使用 MIGRATE 命令将 Slot 中的每个 Key 迁移到新的节点。由于 MIGRATE 是同步操作,它会导致长时间的 BLOCK 状态,严重影响 Redis 集群的性能,特别是当迁移的 Key 过大时,可能会引发集群 Failover。
为了避免 MIGRATE 阻塞问题,知乎选择了 Twemproxy,避免了与官方 Redis 集群相同的性能瓶颈。
Redis 4.2 的 roadmap 提到非阻塞 MIGRATE 功能,但截至目前尚未合并到主分支。知乎会持续关注相关的更新。
缓存模式下高可用方案不够灵活
此外,官方 Redis 集群方案的高可用策略仅支持主从模式,高可用级别与 Slave 数量成正相关。如果只有一个 Slave 节点,则只能容忍一台物理机器的宕机。Redis 4.2 的 roadmap 提到了 cache-only mode,旨在提供类似于 Twemproxy 的自动剔除后重分片策略,但截至目前该功能仍未实现。
内置 Sentinel 造成额外流量负载
官方 Redis 集群方案将 Sentinel 功能内置到 Redis 内部,这导致在节点数量较多(大于 100)时,Gossip 阶段会产生大量的 PING/INFO/CLUSTER INFO 流量。根据 issue 中的反馈情况,200 个使用 3.2.8 版本的 Redis 节点集群,在没有任何客户端请求的情况下,每个节点仍然会产生 40Mb/s 的流量。虽然 Redis 官方尝试对其进行压缩修复,但在节点较多的情况下,无论如何都会产生这部分流量。对于使用大内存机器,但只有千兆网卡的用户来说,这是一个值得注意的问题。
Slot 存储开销
每个 Key 对应的 Slot 存储开销,在规模较大的情况下会占用大量内存。4.x 版本以前,这一开销甚至可能是实际使用内存的几倍。虽然在 4.x 版本中使用了 rax 结构进行存储,但仍然占用了大量内存。在从非官方集群方案迁移到官方集群方案时,需要特别注意这部分额外的内存开销。
总之,官方 Redis 集群方案和 Codis 方案对于绝大多数场景来说都是非常优秀的解决方案,但经过仔细调研,我们发现它们并不适合集群数量较多且使用方式多样化的情况。不同场景的侧重点不同,但在此我们仍要感谢这些组件的开发者们,感谢他们为 Redis 社区做出的贡献。
扩容
静态扩容
对于单机实例,如果通过调度器观察到对应机器仍有空闲内存,我们只需直接调整实例的 maxmemory 配置并进行报警。同样,对于集群实例,我们通过调度器观察每个节点所在的机器,如果所有节点所在的机器均有空闲内存,我们会像扩容单机实例一样,直接更新 maxmemory 配置和报警。
动态扩容
当机器空闲内存不足,或者单机实例与集群后端实例过大,无法直接扩容时,我们需要进行动态扩容:
- 对于单机实例,如果单实例超过 30GB,且没有像
sinterstore这样的多 Key 操作,我们会将其扩容为集群实例; - 对于集群实例,我们会进行横向的 重分片(Resharding) 过程。
Resharding 过程
原生 Twemproxy 集群方案并不支持扩容,因此我们开发了数据迁移工具来进行 Twemproxy 的扩容。迁移工具本质上是一个上下游之间的代理,将数据从上游按照新的分片方式迁移到下游。
原生 Redis 主从同步使用 SYNC/PSYNC 命令建立主从连接。收到 SYNC 命令的 Master 会 fork 出一个进程遍历内存空间生成 RDB 文件,并发送给 Slave,期间所有写命令会缓存到内存缓冲区,等待 RDB 发送完成后才会转发给 Slave 节点。
我们的迁移代理会向上游发送 SYNC 命令,模拟上游实例的 Slave。代理收到 RDB 后进行解析,由于 RDB 中每个 Key 的格式与 RESTORE 命令的格式相同,所以我们使用生成的 RESTORE 命令,按照下游的 Key 重新计算哈希并使用 Pipeline 批量发送给下游。
等待 RDB 转发完成后,我们会根据新的配置生成 Twemproxy 配置,并启动 Canary 实例。此实例从上游 Redis 后端获取 Key 进行测试,测试 Resharding 过程是否正确,测试内容包括 Key 的大小、类型和 TTL。
测试通过后,我们使用新的配置替代原有的 Twemproxy 配置并执行 restart/reload。虽然我们修改了 Twemproxy 代码,加入了 config reload 功能,但实际使用中发现,直接重启实例会更加可控。对于单机实例,由于单机实例与集群实例对于命令的支持不同,通常需要与业务方协商后手动重启切换。
由于 Twemproxy 部署于 Kubernetes,我们可以实现细粒度的灰度。如果客户端接入了读写分离,我们可以先将读流量接入新集群,最终接入全部流量。这样相对于 Redis 官方集群方案,除了在上游进行 BGSAVE 时的 fork 复制页表时产生的尖刺以及重启时可能出现的连接闪断,其它对 Redis 上游造成的影响微乎其微。
这样扩容存在的问题
-
SYNC 后上游 fork 产生尖刺:对于存储实例,我们使用 Slave 进行数据同步,不会影响到接收请求的 Master 节点。对于缓存实例,由于没有 Slave 实例,这个尖刺无法避免。如果对于尖刺过于敏感,可以跳过 RDB 阶段,直接通过 PSYNC 使用最新的
SET消息来建立下游的缓存。 -
切换过程中可能出现写入下游,读取上游的情况:对于接入了读写分离的客户端,我们会先切换读流量到下游实例,再切换写流量。
-
一致性问题:两条具有先后顺序的写命令可能会在切换代理后端时通过不同方式写到下游。具体而言,写命令可能通过 1) 写上游同步到下游;2) 直接写到下游,这会导致命令先后顺序的倒置。
这个问题在切换过程中无法避免,幸好绝大多数应用没有这种问题。如果不能接受这个问题,只能通过上游停写来排空 Resharding 代理,保证命令的先后顺序。
旁路分析
由于生产环境调试的需要,有时需要监控线上 Redis 实例的访问情况。虽然 Redis 提供了多种监控手段,如 MONITOR 命令,但由于 Redis 的单线程限制,MONITOR 命令在负载过高的情况下会再次消耗高 CPU,这对于生产环境来说是非常危险的。此外,其他方式如 Keyspace Notify 仅支持写事件,缺少对读事件的监控,无法做到细致的观察。
为了应对这一问题,知乎开发了基于 libpcap 的旁路分析工具。该工具通过系统层面复制流量,并对应用层流量进行协议分析,实现旁路的 MONITOR 功能。实测表明,这种方式对运行中的实例几乎没有影响。
此外,对于没有 MONITOR 命令的 Twemproxy,旁路分析工具同样可以进行分析。由于生产环境中大部分业务都部署在 Kubernetes 和 Docker 容器中,每个容器都有独立的 IP 地址,因此可以使用旁路分析工具反向解析,找出客户端所在的应用,分析业务方的使用模式,从而有效防止不正常的使用。
将来的工作
随着 Redis 5.0 的发布临近,4.0 版本已经趋于稳定,知乎计划逐步将实例升级到 4.0 版本。通过此版本,我们可以利用新特性,如 MEMORY 命令、Redis Module、以及 新的 LFU 算法 等,这些特性无论是对运维方还是业务方,都会带来极大的帮助和改进。
通过旁路分析工具,知乎能够在不影响 Redis 实例性能的情况下,对线上流量进行详细监控,从而优化运维效率和提高系统稳定性。对于即将到来的 Redis 5.0 版本,知乎也将积极进行适配,利用新特性来进一步增强系统性能和可扩展性。
最后说一句(求关注,求赞,别白嫖我)
最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。 这是大佬写的 7701页的BAT大佬写的刷题笔记,让我offer拿到手软
本文,已收录于,我的技术网站 cxykk.com:程序员编程资料站,有大厂完整面经,工作技术,架构师成长之路,等经验分享
求一键三连:点赞、分享、收藏
点赞对我真的非常重要!在线求赞,加个关注我会非常感激!
真的免费,如果你近期准备面试跳槽,建议在cxykk.com在线刷题,涵盖 1万+ 道 Java 面试题,几乎覆盖了所有主流技术面试题、简历模板、算法刷题