系统设计:基础组件
系统设计:基础组件
一、负载均衡
从这节课开始,我们就来学习架构设计中常用的组件。今天我们来讲一般处于架构中最前面的一个组件:负载均衡。负载均衡可以分为两类:客户端负载均衡和代理负载均衡。客户端负载均衡一般在微服务场景中,依赖服务注册与发现,在客户端实现负载均衡。这个我们在微服务的服务治理部分讲解。这里我们重点看代理负载均衡。
1. 负载均衡作用
负载均衡,说白了,就是给系统找个“调度员”。 想象一下,你开了一家网红奶茶店(你的服务),生意火爆得不行(用户请求暴增),门口排起了长龙(请求积压)。光靠一个店员(一台服务器)累死也做不过来。怎么办?多招几个店员(多台服务器),再安排一个聪明的前台(负载均衡器)。这个前台的任务就是:根据一套策略,把源源不断涌进来的顾客(请求),合理地分配到后面各个忙碌的店员(服务器)手上。 这样经过路由分发,每个店员都不至于累趴下,整体效率大大提高,顾客(用户)也满意了。
负载均衡是大型分布式系统的必备组件,对于它常常出现的地方,我们做了一个总结:
- 服务入口:这是最常见的。用户访问业务站点时,流量首先到达负载均衡器(比如 Nginx),再由它分发给后面的多个 Web 服务器(比如 Tomcat)。
- 微服务网关:在微服务架构里,API 网关(如 Zuul, Spring Cloud Gateway, Kong)本质上就是一个强大的七层(应用层)负载均衡器,负责将外部请求路由到内部各个微服务实例。
- 分布式存储:对于MySQL、Redis、Kafka,这些需要存储数据的系统,为了实现读写分离、主从备份、数据分片(如分库分表),也需要负载均衡来分发请求。
- 微服务调用: 服务 A 要调用服务 B,服务 B 有多个实例。服务注册中心(如 Eureka, Consul, Nacos)配合客户端(比如Dubbo client)集成的负载均衡逻辑,就能实现服务 A 到服务 B 实例间的智能负载均衡。
需要注意的是,我们这节课重点讲解第一个应用场景,也就是服务入口的负载均衡。对于微服务网关中的负载均衡,我们留在微服务部分讲解。对于数据分片中的负载均衡,因为涉及到存储而非无状态计算(比如第一个场景就是无状态的计算),需要考虑到数据的一致性、主从切换等等诸多问题,逻辑比较复杂,我们留在「数据存储」模块中讲解。对于内部服务调用这一场景,实际上可以归为微服务的服务注册发现这部分内容,我们也留在微服务中讲解。
对于服务入口的负载均衡,负载均衡主要解决哪些具体的问题呢?或者说可以起到什么作用呢?我们做了一些总结,如下所示。
- 单点故障: 如果只有一台服务器,万一它挂了(硬件故障、软件崩溃、机房断网),整个服务瞬间全停,用户直接“凉凉”。单点故障是线上系统的大忌。负载均衡后面挂着多台服务器,一台服务器挂了,负载均衡马上就能把流量导到其他健康的机器上,用户可能都感觉不到异常。消灭了单点故障,可用性提高了。
- 性能瓶颈: 一台服务器的能力(CPU、内存、磁盘IO、网络带宽)是有上限的。用户少的时候没事,用户一多,它就可能顶不住了,响应时间飙升甚至拒绝服务。负载均衡把请求分散到多台机器上,相当于大家合力干活。水平扩展,用户再多,加机器就行。
- 水平扩展: 用户的访问量通常不是一成不变的,有高峰有低谷。如果按高峰配置单台超强服务器(垂直扩展),低谷期它就闲置浪费了。用负载均衡配合一堆普通配置的服务器,可以根据流量动态调整机器数量(弹性伸缩),高峰加机器扛住,低谷减机器省钱。资源利用率更高,成本更优。
2. 基本工作原理
其实,负载均衡的工作原理很简单,就是根据特定的负载均衡算法来实现流量的智能转发(说白了就是按照某种规则决定转发到哪个后端服务器)。
一般来讲,一个请求就会对应着一个响应,按照响应返回时是否经过负载均衡器,负载均衡器的工作模式可以分为以下两种
- NAT工作模式:在这种工作模式中,请求数据和响应数据都会经过负载均衡器,Nginx就是这种工作模式。
- DR工作模式:在这种工作模式中,请求数据经过负载均衡之后被改写,后端服务器收到请求之后,将响应数据直接返回给客户端,而不经过负载均衡,后面讲到的F5、LVS等都支持这种工作模式。
除了以上工作模式之外,负载均衡根据其路由决策使用的数据处于ISO网络分层中的哪一层,又分为了L4层负载均衡和L7层负载均衡。
L4层负载均衡
负载均衡进行路由决策的依据是:IP和端口。在ISO网络分层模型中,IP为L3网络层的概念,端口为L4传输层的概念。负载均衡器主要看请求是哪个 IP 发来的(源 IP)、目标是哪个 IP 和端口(目标 IP:Port)。它不怎么关心请求里具体是啥内容(比如 HTTP 请求的 URL 或 Header)。
L4层负载均衡最大的特点就是:速度快、效率高,因为它处理的信息少,就像快递分拣只看邮编和街道,不拆包裹看里面是啥。适合对传输速度要求高、不需要根据内容做精细路由的场景。
L7层负载均衡
L7层负载均衡根据ISO网络模型中第七层应用层的数据做路由决策。也就是说,调度员会“拆开包裹”看看里面的内容,再决定如何投递。对于 HTTP请求,它会解析 URL 路径、HTTP 头信息(如 Host, Cookie, User-Agent)、甚至是请求体内容。
它的优势是功能更加强大、更智能、更能贴合业务的路由需求,可以实现非常精细的路由策略。比如基于URL的路径进行路由到不同的微服务上,将包含特定Cookie(比如version=beta)的请求导到测试环境,甚至还可以做简单的请求改写(比如加/减 Header)。
当然,天下没有免费的午餐,以上优势的代价是对性能的损耗。解析应用层协议肯定比只看IP和端口要消耗更多资源,因此,L7负载均衡要比L4负载均衡转发速度慢。
3. 常用负载均衡
负载均衡器本身可以是一个专门的硬件设备(比如F5等),也可以是软件(比如Nginx, HAProxy, LVS 等),甚至还可以是DNS。
(1)DNS负载均衡
我们知道,DNS的作用是将域名解析为IP。实际上,一个域名可以配置多条IP解析规则,DNS可以轮询或者随机返回一个域名请求对应的IP,当然,也可以根据地理位置就近原则(比如,国内IP对域名的访问,DNS会返回国内的服务器IP)或者通信线路(移动、联通、电信等)返回不同的IP。DNS只能实现全局入口流量的粗略调度,它无法替代其他更加精细的负载均衡器。
(2)硬件负载均衡
硬件负载均衡器是特制的硬件(硬件加速、专用芯片),是企业级高性能的首选,当然,价格相对于软件负载均衡,也更加昂贵。代表的产品有F5等。
(3)软件负载均衡
LVS、HAProxy、Nginx都是软件负载均衡,不同的地方是,LVS是L4层负载均衡,Nginx是L7层负载均衡,HAProxy根据配置,既可以工作在L4层,也可以工作在L7层。相对于硬件负载均衡器,软件负载均衡在性能上要稍逊色一些,但价格要便宜太多,只需要部署在一台普通的服务器上就可以。
4. 性能指标对比
对于负载均衡器,表征性能的指标主要以下几个。
(1)CPS(每秒新建连接数,Connections Per Second)
它表示负载均衡器每秒能处理多少个新建的TCP连接。在短连接场景(如移动端连接频繁重建),CPS往往是首要瓶颈。一台经过优化的LVS,CPS可以达到50万~100万/秒。
(2)并发连接数(Concurrent Connections)
他表示负载均衡器同时能维持的最大连接数。每个连接都会占用一定的内存(用于记录连接状态、会话信息等),因此并发连接数主要受内存限制。在长连接场景(如WebSocket、消息推送、数据库连接池)中,这个指标比CPS更重要。
(3)吞吐量(Mbps / Gbps)
这是L4层负载均衡器最直观的性能指标,表示单位时间内转发的数据总量,以Mbps或Gbps为单位。例如,一台LVS服务器经过优化后,可以达到10Gbps甚至更高的吞吐量。这个指标适合评估大流量传输场景,如文件传输、视频流、数据库同步等。
(4)每秒数据包数(PPS,Packets Per Second)
吞吐量关注的是“数据量”,而PPS关注的是“数据包个数”。当数据包很小时(如HTTP短连接、DNS查询、心跳包),即使吞吐量不高,PPS也可能成为瓶颈。对于小包场景,PPS比吞吐量更关键。高性能的L4负载均衡器可以达到数百万到千万级别的PPS。
(5)QPS(每秒查询数,Queries Per Second)
这是七层负载均衡器的专有指标,表示每秒钟能转发的HTTP请求数。QPS反映了负载均衡器解析应用层协议、处理HTTP头部、执行路由规则等能力。Nginx、HAProxy等七层负载均衡器通常用QPS来衡量其性能。
(6)延迟(Latency)
指请求从进入负载均衡器到转发出去的时间。虽然负载均衡器本身引入的延迟通常很低(微秒级),但在高并发下或执行复杂规则时,延迟可能增加。对于延迟敏感型业务,这个指标需要重点关注。
| 负载均衡器 | 类型 | CPS (万/秒) | 并发连接数 (万) | 吞吐量 | PPS (万/秒) | L7 QPS (万/秒) |
|---|---|---|---|---|---|---|
| F5 | 硬件 (L4/L7) | 100~300+ | 1000+ | 100~200 Gbps | 1000~2000 | 100~200 |
| LVS | 软件 (L4) | 50~100 | 100~200 | 10~40 Gbps | 200~500 | N/A |
| HAProxy | 软件 (L4/L7) | 20~50 | 50~100 | 5~20 Gbps | 50~100 | 10~30 |
| Nginx | 软件 (L7) | 10~30 | 30~50 | 2~10 Gbps | 30~50 | 5~20 |
5. 负载均衡选型
在做架构设计时,如何选择使用哪种负载均衡器?是 F5 这样的硬件设备,还是 LVS、HAProxy、Nginx 这类软件方案?这需要根据业务规模、性能要求、成本预算综合权衡。
(1)中小规模场景(QPS < 3 万)
如果业务 QPS 在 3 万以内,单台 Nginx 或 HAProxy 完全足够。经过合理调优,Nginx 单机可以稳定支撑 3 万~5 万 QPS,甚至更高(取决于请求复杂度和硬件配置)。此时架构最简单:
客户端 → Nginx/HAProxy → 后端服务这种单层架构运维成本低,故障排查简单,适合大多数创业公司和中型业务。如果担心 Nginx 单点故障,可以通过 Keepalived 实现主备切换,或者直接使用云厂商的负载均衡服务(如阿里云 SLB、AWS ELB)作为入口。
(2)大规模场景(QPS 3 万 ~ 20 万)
当 QPS 超过单台 Nginx 的处理能力,或者需要更高的可用性保障时,采用“LVS + Nginx 集群”的两级架构:
客户端 → LVS(四层) → Nginx 集群(七层) → 后端服务- LVS:工作在四层,基于 Linux 内核实现,性能极高,负责将流量均衡到多台 Nginx 上,同时通过 VIP 实现主备切换,保障入口高可用。
- Nginx:部署多台实例,承担七层负载职责,包括路由分发、限流、缓存、安全防护等。Nginx 集群可以水平扩展,当流量增长时只需增加 Nginx 节点即可。
这种两级架构兼顾了四层的高性能和七层的灵活性,是生产环境中最成熟的标准模式,广泛应用于各大互联网公司。
(3)超大规模或极端性能场景(QPS > 20 万 或 CPS > 50 万)
当业务规模达到顶级互联网级别(如双十一、春晚红包),或者对延迟有极致要求(如高频交易、实时竞价),可以考虑硬件负载均衡器(F5 等)。
客户端 → F5(硬件四层/七层) → 后端服务(4)全球多地域场景
如果业务覆盖全球用户,需要在 DNS 层做第一级流量调度。通过智能 DNS(如阿里云 DNS、AWS Route),根据用户地理位置、运营商、负载情况,将请求解析到最近的机房入口,实现全局流量调度。每个机房内部再部署 LVS + Nginx 两级架构,处理本地流量。
DNS(全局流量调度) → 地域级 LVS → 地域级 Nginx 集群 → 后端服务6. 负载均衡算法
负载均衡的核心工作原理可以概括为:流量分发+智能调度。前面都在讲流量分发,接下来,我们再讲一下智能调度。负载均衡会依赖不同的策略,将流量分配给不同的后端服务器,这里的策略就是负载均衡算法,是在负载均衡器中预设置好的。
常用的负载均衡算法有以下几种。
(1)轮询
这是最公平的流量分配算法,将请求按顺序依次分配给后端服务器列表中的每一个服务器,循环往复。
Nginx 默认采用轮询算法,配置 upstream 块时不指定任何参数即为轮询。示例:
upstream backend {
server 192.168.1.1;
server 192.168.1.2;
server 192.168.1.3;
}(2)加权轮询
考虑后端服务器性能差异,为了让性能好的服务器多承担一些流量, 在轮询的基础上,为每台服务器分配一个权重值(Weight)。权重高的服务器被分配到的流量比例更高。
Nginx 通过 weight 参数实现。示例:
upstream backend {
server 192.168.1.1 weight=5;
server 192.168.1.2 weight=3;
server 192.168.1.3 weight=2;
}(3)基于哈希
基于源IP地址或者URL,又或者业务信息(如用户 ID),通过计算哈希值,然后跟服务器个数取模,得到请求对应的服务器编号,将请求分发给这个服务器。这样,同一个来源(同一个 IP 或同一个用户)的请求始终会被分配到同一台服务器上。
Nginx 通过 hash 指令实现,支持指定 key(如 remoteaddr或request_uri)。示例:
upstream backend {
hash $remote_addr; # 基于源 IP 的哈希
server 192.168.1.1;
server 192.168.1.2;
server 192.168.1.3;
}(4)最小连接
负载均衡实时获取后端服务器的负载,这里的负载通过连接数来表征,将请求分发给负载最小的后端服务器,以达到各个服务器的负载均衡。相对于前面几种静态负载均衡算法,这是一种动态负载均衡算法。
Nginx 通过 least_conn 指令实现。示例:
upstream backend {
least_conn;
server 192.168.1.1;
server 192.168.1.2;
server 192.168.1.3;
}(5)最短响应时间
负载均衡实时获取后端服务器的响应时间(某段时间内的平均响应时间),将请求分发给响应时间最短的服务器。这也是一种动态负载均衡算法,比最小连接更精细,因为它考虑了响应速度而非仅连接数。
Nginx 商业版(Nginx Plus)支持 least_time 指令。社区版(开源版)默认不支持。
upstream backend {
least_time header; # 考虑接收响应头的时间
# least_time last_byte; # 考虑完整响应时间
server 192.168.1.1;
server 192.168.1.2;
server 192.168.1.3;
}当然,除了以上负载均衡算法之外,还有其他算法,而且,也并不是所有的负载均衡器都支持以上所有的负载均衡算法,具体要查阅相应的负载均衡器的实现。
7. 最后总结
负载均衡在架构设计中至关重要,可以起到高可用、高性能、水平扩展的作用。在这个文章中,我们讲了负载均衡的基本工作原理,常用负载均衡器,技术选型,以及常用的负载均衡算法。希望今天的内容能帮你对负载均衡有一个全面的了解,方便今后将其合理的应用在架构设计中。
二、系统通信
前面我们讲到,构建高可用、高性能、可伸缩的大型系统,分布式是必然的选择。不同的系统之间通过网络通信,协同工作,作为整体提供服务。系统与系统之间的通信非常重要。这节课我们会先笼统的讲解一下系统通信的底层实现方式,包括长连接和短连接,然后重点讲解“业务”系统之间的通信方式:接口调用,包括HTTP接口调用和RPC接口调用,以及如何做技术选型,同时,这也是面试中常考的技术点。
1. 分布式系统通信
在分布式系统中,通信无处不在。你打开一个 App,点一下“下单”按钮,背后可能就触发了一系列通信:
- 你的手机(客户端)跟后端服务器之间的通信;
- 后端服务器之间互相调用的通信;
- 服务器跟 Redis 之间的通信;
- 服务器跟数据库之间的通信;
- 服务器往消息队列里发消息的通信
这些通信就像人体的“神经网络”,把分散在各地的节点连接成一个系统。一次用户请求,背后可能涉及十几次甚至几十次通信调用。如果每次通信多花 10 毫秒,累加起来就是几百毫秒。用户的感知就是“这个 App 有点卡”。
如果按通信发生的双方来分,我们可以把分布式系统中的通信分成四大类:
(1)客户端 ↔ 服务端通信
这是用户直接感知的通信。手机 App、浏览器向后端发起的请求,都属于这一类。这类通信的特点是:网络环境复杂(4G、WiFi),延迟不可控,客户端数量巨大(动辄百万、千万级)。所以通常用通用协议(比如 HTTP/HTTPS),方便各种终端接入,也方便调试。
(2)服务间通信(内部微服务)
后端系统内部“自己人”之间的通信。比如订单服务调用用户服务、支付服务调用账户服务。这类通信的特点是:网络链路稳定(一般都在同一个数据中心),调用频率极高,对延迟敏感。一个用户请求进来,可能层层调用三五个甚至十几个服务,如果每次调用都慢 10ms,整体响应时间就奔着 100ms+ 去了。所以这类通信往往追求极致性能,用 RPC、二进制协议、长连接、连接池等手段来提高性能。
(3)数据层通信
业务系统跟数据库、缓存、搜索引擎之间的通信。比如 MySQL 的 SQL 执行、Redis 的 GET/SET 命令、Elasticsearch 的查询请求。这类通信对延迟极度敏感。数据库慢一点,整个业务就慢一点,所以,几乎都用长连接 + 连接池,避免反复建立连接的开销。
(4)消息驱动通信
通过消息队列(如 RocketMQ、Kafka)进行的异步通信。生产者发送消息,消费者拉取或推送消息。这类通信的特点是:解耦、削峰、异步。生产者不关心消费者是谁、在不在线,消费者也不关心生产者什么时候发的。这类通信通常也是长连接(尤其是生产者到 Broker、消费者到 Broker 之间),保证高吞吐和低延迟。
2. 长连接 VS 短连接
我们刚刚提到了一些分布式系统中的常用组件(Redis、MQ等),本质上来讲,它们的通信方式的主要区别在于应用层协议(比如,HTTP协议、MySQL协议、Redis协议、Dubbo协议、RocketMQ协议)和连接管理方式的不同。
应用层协议可以简单理解为数据格式的定义,每个组件都不同,我们不展开讨论。连接管理方式可以分为短连接和长连接。
不管是短连接还是长连接,它们在网络传输层最终都要依赖操作系统提供的 TCP Socket 来建立连接、发送和接收数据。Socket 是操作系统提供的网络通信的编程接口,TCP 是可靠的、面向连接的传输层协议。
短连接的流程很简单:客户端和服务器每进行一次数据交换,就建立一次 TCP 连接,传输完毕立即断开。经典的例子是 HTTP/1.0:每次请求都需要三次握手建立连接,传输数据,再四次挥手断开连接。
这种方式简单直接,但缺点也很明显:频繁建立和断开连接消耗大量资源。三次握手和四次挥手本身就有网络延迟,加上系统需要为每个连接分配 socket 文件描述符和内存,在高并发场景下,很容易把服务器拖垮。具体可以看我们的计算机网络课程的讲解。
长连接恰好相反,一旦建立连接,就会在多次数据传输中重复使用,直到空闲超时或主动关闭。例如,HTTP/1.1 的 Keep-Alive 机制、数据库连接池、RPC 框架的底层通信,都是长连接的典型应用。
长连接避免了反复 TCP 握手和挥手的开销,显著提升了性能。但长连接也有它的“麻烦”:需要额外的心跳机制来维持连接活性,防止被防火墙或运营商切断。比如 TCP 默认的空闲超时是 2 小时,但很多中间网络设备(如防火墙)可能会在几分钟内就把空闲连接干掉。所以,长连接应用通常会定期发送心跳包(比如每 30 秒发一个 PING),让连接保持活跃。
我们来看几个具体组件是怎么选的:
- RocketMQ:Producer 和 Broker 之间是长连接,可以持续发送消息;Consumer 在 Push 模式下跟 Broker 也是长连接,但在 Pull 模式下,Consumer 每次拉取消息时建立短连接,拉完就断。为什么要这样?因为 Push 模式需要 Broker 主动推消息,连接必须长期保持;而 Pull 模式是 Consumer 主动来取,取完就走,短连接反而更简单。
- MySQL:MySQL 客户端默认是短连接,执行一条 SQL 就断开。但生产环境几乎都会用连接池(如 HikariCP、Druid),连接池底层维护的是一批物理长连接,业务代码从池里借一个连接,用完后归还,从业务视角看是“短”的,但底层其实是长连接复用。这种做法兼顾了编程的简单性和连接的高效性。
- Redis:Redis 客户端通常也是长连接 + 连接池。因为 Redis 本身是内存操作,极快,如果每次命令都新建连接,三次握手的开销可能比命令本身还大。
所以,你会发现,大部分基础组件都倾向于用长连接来保证性能。但在服务间和端到服务间的通信中,到底用长连接还是短连接,就需要根据具体业务场景来权衡了。
3. HTTP接口 VS RPC
在业务开发中,最常见的服务间通信方式有两种:一种是直接接口调用(同步),另一种是发送异步消息(通过消息队列)。异步消息我们后面会专门讲,这节课我们聚焦同步接口调用。而接口调用又有两种主要形态:HTTP 接口和 RPC 调用。
(1)HTTP 接口:简单、通用、调试方便
HTTP 接口基于我们熟悉的 HTTP 协议,通常以 RESTful 风格设计(虽然很多项目会对 RESTful 做简化,但基本思想一致)。它的最大优势是通用性:任何语言、任何平台,只要支持 HTTP 就能调用。比如,Java 服务通过 Spring Boot 暴露 HTTP 接口,Python 脚本、Node.js 后端、甚至手机 App 都可以直接调用。另外,调试也极其方便:用 curl 就能测,用浏览器也能看响应。
但 HTTP 接口也有它的短板。传统 HTTP/1.0 基于短连接,每次请求都新建连接,效率低。虽然 HTTP/1.1 引入了 Keep-Alive,可以复用连接,但在实际生产环境中,由于中间代理、负载均衡器的存在,Keep-Alive 的效果往往不如直接使用长连接稳定。另外,HTTP 协议是文本协议,传输 JSON 或 XML 数据时,序列化/反序列化开销较大,消息体积也大,对性能敏感的场景不太友好。
(2)RPC 调用:高性能、服务治理能力强
RPC(Remote Procedure Call)的目标是让远程调用像本地方法调用一样简单。比如 Dubbo、gRPC 这类框架,会生成代理对象,开发者直接调用代理对象上的接口方法,框架自动完成网络传输、序列化、负载均衡、服务发现等工作。
RPC 通常采用二进制协议(如 Protobuf、Hessian2),数据体积小、序列化速度快。而且 RPC 框架一般内置长连接池,避免频繁建连的开销。很多 RPC 框架还提供了丰富的服务治理功能,比如服务注册与发现、负载均衡、熔断降级、链路追踪等,这些都是构建微服务体系的重要功能。当然,这也被逐渐改写,Spring Cloud也同样提供了各种微服务治理的功能。
之前参与过一个电商项目,内部服务数量超过 200个。早期他们使用 HTTP + JSON 作为服务间通信协议,结果发现每到双十一大促,网络带宽就被打满,序列化开销也把 CPU 吃得很高。后来全面迁移到 Dubbo,带宽占用降低了 60%,CPU 使用率也下降了 30%。这是因为二进制协议的消息体积远小于 JSON,而且长连接复用避免了频繁的三次握手。
RPC 的代价是语言绑定性强。虽然gRPC支持多语言,但整体生态仍不如HTTP开放。调试也更复杂,往往需要专用工具。在我之前工作过的一家公司里,当时的微服务就是使用HTTP而非RPC来实现接口。其主要考虑的因素是Debug问题的难度。RPC框架为了性能,一般都会用很多异步,池化,多线程,长连接技术,框架的复杂度比短连接高好几个数量级,当线上出现问题时,debug起来很麻烦,而HTTP实现方式的好处在于底层逻辑清晰明了,一个请求一个线程,处理完之后释放,请求与请求之间,线程与线程之间没有任何逻辑交叉,debug问题简单多了。我们并非像阿里那样的超级大平台,内部服务也没那么多,调用关系也没有那么复杂,系统对性能的追求还没有达到那么的极致,HTTP的调用方式(HTTP1.1支持长连接)并不会成为性能瓶颈的情况下,我们就老老实实选择了HTTP调用方式。
4. 最后总结
通信成本是分布式系统最大的隐性开销(网络延迟可能占请求时间的60%),因此,选择合适的通信方式非常重要,特别是跟业务程序员息息相关的服务间以及端到服务间的通信。这部分通信往往是通过接口调用实现的,常用的接口暴露方式有基于短连接的HTTP接口和基于长连接的RPC调用,一般来讲,内部服务选用RPC调用,外部服务选用HTTP接口,但是,这也不是绝对的,可以根据公司技术栈、项目大小、性能压力等自由选择接口暴露方式。
三、Web容器
前面的文章中,我们讲到请求经过负载均衡到达后端服务器,后端服务器执行相应的业务逻辑。如果聚焦到Web应用,对于HTTP请求来说,后端服务器需要解析HTTP请求中的JSON等序列化数据,转换成后端编程语言中的对象,然后找到请求(URL)对应的业务逻辑代码(类及其方法),最终执行之后,将对象形式的执行结果,转换成JSON等序列化数据,封装到HTTP Body中返回。
这一整套流程,其实跟业务代码没太大关系。你写订单逻辑的时候,并不关心 HTTP 头部怎么解析,也不关心线程池怎么管理。如果每个项目都要自己实现一遍这些底层处理逻辑,那程序员就别写业务了。我们完全可以将这部分非业务功能代码抽离出来,做成独立的框架(内嵌式Web容器)或者系统(独立部署Web容器),于是就有了Web容器。
1. Web容器的部署方式
根据存在形式和使用方法,Web 容器可以分为两类:独立部署 Web 容器和内嵌式 Web 容器。
(1)独立部署 Web 容器
这类容器需要预先安装并独立运行,应用程序按特定规范编写并打包后,部署到容器里。Tomcat 是典型的代表:开发者按照 Servlet 规范编写代码,打包为 WAR 文件,丢到 Tomcat 的 webapps 目录下,Tomcat 负责加载并执行。
这种模式在传统企业级开发中非常普遍。我记得刚入行时,每次上线都要 ssh 到服务器,把 WAR 包 scp 过去,然后重启 Tomcat。一台服务器上可能部署了多个应用,通过不同的端口或上下文路径区分。优点是隔离性好,一个应用挂了不影响其他应用;缺点是运维成本高,每台服务器都要提前装好 Tomcat,管理起来比较麻烦。
(2)内嵌式 Web 容器
内嵌式容器将 Web 服务器功能直接集成在应用代码中,编译后形成自包含的可执行文件。Go 语言的 net/http 包就是典型:用它开发的业务代码,编译成一个二进制文件后直接运行,就能启动完整的 Web 服务,无需外部容器。
Java 生态里,Spring Boot 也提供了内嵌式方案。它把 Tomcat、Jetty 打包进应用,执行 java -jar 就能启动。这种方式极大地简化了部署流程——你只需要一个 JDK 和你的 JAR 包,不需要提前安装 Tomcat,也不需要配置 catalina.sh。现在很多云原生应用都采用这种方式,和 Docker 容器天生契合。
这两种模式没有绝对的好坏,更多是运维习惯和团队偏好。传统企业可能更习惯独立部署。运维统一管理 Tomcat 版本和配置,开发只负责出 WAR 包。云原生团队则更喜欢内嵌式——每个应用自包含,镜像构建简单,扩容时直接拉起新容器就行。
2. Web容器工作原理
不管是独立部署还是内嵌式,Web 容器做的事情基本一致。我们简单总结一下:
(1)动态请求处理
这是 Web 容器和 Nginx 最本质的区别。Nginx 擅长处理静态请求,即直接返回磁盘上的 HTML/CSS/JS 文件,不需要执行业务逻辑。Web 容器则能处理动态请求,需要执行业务代码实时生成返回的内容,比如查数据库、做权限校验。
之所以有这个能力,是因为 Web 容器内置了语言运行时环境。Tomcat 里有 JVM,能加载并执行 Java 字节码;Go 的 net/http 直接运行编译后的二进制;Python 的 Django 则依赖 Python 解释器。Nginx 没有这样的执行引擎,所以它只能做代理或静态文件服务,处理不了动态内容。
(2)协议解析与封装
这是 Web 容器最基础的“翻译”工作。它把原始的 HTTP 字节流,转换成编程语言可操作的对象:
- Java 的 Tomcat 通过 HttpServletRequest 和 HttpServletResponse 封装请求/响应
- Go 的 net/http 提供 http.Request 和 http.ResponseWriter
- Python 的 Django 构建 HttpRequest 对象
同时,Web容器还统一处理了连接复用(Keep-Alive)、分块传输编码(Chunked Encoding)等网络传输细节。作为开发者,我们只需要从 request 对象里拿参数,往 response 对象里写结果,完全不用关心底层 Socket 是怎么收发数据的。
(3)生命周期管理
Web容器提供了请求从接收到响应的完整生命周期管理,例如,Tomcat通过init()初始化Servlet,service()处理请求对应的业务逻辑,最终由destroy()回收资源;Go的HTTP服务在注册HandlerFunc后自动执行回调。
我们在编程时,无须关心请求到响应的处理流程,不用关心“什么时候该解析请求”“什么时候该返回响应”这类问题,只需要在Web容器预留的钩子(hook)中,编写业务逻辑代码即可,这就有点类似模版设计模式。
(4)并发模型实现
后端服务必须支持高并发,这是 Web 容器的核心能力之一。Web 容器需要同时做好两件事:一是并发接收 HTTP 请求(网络层),二是并发执行业务逻辑(计算层)。
不同语言的 Web 容器采用了不同的并发模型:
- Java Tomcat 用 epoll 等机制支撑高并发连接,用线程池执行业务逻辑
- Go 的 net/http 用 epoll 接收连接,用 Goroutine(协程)执行业务逻辑
- Python 的 Django 用异步事件循环 + 协程实现高并发
并发模型的选择直接影响系统的性能表现。下面我们深入讲一讲 Tomcat 和 Go 这两种典型的实现方式。这部分跟架构设计息息相关,并发参数设置不合理,再好的业务代码也跑不出性能。
3. Tomcat 并发模型
刚刚我们对Web容器做了简单的介绍,介绍了其核心职责,其中一项是并发模型,这里我们就展开重点讲一讲。为了言之有物,我们聚集到Java的Tomcat Web容器来举例讲解。
并发模型一般包括两部分:并发连接和并发线程(并发线程并发执行请求对应的业务逻辑)。在Tomcat中,这两个值通过两个参数(maxConnections和maxThreads)来设置。
<Connector
port="8080"
maxConnections="10000" # 支撑大量空闲连接(如WebSocket)
maxThreads="500" # 最大线程数
minSpareThreads="50" # 最小空闲线程
acceptCount="100" # 等待队列长度(这个我们暂时不去管它)
connectionTimeout="20000" # 超时时间(ms)
/>(1)并发连接maxConnections
并发连接(maxConnections)指的是可以有多少客户端同时连接到Web容器,在网络层面就是TCP连接数,在操作系统的socket编程层面,就是同时建立连接的socket个数。
并发连接主要消耗内存和操作系统的文件描述符。每个 socket 都有接收缓冲区和发送缓冲区(默认每个约 64KB,加起来 128KB)。如果 maxConnections 设成 10000,光缓冲区就要消耗约 1.28GB 内存。所以这个值不能无脑开大。
但这里有个关键点:maxConnections 并不直接决定性能,它受制于 maxThreads。如果连接数很大,但线程数很小,大量请求来了却没人处理,就会积压、超时、甚至被拒绝。
对于短连接请求,连接上之后就发送请求,发送完就结束连接,并发连接数设置的稍大于并发线程数(比如3 ~ 5倍,大一点是为了起到缓冲请求的作用)就可以了,太大了服务不了,也没有意义。但是,对于像Websocket这种长连接请求,尽管大量客户端连接着后端服务器,但是发送请求的频率却很低(比如每秒1次,对于服务器来说算低了)。连接虽多,但请求不多,在这种场景下,为了支持大量客户端同时连接,就可以将并发连接设置的比并发线程数大很多(比如50 ~ 100倍)。
Tomcat 默认 maxConnections=10000,maxThreads=200,为什么默认设这么大?其实也说明了一个道理:maxConnections 大一点问题不大,大不了线程忙不过来就拒绝连接,早拒绝(连接时被拒)和晚拒绝(等线程时被拒)都是拒绝,对用户体验影响不大。但要考虑内存,如果内存有限,又不需要支持大量长连接,建议把这个值调低。
(2)并发模型-maxThreads
综合上面的分析,我们也隐约感觉到,相比于 maxConnections,maxThreads 重要得多。它决定了业务逻辑能同时执行多少个请求。这个值设置多大,是面试常问的问题,也是很多系统性能问题的根源。
对于CPU密集型程序,对应的线程池不需要太大,跟可用CPU核数相当或稍大即可。这样便可以充分的利用CPU资源。对于I/O密集型程序,因为程序的大部分时间都在执行I/O操作,所以,CPU利用率很低。为了提高CPU的利用率,我们可以将线程池适当开大点,以便多个线程轮流使用CPU。那么,既非CPU密集又非I/O密集的程序,对应的线程池大小又该如何设置呢?有没有具体的计算机公式可以给出线程池大小的确切值呢?
从理论上来讲,确实存在这样的计算公式。假设通过监控统计,我们得知线程池所执行的任务的平均CPU耗时和平均I/O耗时(确切的讲应该为非CPU耗时,但是,大部分非CPU耗时一般都花费在I/O上,因此,这里就直接使用I/O耗时代指非CPU耗时),那么,线程池大小的计算公式就应该如下所示。
并发线程数 = CPU核数 × 目标CPU利用率 × (1 + 平均I/O时间 / 平均计算时间)比如,某订单服务,统计接口的平均响应时间50ms,其中,等待DB/Redis等的I/O时间为45ms(I/O等待占比90%),CPU计算时间为5ms。除此之外,CPU为8核,目标CPU利用率通常为70%~80%(留余量防突发流量),带入公式得到:
并发线程数 = 8核 × 0.8 × (1 + 45/5) = 8 × 0.8 × 10 = 64这个公式看起来很有道理,但实际工作中,我见过很多面试者把这个公式当圣经背,却没意识到它的前提条件。这个公式假设 I/O 资源是无限的,CPU 是唯一的瓶颈。但在真实系统里,I/O 资源往往才是真正的短板。
假设服务依赖数据库,数据库连接池大小为 50。当线程数大于 50 时,多出来的线程并没有数据库连接可用,只能排队等待连接。这时候,再多的线程也不会提高吞吐量,反而会因为线程切换增加开销。
还有磁盘 IOPS、网络带宽、内存大小,都可能成为瓶颈。所以,线程池大小不应该超过链路上最小的资源限制。我们要建立全链路的资源视图:请求从进入容器到返回,会经过哪些资源?数据库连接池、Redis 连接池、下游服务线程池……线程池大小必须小于等于这些资源的最小值。
不过,这个计算公式过于理想化,它默认I/O资源是无限的。这也是只会背八股文的程序员在面试时经常犯的错误。实际上,这个公式计算的是:CPU在充分利用的情况下的并发线程数,但是,有可能CPU还没有达到充分利用,其他资源(数据库连接池、磁盘IOPS、网络带宽、内存的大小)就已经到达瓶颈了。
Tomcat 默认 maxThreads=200,这不是随便选的。200 个线程意味着:
- 每个线程默认栈大小 1MB,光线程栈就要 200MB 内存(还不算堆内存)
- 200 个线程同时运行,CPU 上下文切换开销已经不小
- 大多数业务场景下,200 线程足以支撑几千 QPS
某公司把一个核心服务的 Tomcat maxThreads 从 200 调到了 800,以为能提高并发能力。结果上线后,数据库连接池被撑爆,大量连接在等待数据库连接,最终导致数据库连接超时,服务大面积报错。最后回滚配置,并把数据库连接池也扩容了才解决。这个教训说明:线程池是显性的,但背后依赖的资源往往是隐性的。调大线程池之前,得先看看下游能不能扛得住。
4. Go Web并发模型
刚刚我们讲了Tomcat的线程池并发模型实现,我们再对比看下Go Web容器的协程并发模型实现。
在Go语言的Web容器(如net/http)中,采用了基于Goroutine(协程)的并发模型,这与Tomcat的线程池模型有本质区别。Goroutine是Go语言运行时管理的轻量级用户态线程,其核心优势在于:
- 极低资源开销:每个Goroutine初始栈仅2KB(可动态扩缩),远小于Java线程栈1MB,但从内存上看,单机可轻松支撑数十万甚至百万并发。
- 非阻塞调度:当Goroutine执行I/O操作时(如数据库查询),Go运行时会自动将其挂起,调度其他Goroutine运行,避免线程阻塞。涉及到线程模型的实现(用户级线程),可以看看我们操作系统课程中的讲解。
- 协程切换开销很小:Go协程可以看做用户级线程,切换只发生在用户态,相对于Java线程切换(涉及内核线程的切换)来说,要快很多,Java线程切换可能需要几微秒(取决于CPU等硬件性能),而Go协程的切换可能只需要200纳秒左右,具有十倍的性能优势。
也就是说,Tomcat在设置线程池大小的时候,需要考虑内存消耗、线程过多导致频繁切换开销这些因素,那么,对于Go Web容器来说,完全就不需要考虑这些问题了。但是,这是不是意味着Go Web容器就可以无限创建很多很多协程去处理请求呢?当然不是!因为还有其他资源限制,比如数据库连接池、Redis连接池、磁盘IOPS、网络带宽等等。
如果有大量请求到来,Tomcat处理不了(超过maxThreads设置的处理上限),就会抛出HTTP 503拒绝服务,相当于一种fail fast策略,起码能保证剩下的请求正常响应。但是,Go Web容器会持续创建协程,但是,会有大量的请求阻塞在等待数据库连接池等瓶颈资源,导致超时。而拒绝比超时更明智,因为等待超时的请求需要长时间占用内存等资源直到超时,而拒绝会立即释放资源。
这样看来,Tomcat天生提供的maxThreads配置反倒是一种优势,相当于一种限流机制。相反,Go的“无限协程”只不过是把资源管理的责任转嫁给开发者罢了。开发者仍需用channel+select实现限流,或用semaphore.Weighted控制并发度。
5. 最后总结
这节课我们重点讲解了Web容器的并发模型,包括Java Tomcat的线程池实现方式和Go Web容器的协程实现方式,重点讲解并发模型而非Web容器其他核心功能的原因,是因为这部分跟整体的架构设计有关,需要合理的设置并发连接和并发执行数,保证架构设计的性能。
四、KV数据库
今天这节课我们来介绍Key-Value数据库,特别重点介绍Redis,它在后端架构中非常常用。当然,我们并不会讲解它的底层实现原理,我们会将讲解的重点放到:Redis的关键特性、应用场景、性能分析、高性能和高可用的架构方案,这些才是跟我们的课程(系统设计与架构)息息相关的内容。
当然,有些内容你可能有了解,就当复习一下,毕竟课程内容要保证全面系统,为后面的内容做铺垫,不能你会我就不写了。
1. KV数据库介绍
顾名思义,Key-Value 数据库的核心思想就俩字:键值对。你可以把它想象成一个超级大的字典(或者说哈希表)。每一条数据都由一个唯一的键(Key)和一个对应的值(Value)组成。你想存数据?好,给我一个 Key 和一个 Value。你想读数据?告诉我 Key,我立马把 Value 找出来给你。你想删数据?还是告诉我 Key 就行。
它的接口通常极其简洁,核心操作往往就是 Get(Key), Put(Key, Value), Delete(Key) 这么几个。这种简单直接的设计,正是它威力所在。
(1)因为简单,所以快。
没有复杂的表连接,没有多表事务(或者事务支持很有限),没有模式(Schema)约束(Value 的结构可以很灵活,可以是字符串、数字、二进制 blob,甚至是 JSON、Protocol Buffers 序列化后的数据)。
这种“轻装上阵”的特性,让 Key-Value 数据库在读写速度上,尤其是简单查询(根据 Key 查 Value)的性能,通常能碾压传统的关系型数据库。毫秒级甚至微秒级的响应很常见,特别适合那些对延迟极度敏感的场景,比如缓存、会话存储。
(2)值的灵活性与代价。
Key-Value 数据库的 Value 通常没有固定结构,这既是优点也是缺点。
优点是灵活,同一个“表”(或者叫集合、Namespace)里,不同 Key 对应的 Value 结构可以完全不同。今天存个字符串,明天存个 JSON 对象,后天存张图片的二进制数据,理论上都可以。这在需求快速变化或者存储半结构化数据时很方便。
但缺点也很明显:数据库本身几乎不提供对 Value 内部结构的查询能力。你想根据 Value 里面的某个字段(比如 JSON 里的 user.name)来查询?对不起,数据库层面通常做不到(或者效率极低)。你只能先通过 Key 把整个 Value 取出来,然后在应用层自己解析、过滤。这就意味着,如果你的查询模式经常需要基于 Value 内部属性,Key-Value 数据库可能就不太合适了。
(3)持久化与内存之争。
Key-Value 数据库在发展过程中,形成了两条截然不同的技术路线:内存优先型和持久化优先型。
内存优先型这类数据库的核心设计目标是极致的读写速度,数据主要存放在内存中。内存访问是纳秒级的,比磁盘快几个数量级。因此,这类数据库的读写延迟通常能做到微秒级别,吞吐量也极高。Redis 和 Memcached 就是典型代表。
但内存有两个特点:贵且易失。贵意味着数据规模受限于内存容量,你不可能像用磁盘一样存 TB 级数据;易失意味着断电后数据就没了。为了解决易失性问题,这类数据库通常会提供持久化机制(比如 Redis 的 RDB 快照和 AOF 日志),把内存数据定期或实时写到磁盘上,防止重启后数据丢失。
不过要注意:持久化只是“备份”和“恢复”的手段,不是数据的主要存储位置。Redis 的读写操作仍然以内存为主,持久化是异步的、为了安全而做的附加操作。
另一类 KV 数据库,比如 RocksDB、LevelDB,设计目标完全不同。它们的数据主要存放在磁盘上,内存只是用来做缓存。这类数据库的核心挑战是如何在磁盘上高效地组织数据,保证高吞吐写入和不错的读取性能。
它们通常采用 LSM-Tree(日志结构合并树,后面会在讲解数据存储时详细讲解) 这样的数据结构,把随机写转换成顺序写,利用磁盘顺序写速度远高于随机写的特性,实现极高的写入吞吐量。这类数据库可以处理远超内存容量的数据,几十 TB 甚至 PB 级都没问题,适合做底层存储引擎,比如 Cassandra、HBase 底层就用了类似的机制。
2. Redis核心功能
接下来,我们重点讲解 Redis,这也是我们在架构设计中最常用到的 Key-Value 数据库。Redis 不只提供了简单的 Key-Value 的读写,其实,它还支持丰富的 Value 数据结构和其他关键特性,比如原子操作、Lua 脚本、发布订阅、过期控制等等。
(1)五种数据结构
- String:文本、数字甚至二进制数据
写:SET user:1001 "{\"name\":\"Alice\",\"age\":28}"(存储JSON)
INCR article:10086:views(文章阅读量+1)
读:GET user:1001 → 返回JSON字符串
GET article:10086:views → 返回"157"- Hash:字段值对(适合存储用户对象)
写:HSET product:5001 price 299 stock 50(设置商品价格/库存)
读:HGET product:5001 price → 返回"299"
HGETALL product:5001 → 返回所有字段和值- List:有序元素集合(可实现消息队列)
写:LPUSH news:latest "ID:1001"(左侧插入新闻ID)
RPOP order:queue(右侧弹出待处理订单)
读:LRANGE news:latest 0 4 → 返回最新5条新闻ID- Set:无序唯一集合(可以实现关注列表)
写:SADD user:1001:follows 2002 2005(添加关注用户ID)
读:SMEMBERS user:1001:follows → 返回所有关注ID
SISMEMBER user:1001:follows 2002 → 返回1(存在)- ZSet:带权重的有序集合(可以实现实时排行榜)
写:ZADD leaderboard 95.5 "Player:A" 87.2 "Player:B"
读:ZREVRANGE leaderboard 0 2 WITHSCORES → 返回前三名玩家及分数
ZRANK leaderboard "Player:B" → 返回排名(从0开始)(2)其他关键特性
- 原子操作:MULTI/EXEC事务块内的命令顺序执行且不被中断
计数器:INCR user:login:count(原子+1)
库存扣减:
MULTI
DECR product:1001:stock # 库存-1
LPUSH order:queue "order:20230801" # 订单入队
EXEC # 两条命令原子执行- Lua脚本:在服务端原子执行多命令逻辑,避免网络往返
-- 安全释放分布式锁
-- KEYS[1]=锁名称, ARGV[1]=当前线程标识
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1]) -- 验证锁归属者再删除
else
return 0
end- 发布订阅:轻量级消息通知机制
订阅:SUBSCRIBE news:sports(监听体育新闻频道)
发布:PUBLISH news:sports "湖人夺冠!"- 过期控制:设置Key的生存时间(TTL),到期自动删除
设置:SET session:xyz "user_data" EX 3600(1小时后过期)
查询:TTL session:xyz → 返回剩余秒数3. Redis性能分析
Redis 能达到 10万+ QPS 和 亚毫秒级(零点几毫秒)延迟的高性能,性能是MySQL的十倍以上。如此高性能主要得益于以下几点:
- 纯内存读写:磁盘随机读延迟约 10ms,内存读仅 0.1μs,差了 10 万倍;
- 单线程模型:避免了线程切换和锁竞争导致的性能开销,也让内部数据结构无需考虑并发安全问题,实现更简单高效。
Redis的性能测试我们可以直接查看官网:
https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/benchmarks/
根据 Redis 官方文档,影响 Redis 性能的因素包括:
- 网络带宽:Redis 吞吐量往往先受网络限制,而非 CPU。例如,100,000 QPS 写入 4KB 数据会消耗约 3.2Gbit/s 带宽。
- CPU:Redis 是单线程服务器,偏好高主频、大缓存的 CPU。
- 内存带宽:对小对象影响不大,但大对象(>10KB)时影响明显。
Redis 官方内置了 redis-benchmark 工具,用于模拟 N 个客户端同时发送 M 个请求,测试 Redis 的性能表现。这是官方推荐的标准测试方法。
# 测试 SET 和 LPUSH 命令,10 万次请求
redis-benchmark -t set,lpush -n 100000 -q根据 Redis 官方文档,在普通 Linux 服务器上使用默认配置(50 个并发客户端)的测试结果:
| 命令 | QPS(万/秒) | 说明 |
|---|---|---|
| SET | ~18 | 写入操作 |
| LPUSH | ~18.8 | 列表左侧插入 |
| PING | ~14 | 心跳检测 |
官方强调,使用 Pipeline 可以显著提升性能。Pipeline 允许一次发送多个命令,减少网络往返开销。
# 使用 16 条命令的 Pipeline
redis-benchmark -n 1000000 -t set,get -P 16 -q官方测试结果:
| 命令 | QPS (万/秒) | 说明 |
|---|---|---|
| SET (Pipeline) | ~153.6 | 相比非 Pipeline 提升约 8.5 倍 |
| GET (Pipeline) | ~181.2 | 相比非 Pipeline 提升约 10 倍 |
阿里云官方发布了 Redis 开源版的基准测试数据,里面有测试结果、测试环境、测试工具和测试方法,这里我们仅仅展示它的测试结果。
| 命令 | QPS(万/秒) | 平均延迟 | P99 延迟 |
|---|---|---|---|
| GET | 20.5 | 0.31 ms | 0.47 ms |
| SET | 14.2 | 0.45 ms | 0.72 ms |
| HSET | 12.5 | 0.51 ms | 0.97 ms |
| HGET | 18.9 | 0.34 ms | 0.52 ms |
| ZADD | 11.3 | 0.57 ms | 0.78 ms |
| LPUSH | 15.3 | 0.42 ms | 0.59 ms |
| SADD | 14 | 0.45 ms | 0.63 ms |
通过以上公布的测试数据(官方 or 阿里云),我们可以对Redis的性能有大致的了解。对于自己的线上Redis环境,根据存储数据的特点(大小、数据结构)、数据分布、读写比例等,我们可以使用同样的方法(redis-benchmark)进行测试,得到最准确的性能表现。
前面提到,Redis 是单线程模型,也就是单线程执行命令。那么,如果机器的 CPU 是多核的,比如 8 核,在机器上部署一个 Redis 实例,这个 Redis 实例只会使用 1 个 CPU 核,其他 CPU 核都闲置了,怎么能充分利用 CPU 核呢?
解决办法也很简单:在一台机器上部署多个 Redis 实例,比如 8 个 Redis 实例,这样就能充分利用 CPU 资源。但 QPS 跟实例个数并不成线性关系,因为随着单台机器上 Redis 实例的增多,内存和网络慢慢也会成为瓶颈。假设单个 Redis 实例的 QPS 为 12 万,理论上讲,8 个 Redis 实例总的 QPS 应该是 96 万。但对于 DDR4-3200(双通道)内存来说,它的读写速度的上限是 50GB/s,8 个 Redis 实例并发读写内存,争抢内存通道,其对读写速度的需求远超过 50GB/s。除此之外,8 个实例共用 CPU 的 L3 缓存,也会导致缓存频繁失效,严重影响性能。不仅 QPS 不会随着 Redis 实例的增多而线性增长,反而读写的响应时间会延长。
因此,对于在 8 核机器上具体应该部署几个 Redis 实例,我们需要经过压测以及监控,递进式地增加 Redis 实例个数,找到性能拐点,即增加 Redis 实例也不会增长 QPS,反倒会性能下降的临界情况。这时的 Redis 实例个数就是单机部署 Redis 实例的上限。通常,对于 8 核 32GB 的服务器,经验值是部署 4~6 个 Redis 实例。
4. Redis高性能架构
虽然 Redis 性能非常高,但是单台机器的内存是有限的。对于大规模互联网应用,如果我们希望存储海量的数据(远大于单台机器的内存大小)并且支撑更大的 QPS(比如 100 万 QPS),单机无法满足对内存和 QPS 的需求,我们就要走分布式路线,也就是对 Redis 进行水平扩展,更确切地说,应该是数据分片(sharding)。
我们知道,关系型数据库比如 MySQL,虽然可以通过主从读写分离、分库分表来扩展,但这通常比较复杂,且分库分表后对跨分片事务和复杂查询支持困难。而 Key-Value 数据库恰恰相反。因为它只提供简单的读写操作,并且数据存储结构也很简单(一个 Key 对应一个 Value),因此天生易于水平扩展,只需要基于 Key 做分片,配合哈希算法,就可以实现性能无损的无限扩展。
微博就采用128个分片的Redis集群,实现了超大内存(120TB),峰值QPS支撑2100万,平均读写延迟在0.9ms,P99为5ms。可见,Redis是应对海量数据高性能读写的利器。
Redis提供了Redis Cluster实现自动分片。关于Redis Cluster的实现原理,我们留在「数据分片」章节中讲解,这里就不展开详细来讲了。
5. Redis高可用架构
为了保证系统整体的高可用,我们需要解决 Redis 的单点故障问题。需要说明的是,Redis 提供的基于 RDB 快照和 AOF 日志的落盘(写入磁盘)机制,是数据的持久化方案,并非高可用方案。实际上,Redis 提供了以下三种高可用的架构方案。
(1)主从复制(Replication)
也就是经典的 master-slave 架构。数据写入 master,然后异步复制到一个或多个 slave。既可以让 master 负责读写,slave 负责备份,也可以让 master 只负责写,slave 负责读。不过,当 master 宕机之后,需要人工干预手动切换从节点为主节点,这就会导致有一定的不可用时间。适用于对可用性要求不高,或能接受短时间人工介入的场景。
(2)哨兵模式(Sentinel)
相对于主从复制架构需要人工手动切换主从节点的问题,Redis 提供了哨兵模式。哨兵通过监控主从节点,实现自动故障转移:当主节点宕机后,通过 Raft 算法自动选举一个从节点为主节点。客户端通过哨兵(sentinel)获取新的主节点地址。哨兵模式解决了人工干预的问题,但依然存在“单主”写入瓶颈,适合中等规模、对可用性要求较高的场景。
(3)Redis Cluster
其实,在讲Redis高性能架构的时候,提到的Redis Cluster也可以实现Redis的高可用。在Redis Cluster中,每个分片都有主从节点,并且实现了故障自动转移,主节点宕机之后从节点自动接替。
对于以上提到的高可用架构的底层实现原理,以及其他一些技术细节(比如,主从异步复制、同步复制、选举算法、故障处理策略等),我们在「故障处理」「冗余复制」「读写分离」章节中详细讲解。
其实,在讲 Redis 高性能架构的时候,提到的 Redis Cluster 也可以实现 Redis 的高可用。在 Redis Cluster 中,每个分片都有主从节点,并且实现了故障自动转移,即主节点宕机之后从节点自动接替。同时,Cluster 还自带分片能力,是真正意义上的分布式“高性能 + 高可用”一体化方案,适合大规模、高并发、高可用的生产环境。
对于以上提到的高可用架构的底层实现原理,以及其他一些技术细节(比如主从异步复制、同步复制、选举算法、故障处理策略等),我们在「数据存储」模块中详细讲解。
6. Redis应用场景
之所以我们会重点介绍 Redis,不仅仅是它具有高超的性能、天生的扩展性,还因为它具备丰富的数据结构和特性,这使它在很多应用场景中都能发挥作用。下面我们结合具体业务,看看 Redis 的典型用法。
| 场景 | 数据结构 | 示例命令 | 业务含义 |
|---|---|---|---|
| 缓存 | Hash | HMSET user:1001 name "Alice" age 28 EXPIRE user:1001 3600 | 缓存用户信息,1小时后自动失效 |
| 会话存储 | String | SET session:user123 "{userId:123, role:'vip'}" EX 1800 | 存储用户登录态,30分钟过期 |
| 分布式锁 | Lua脚本 | if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then return 1 else return 0 end | 保证只有一个客户端能执行关键操作 |
| 高性能计数器 | String + INCR | INCR article:10086:views | 文章阅读量自增,原子操作避免并发问题 |
| 消息队列 | 发布订阅 | SUBSCRIBE news:sports PUBLISH news:sports "中国队夺冠!" | 实时推送消息,轻量级广播 |
| 排行榜 | ZSet | ZADD leaderboard 95.5 "Player:A" 87.2 "Player:B" ZREVRANGE leaderboard 0 2 WITHSCORES | 游戏积分排行榜,实时更新 |
| 签到统计 | BitMap | SETBIT sign:202308:user1001 1 1 BITCOUNT sign:202308:user1001 | 记录用户每日签到,极省内存 |
| 点赞 | Set | SADD post:888:likes user1001 SISMEMBER post:888:likes user1001 | 判断用户是否点过赞,集合天然去重 |
| 最新动态 | List | LPUSH feed:user1001 "post_789" LTRIM feed:user1001 0 99 | 朋友圈时间线,保留最近100条 |
| 共同关注 | Set | SADD user:1001:follows 2002 2005 SINTER user:1001:follows user:1002:follows | 计算两个用户的共同关注 |
| 购物车 | Hash | HSET cart:user1001 SKU_3001 2 HINCRBY cart:user1001 SKU_3001 -1 | 存储商品数量,支持增减 |
| 延迟队列 | ZSet | ZADD delayed_queue $(($(date +%s)+3600)) '{"type":"order_close","order_id":10086}' ZRANGEBYSCORE delayed_queue 0 $CURRENT_TS | 定时任务,比如订单超时关闭 |
7. 最后总结
Key-Value 数据库,以其极简的模型(键值对)、超高的性能(尤其点查)、出色的水平扩展能力和 Value 的灵活性,在后端架构中占据着不可替代的位置。它特别擅长处理基于 Key 的高速访问、海量数据的存储扩展。但它也不是万能的,缺乏复杂查询能力是其主要短板。理解它的关键特性和适用场景,能让我们在进行系统设计时,将它精准地用在“刀刃”上,让它和关系型数据库各司其职,共同构建出优秀的系统架构。
五、关系数据库
上一节课我们讲解了 Key-Value 数据库,并且重点介绍了其中最常用的 Redis。这节课我们把目光转向关系型数据库,聚焦于最常用的 MySQL。同样,我们不会陷入底层原理的细节,而是把重点放在与技术选型、架构设计、系统设计密切相关的内容上:关键特性、存储引擎、ACID 事务、性能分析与调优,以及如何构建高性能、高可用的 MySQL 架构。
1. 关键特性
关系型数据库也可以叫做SQL数据库,使用SQL语言作为与数据库的交互语言。它存储的是结构化的数据。所谓结构化的数据指的是数据具有预定义的模式(Schema),所有记录遵循相同的字段结构。数据以二维表形式组织:行存储单条记录,列存储字段值,表内可定义约束(如唯一性、数据类型、长度限制),表间通过外键建立关联。
相较于Key-Value数据库,关系型数据库性能稍逊,但其核心特性在业务开发中不可替代,特别适合复杂业务数据的存储。我们来看下它都有哪些不可替代的关键特性。
(1)强一致性事务
关系型数据库通过 ACID 特性支持强一致性事务,这是其他数据库为了追求高性能常常放弃的特性。这一特性极好地满足了业务开发中某些关键场景对事务的强一致性要求。比如银行转账确保扣款与入账同时成功。其实,后面我们讲到最终一致性解决方案时,其常用的基于本地消息表的解决方案,也是依赖数据库的强一致性事务支持的!
(2)复杂关联查询
关系型数据库通过SQL语句以及结构化的存储结构,支持JOIN操作跨表关联查询、嵌套子查询、聚合函数(SUM/AVG)、分组统计(GROUP BY)、排序(ORDER BY)等复杂查询,方便开发者通过SQL一次性获取足够的数据。对比来说,Key-Value数据库仅支持简单的命令操作,关联查询需多次往返客户端拼接数据,效率低下且一致性难保障。
(3)结构化数据存储
关系型数据库通过字段类型、长度、非空、唯一性等规则,强制保障数据质量(如手机号必须是11位数字),避免非法数据入库(如字符串写入整数字段),提升业务开发效率,ORM框架(如MyBatis)自动映射表结构到对象。而Key-Value数据库无固定结构(如Redis的Value自由格式),需业务层解析数据,易出现脏数据或解析错误。
关系型数据库有很多,比如MySQL、Oracle、Microsoft SQL Server、DB2、PostgreSQL,接下来,我们聚集到在最常用的MySQL数据库来讲解。
2. 存储引擎
存储引擎是MySQL的核心组件,它决定了数据如何存储、读取、更新和删除。MySQL常见的存储引擎有InnoDB和MyISAM。MySQL采用插件式架构,允许你为每张表选择合适的存储引擎。
InnoDB默认引擎,因其支持事务并且是行级锁,锁定粒度是行级(默认),大大提高了多用户并发环境下的读写性能(尤其是写操作),减少了锁冲突。而MyISAM不支持事务,并且是表级锁,对整个表加锁。写操作会阻塞所有其他读写操作,并发性能差,尤其在写入频繁的场景下。对于只读或读多写少且不需要事务的场景,其简单设计有时能提供比InnoDB更快的纯读取速度。
| 特性 | InnoDB | MyISAM |
|---|---|---|
| 事务 | 支持 ACID | 不支持 |
| 锁粒度 | 行级锁 | 表级锁 |
| 并发写 | 高(行锁减少冲突) | 低(写锁阻塞全表) |
| 崩溃恢复 | 通过 redo log 自动恢复 | 容易损坏,需手动修复 |
| 适用场景 | 大部分业务场景 | 只读或读多写少、无需事务 |
除非有非常明确且合理的理由选择其他引擎,否则,我们在开发中一般首选InnoDB引擎。
在存储引擎中,有一个非常重要的数据结构,那就是索引。索引是保证数据库快速查找数据的关键。这部分内容比较多,我们放到「索引优化」章节中讲解。
3. ACID特性
ACID是保证数据库事务可靠性的四个关键属性,确保即使在系统故障或并发操作的情况下,数据库事务也能正确执行。InnoDB是MySQL中支持完整ACID事务的主要存储引擎。我们依次来看下A、C、I、D这四个特性。
(1)原子性(Atomicity)
原子性指的是,一个事务(Transaction)被视为一个不可分割的最小工作单元。事务中的所有操作要么全部成功执行(Commit),要么全部失败回滚(Rollback)。不存在部分执行的状态。比如转账操作:从A账户扣钱和向B账户加钱必须同时成功或同时失败,不会出现扣了A的钱却没加到B账上的情况。
原子性的实现机制主要依靠 undo log。当事务需要回滚时,InnoDB利用undo log中记录的信息来撤销该事务所做的修改。
(2)一致性(Consistency)
事务的执行必须使数据库从一个一致的状态转换到另一个一致的状态。一致性是指数据库的完整性约束(如主键唯一、外键约束、数据类型、用户定义的业务规则)在事务开始前和结束后都必须得到满足。
一致性是原子性、隔离性和持久性共同作用的结果。数据库本身会强制执行一些约束(如唯一索引、外键检查),但应用层也需要确保业务逻辑的正确性。事务是保证一致性的重要手段,但不是唯一手段。
(3)隔离性(Isolation)
并发执行的事务之间应该相互隔离。一个事务的执行不应该影响其他并发执行的事务。目标是防止出现脏读 (Dirty Read)、不可重复读 (Non-repeatable Read) 和幻读 (Phantom Read) 等问题。
- 脏读: 事务A读取了事务B修改但未提交的数据。如果事务B回滚,事务A读取到的就是无效的“脏”数据。
- 不可重复读: 事务A内多次读取同一数据。在事务A执行期间,事务B修改并提交了该数据。导致事务A两次读取的结果不一致。
- 幻读: 事务A根据一定条件读取了一些行。在事务A执行期间,事务B插入或删除了满足该条件的行并提交,导致事务A再次按相同条件读取时,发现多了或少了行。
为了解决上述问题,MySQL定义了4种隔离级别,隔离性由低到高,并发性由高到低:
- 读未提交 (READ UNCOMMITTED): 最低级别。可能发生脏读、不可重复读、幻读。
- 读已提交 (READ COMMITTED): 解决脏读。一个事务只能读取到其他事务已提交的数据,但可能发生不可重复读和幻读。Oracle默认是此隔离级别。
- 可重复读 (REPEATABLE READ): 解决脏读和不可重复读。一个事务内多次读取同一数据的结果是一致的。MySQL InnoDB默认是此隔离级别。
- 串行化 (SERIALIZABLE): 最高级别。事务串行执行,完全避免脏读、不可重复读、幻读。并发性能最低。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 |
|---|---|---|---|---|
| 读未提交 (READ UNCOMMITTED) | 可能 | 可能 | 可能 | 最高 |
| 读已提交 (READ COMMITTED) | 不可能 | 可能 | 可能 | 较高 |
| 可重复读 (REPEATABLE READ) | 不可能 | 不可能 | 可能 | 中等 |
| 串行化 (SERIALIZABLE) | 不可能 | 不可能 | 不可能 | 最低 |
(4)持久性(Durability)
一旦事务成功提交,它对数据库所做的修改就是永久性的。即使发生系统崩溃、断电等故障,已提交的数据也不会丢失。InnoDB采用Write-Ahead-Logging(WAL)原则,日志(redo log)先于数据落盘。
当你执行 COMMIT 语句时,事务并不会立即将修改后的数据页写入磁盘上的数据文件。这样做效率太低(随机 I/O),而是会先将修改写入redo log buffer,然后(根据配置)刷写到磁盘上的redo log file(顺序I/O,很快)。
innodb_flush_log_at_trx_commit这个参数控制redo log的刷盘策略。默认情况下,它的值是1,这种刷盘策略最安全。事务提交时,InnoDB会立即将本次事务产生的 redo log 从 redo log buffer 执行一次 fsync() 系统调用,强制刷新到磁盘上的 redo log file。sync() 的作用是:它告诉操作系统,确保将指定文件的所有修改(包括内核缓冲区中的内容)物理写入磁盘存储设备。只有 fsync() 成功返回,COMMIT 操作才会向客户端返回成功。因此,只要客户端收到了 COMMIT 成功的响应,那么该事务的 redo log 一定已经安全地存储在磁盘上了。即使随后发生宕机,重启后也能根据 redo log 恢复该事务。
4. 性能分析:QPS
MySQL的性能(QPS 和 TPS)受很多因素的影响,包括硬件CPU性能、InnoDB内存缓冲区大小、硬盘速度、索引、SQL查询的复杂度、是否并发竞争行锁、事务包含的SQL语句多少等等。
- 如果只是简单的主键查询,并且数据在内存缓冲区中,MySQL的QPS可以达到几万,相对于Redis的10万QPS,其实也不算低。
- 如果查询是复杂查询,比如JOIN、聚合、嵌套等等,QPS可能下降到1万以下。
- 如果数据没有在内存缓冲区中,需要在磁盘中查找,那么QPS可能下降到几千,从侧面上也可以得到,我们要将内存缓冲区大小(对应innodb_buffer_pool_size这个参数)设置的大一些,一般为机器内存的70% ~ 80%。
- 如果全表扫描或不经过索引的查询,又或者高并发写场景下竞争行锁,QPS就有可能下降到几百。
我们经常拿Redis和MySQL做性能对比,然后给出结论说Redis性能更好!实际上,这就是两个赛道的东西,应用场景就不同,根本没有可比性。刚刚我们也提到,简单的主键查询,数据在内存缓冲区中,实际上,MySQL的QPS也不低,但显然,MySQL的优势并不在此,而是在复杂查询、事务支持、结构化数据存储这些特性上,这也是只追求性能的Redis所不具备的。
5. 性能分析:并发连接
前面讲到MySQL的QPS,其实,还有一个表征MySQL性能的指标,那就是并发连接数,也就是可以支持的最大连接数。在MySQL8.0中,此配置参数(max_connections)默认为151。
在MySQL中,每个连接对应一个独立线程。除了线程栈之外,每个连接占用的内存还包括动态分配的缓冲区,比如排序缓冲区(sort_buffer_size)、JOIN缓冲区(join_buffer_size)等。这些缓冲区动态调整,随着查询的复杂度增加而增大,最大可以增至64MB,当然,如果都是简单的查询,不涉及排序、JION、复杂聚合等,这部分缓冲区大小几乎为0。我们简单做个假设,如果按照平均一个连接消耗1MB内存来算,1000个链接要消耗1GB的内存,这还不包括共享的InnoDB缓冲区(即innodb_buffer_pool_size)。
因此MySQL的最大连接数不应该设置过大,根据机器硬件性能,初始设置在300 ~ 500之间即可,后续根据监控(连接的使用情况、机器硬件资源的消耗情况、MySQL的整体性能,比如QPS)再做调整。如果最大连接数设置的过大,就会导致线程频繁切换、内存占用过多、并发竞争行锁加剧等问题,导致QPS下降、SQL执行时间增长,性能不增反降。
对于MySQL客户端来说,也就是Web后端服务,我们可以使用连接池来控制连接个数以及避免频繁创建连接。对于成千上万的接口请求,如果每个请求都重新与数据库建立连接,使用完之后再销毁连接,每次连接的建立都经历TCP三次握手、数据库认证、会话建立等操作,这将是非常耗时的,影响Web服务器的接口响应时间,而且,对于数据库也是非常大的性能压力。
除了以上说到的性能优点之外,数据库连接池还有更多优点。
- 控制资源消耗: 通过设置最大连接数,防止应用程序无限制地创建连接耗尽数据库连接。
- 统一管理: 连接池负责连接的创建、验证、销毁、超时处理。
- 避免连接泄漏: 严格的归还机制和泄漏检测有助于减少因代码错误导致的资源耗尽问题。
- 连接健康检查: 确保应用程序拿到的连接是有效的,避免因网络闪断或数据库重启导致的连接失效错误。
6. 扩展架构
在讲解Redis的时候,我们提到了高性能、高可用的架构方案,包括主从复制、读写分离、哨兵模式(自动故障转移)、数据分片(Redis Cluster)。实际上,MySQL的高性能、高可用的扩展方案也无外乎这几种:主从复制保证高可用,读写分离保证高性能,分库分表是性能的终极大招。其实,基本上所有的存储类系统,它们实现高可用、高性能、海量存储、水平扩展的基本方案都是这几种,因此,我们留在后面分几篇文章来单独详细讲解。
7. 最后,总结
本节课我们聚焦于关系型数据库的核心代表:MySQL,探讨了关键特性(强事务、复杂查询、结构化存储)、默认且强大的InnoDB存储引擎、保障数据可靠性的ACID机制,以及影响性能的关键因素(如QPS、连接数管理和连接池)。理解这些核心概念,是我们在后续构建高性能、高可用数据库架构(如主从复制、读写分离、分库分表)的重要基础。
六、NoSQL数据库
上一节课,我们讲到关系型数据库,它因为支持强一致性事务、复杂关联查询、结构化数据存储等特性,在业务开发中起到举足轻重的作用。但是, 在某些应用场景下,我们并不需要强一致性事务,也不需要复杂关联查询,更不需要结构化数据存储,优点无用武之地,就成了限制,使其面临着:固定表结构带来的扩展困难、分布式场景的短板、高并发写入瓶颈等问题。
- 表结构扩展困难:产品经理临时加个用户标签字段,你得忙着改表结构;
- 高并发写入瓶颈:每秒要处理几十万条设备状态写入,写入可能成为瓶颈;
- 分布式扩展难题:单机撑不住想水平扩展时,分库分表带来的复杂度陡增。
这时候,NoSQL数据库登场了。它摒弃了关系型数据库引以为傲的 JOIN 操作、严格的 Schema,还有为了保证强一致性所做的种种设计,而是针对以上提到的特定应用场景,进行了针对性的存储设计和索引优化,使其发挥优于关系数据库的性能表现。
1. NoSQL DB
实际上,NoSQL这个词的字面意思是“Not Only SQL”,当然,叫什么并不重要,我们可以简单地将其归类为“非关系型数据库”。实际上,它不特指某个具体数据库,而是一大类为了解决上述特定场景问题而诞生的数据库的统称。它们的设计哲学和关系型数据库很不一样,它从一开始就牺牲完美并拥抱分布式!
(1)灵活的数据模型
打破固定 Schema 的束缚。数据可以是简单的键值对、灵活的文档(类似JSON)、宽列族(方便存储稀疏数据)、甚至是复杂的图结构。业务需要什么结构,我就存什么结构,不用为了迁就数据库表设计而把数据拆得七零八落或者塞进很多 NULL 值。
(2)分布式优先
从设计之初就考虑如何把数据和负载分散到成百上千台机器上。通过分区(Sharding)、复制(Replication)等技术,实现近乎线性的水平扩展能力,并且扩展起来一点都不复杂,业务更是无感知。
比如,用 MySQL 存储物联网设备上报的数据,单库或单表达到一定限度,QPS/TPS就会下降到几千。如果要满足这种海量存储压力,就得分库分表,不管是运维还是开发,都要增加不小的复杂度。如果换成HBase,通过 RowKey 将数据均匀散列到 几十台服务器,写入 TPS 可以从原来的几千提升到几十万,扩容时只需要加 RegionServer,业务毫无无感知。
(3)简化查询
很多 NoSQL 数据库放弃了复杂的 SQL 和 JOIN 操作。查询模式通常更简单、更直接,比如根据主键(Key)快速获取值(Value),或者对文档中的某个字段进行索引查询。复杂的关联逻辑,更多地推给应用层来处理。这种“简化”换来了在特定查询模式下的极高性能。
(4)调整一致性模型
不是所有场景都需要数据的强一致性。NoSQL 常常采用“最终一致性”:系统保证在没有新的更新操作后,经过一段时间,所有副本的数据最终会达到一致。这种“一致性弱化”换取的是更高的写入吞吐量和系统可用性。想想看,发一条微博,可能你刚发完自己立刻能看到,但你的粉丝列表里有人稍晚几秒才看到,这在很多场景下是完全可以接受的。
我们前面提到,NoSQL表示一大类数据库。如果我们给这些数据库分分类的话,大致可以分为键值数据库、文档数据库、列式数据库、图数据库这样几类,每一类都有其擅长的战场。接下来我们就依次看下它们。
2. 键值数据库
键值数据库我们在前面也已经经过了,这里再稍微复习一下。
键值数据库的核心思想就一句话:用Key快速找到Value。你可以把它想象成一个超级大的哈希表。它的优势就是快。因为数据模型简单(Key指向Value),读写通常能到毫秒甚至微秒级,比如Redis这类内存数据库。
它的实现原理也很直接:写入时按Key的哈希值分配存储位置;读取时直接定位,几乎没有计算开销。常用的应用场景有:缓存、会话存储、排行榜、计数器、配置信息、简单的消息队列、分布式锁等等。除此之外,Redis 凭借其丰富的数据结构和功能,几乎成了系统架构设计的标配缓存层。
3. 文档数据库
键值数据库虽然性能很高,Value的格式也比较灵活,但是,它只能通过key来查找Value,无法使用Value中的字段进行查找。为了解决这个问题,于是就有了文档数据库。
文档数据库将数据按照JSON等文档格式直接存储,文档中的字段可增可减,并且不要求每个文档的字段相同。对于需要用于查询的字段,统统都构建索引(可以是B+树、哈希表等),这样就可以实现基于这些构建了索引的字段进行查询,解决了键值数据库只能通过一个键来查询的问题。
除此之外,在系统设计中,当遇到用户画像、商品详情、内容管理这些字段变化频繁、格式不固定的数据时,关系型数据库常常显得笨重。每次加个字段都要改表结构,在大流量高并发下,这种“表结构变更”还可能成为性能瓶颈。文档数据库就能很好的解决这个问题。
常用文档数据库有MongoDB、CouchDB、Couchbase,实际上,Elasticsearch也可以看做是一种文档数据库。其中,MongoDB因其通用性强、社区活跃,成为最常用的文档数据库。我们拿MongoDB来举例,简单讲下其基础用法,让你有个直观的感受。
MongoDB的几个核心概念
- 文档(Document):基本数据单元,就是一个 BSON(Binary JSON)对象。比如存用户信息,其中,_id 是 MongoDB 自动生成的唯一主键,支持自定义。
{
"_id": ObjectId("507f191e810c19729de860ea"),
"name": "李思",
"email": "lisi@example.com",
"address": {
"city": "北京",
"street": "海淀区中关村"
},
"tags": ["科技", "阅读"]
}- 集合(Collection):相当于“表”,但里面文档的结构不必统一。比如 users 集合既存普通用户,也能存管理员(多几个权限字段)。
- 数据库(Database): 一个 MongoDB 实例可包含多个数据库(如 shop_db, blog_db),每个库包含若干集合。这里的数据库跟关系型数据库中的数据库的概念基本一致。
MongoDB常用的操作举例
MongoDB自带一个交互式JavaScript shell(mongosh),可以直接运行命令操作数据库。
- 插入文档: db.users.insertOne({name: "张三", age: 30})
- 查询文档: db.users.find({age: {$gt: 25}}) // 查年龄>25的用户
- 更新文档: db.users.updateOne({name: "张三"}, {$set: {age: 31}})
- 建索引加速查询: db.users.createIndex({email: 1}) // 1 表示升序索引
4. 列式数据库
列式数据库是一类将数据按列而非按行存储的数据库系统,特别适合海量数据存储和分析、实时查询等场景,比如用户行为日志存储、物联网传感数据、风控数据,常常作为数仓或者机器学习的底层数据存储。
其核心优势有:
- 高压缩率:同列数据类型一致,压缩效率提升 40% 以上,大大节省存储成本。
- 聚合计算快:仅需读取相关列,减少 I/O 量。
- 适合稀疏数据:空值不占存储空间,不像关系型数据库每行占用固定空间。
想象一张十亿用户的大表,传统数据库按行存储(把所有列数据放一起),查“用户年龄分布”得扫描整张表。而列数据库会把所有“年龄”值单独存储,查询时只读这一列,结合高压缩率、空值不存储等优势,速度提升N倍。
常见的列式数据库有Cassandra、HBase、Google Bigtable等。我们拿HBase举例展开讲解一下。
HBase的几个核心概念:
- RowKey:记录的唯一标识,查询时必须用它。
- 列族(Column Family):存储的最小单元。每个列族内的所有数据会存储在一起(即 HFile 文件)。设计时,把经常一起查询的列放在同一个列族。
- 列(Column):列族内的具体字段。
RowKey: "user_001"
-> Column Family "basic_info":
name: "张三",
age: "30"
-> Column Family "contact":
phone: "138xxxx",
email: "zhangsan@xx.com"每个列族都会有独立的索引结构,可以看做独立的一个键值数据库,其中key为RowKey,value为列族数据。当我们基于RowKey查询记录时(也可以说是查询一行数据),需要使用RowKey在每个列族HFile基于索引来查找,最后拼接在一起。
从索引结构我们也可以看出,基于非RowKey的查询(比如基于base_info列族的name来查询)是非常慢,需要扫描整个列族文件。如果我们使用列式数据库,那么,我们就压根不建议这么去查询。但是,如果你非要这么做,也可以使用列族的某个列再建立一个二级索引,通过这个二级索引提高基于这个列的查询性能!但代价是会增加写入延迟和存储成本。
5. 图数据库
图数据库相对前面几种NoSQL数据库,用得少一些,因为它更加专精尖,主要存储图这种数据结构。之前我们学习数据结构和算法的时候,图是存储在内存中的,因此,只能存储小图,毕竟单台机器的内存大小是有限的。这里的图数据库可以借助磁盘、分布式存储更加大规模的图。
虽然图数据库专精尖用得少,但是,一旦派上用场,就可以发挥立竿见影的作用。比如,存储超大社交网络之间的好友关系(比如,微博、微信),并且基于三度好友实现好友推荐功能,对于图数据库来说,存储和查找三度好友都很简单,并且查询性能也很高,相反,如果用关系型数据库存储海量关系,分库分表比较麻烦,并且,三度好友的查询需要经历多次数据库查询或者多次JION,性能跟图数据库有天壤之别!除此之外,图数据库还经常用在知识图谱、金融风控、推荐系统等有明显图特征的数据存储中。
常用的图数据库有Neo4j、NebulaGraph、ArangoDB等等。我们使用Neo4j来简单实现一下刚刚的需求。
// 创建用户节点
CREATE
(alice:User {name: "Alice", age: 28}),
(bob:User {name: "Bob", age: 32}),
(carol:User {name: "Carol", age: 25}),
(dave:User {name: "Dave", age: 40}),
(eve:User {name: "Eve", age: 35}),
(frank:User {name: "Frank", age: 45}),
(grace:User {name: "Grace", age: 29})
// 建立好友关系
CREATE
(alice)-[:FRIENDS_WITH]->(bob),
(bob)-[:FRIENDS_WITH]->(carol),
(carol)-[:FRIENDS_WITH]->(dave),
(dave)-[:FRIENDS_WITH]->(eve),
(alice)-[:FRIENDS_WITH]->(frank),
(frank)-[:FRIENDS_WITH]->(grace)
// 为Alice推荐可能认识的人(三度好友且未建立关系)
MATCH (alice:User {name: "Alice"})
MATCH (alice)-[:FRIENDS_WITH*3]-(recommendation:User)
WHERE NOT (alice)-[:FRIENDS_WITH]-(recommendation)
RETURN recommendation.name AS SuggestedFriend,
count(*) AS CommonConnections
ORDER BY CommonConnections DESC
LIMIT 106. 技术选型
从今天的讲解,我们可以看出:关系型数据库像一个不偏科的好学生,在大多数业务中都能胜任,尤其适合需要强一致性、复杂事务、关联查询的核心业务。NoSQL 数据库更像偏科的特长生,在特定领域(海量存储、灵活 Schema、超高并发写入、图关系等)表现远优于关系型数据库,但代价是牺牲了其他方面的能力。
深刻理解每种数据库的特性、应用场景,才能在设计系统时做出理性的选择。
- 如果业务有强事务、复杂 JOIN 需求,选关系型数据库。
- 如果是缓存、计数、排行榜,选 Redis。
- 如果数据量大、字段灵活、查询模式简单,选文档数据库(MongoDB)。
- 如果是海量写入、主要按主键查询、存储成本敏感,选列式数据库(HBase)。
- 如果数据关系复杂(多度关联、路径查询),选图数据库。
不过,现代复杂系统的架构,往往是多种数据库协同工作的结果。例如:
- 交易核心用 MySQL(强事务保证)
- 商品详情、用户画像用 MongoDB(灵活 Schema)
- 设备日志用 HBase(海量写入、分析)
- 社交关系用 Neo4j(复杂关系查询)
- 热点数据用 Redis 做缓存(性能好)
没有万能的数据库,只有最适合业务的数据库。理解了它们各自的“偏科”方向,你就能在架构设计中让它们各司其职,共同构建出稳定、高效、可扩展的系统。
7. 最后,总结
今天我们讲解了图数据库,包括键值数据库、文档数据库、列式数据库、图数据库,实际上,每一种数据库都可以用单独的一本书来详细讲解。显然,在一节课里,我们只能粗略介绍,帮你构建知识广度。学完这节课,只要你在某个存储场景下,能够想到可能用某个NoSQL数据库更合适,就达到了我们的教学目标。至于更加详细地如何去部署、编程开发、最优实践等,需要你自己去摸索,但这只发生在你需要去用某个具体数据库的时候。
七、消息中间件
这节课我们来讲消息队列(Message Queue,简称MQ),也叫做消息中间件,它在构建高并发的分布式系统中,起到解耦、异步、削峰填谷的作用,可以说是系统架构设计的标配,也是后端面试中几乎必备的知识点。常用的消息中间件有RabbitMQ、Kafka、RocketMQ。在众多优秀的消息中间件中,RocketMQ 凭借其高吞吐、高可用、低延迟、高可靠的特性,以及丰富的功能和历经阿里巴巴双十一洪峰考验的实战经验,成为了国内开发者非常青睐的选择。这节课我们就结合RocketMQ系统地讲一讲消息队列。
1. 核心架构
RocketMQ 主要由四个核心角色构成:
(1)Producer(生产者)
负责产生并发送消息的应用程序。例如,用户下单成功后需要发短信通知,下单服务作为 Producer,会将下单成功的消息发送给 Broker。
(2)Broker(消息代理)
RocketMQ 的核心组件,负责接收、存储、管理并投递消息。一个 RocketMQ 集群通常由多个 Broker 组成,通过集群协作实现高可用和高吞吐。
(3)Consumer(消费者)
负责从 Broker 订阅和拉取消息并进行处理的应用程序。Consumer 通过订阅特定的 Topic 来接收消息,消费方式可以是主动拉取(Pull),也可以由 Broker 主动推送(Push)。
(4)NameServer(名字服务)
提供路由发现功能。Broker 启动时会将自己的地址(IP 和端口)以及所负责的 Topic 信息注册到 NameServer。Producer 和 Consumer 在收发消息前,会向 NameServer 查询目标 Topic 所在的 Broker 地址。
实际上,我们可以将消息队列理解为一种专门存储消息的数据库。类比 MySQL,Broker 相当于 MySQL Server,负责数据的存储与管理;Producer 和 Consumer 则相当于 MySQL Client,分别负责写入数据和读取数据。

- Broker 注册:Broker 启动后向 NameServer 注册自身地址和所负责的 Topic。
- Producer 获取路由:Producer 向 NameServer 查询目标 Topic 的 Broker 地址。
- 返回路由:NameServer 返回 Broker 地址给 Producer。
- 发送消息:Producer 将消息发送到对应的 Broker。
- Consumer 获取路由:Consumer 同样向 NameServer 查询 Topic 对应的 Broker 地址。
- 返回路由:NameServer 返回 Broker 地址给 Consumer。
- 消费消息:Consumer 从 Broker 拉取或订阅消息。
2. 核心概念
RockectMQ有两个核心概念:Topic和Queue。
(1)Topic(主题)
Topic 是消息的逻辑分类,也是生产者发送消息和消费者订阅消息的基本单位。生产者将不同类型的消息发送到不同的 Topic(例如:ORDER_TOPIC 用于订单消息,LOG_TOPIC 用于日志消息)。消费者通过订阅感兴趣的 Topic 来接收对应的消息。可以类比数据库中的“表”,不同的表存储不同的数据。
(2)Queue(队列)
Queue 是 Topic 在物理层面的分片(Sharding)单位。这是 RocketMQ 实现并行处理、负载均衡和高吞吐的核心设计。一个 Topic 在创建时会被划分为一个或多个 Queue(数量可配置),这些 Queue 会尽可能均匀地分布在集群的不同 Broker 上(虽然也可以集中在一个 Broker 上,但不推荐)。
生产者发送消息到某个 Topic 时,RocketMQ 会根据一定的负载均衡策略(如轮询、哈希取模、随机等)选择一个该 Topic 下的 Queue,将消息最终存储在这个 Queue 对应的物理文件中。
消费者组 (Consumer Group) 订阅一个 Topic 时,该 Topic 下的 Queue 会被分配给组内的各个 Consumer 实例。例如,一个 Topic 有 8 个 Queue,一个 Consumer Group 有 4 个实例,那么理想情况下每个 Consumer 实例会负责消费 2 个 Queue 的消息。
如果一个Topic对应多个Queue,那么,对于一个 Consumer Group ,同一个 Queue 内的消息只会被一个 Consumer 实例消费,不会被分配给多个Consumer,这样保证了一个Queue内消息的顺序性。Queue 的数量直接决定了该 Topic 的最大消费并行度。
下图展示了 RocketMQ 中 Topic、Queue、Broker、Producer 和 Consumer Group 之间的核心关系。

3. 存储结构
我们再来看消息的存储结构,它主要由四个核心部分组成:CommitLog、ConsumeQueue、ConsumeOffset和IndexFile。
(1)CommitLog(提交日志)
所有的消息(不区分Topic和Queue)都按照接收到的先后顺序,连续地追加(Append)写入到同一个 CommitLog 文件中。每条消息在 CommitLog 中存储其完整的原始内容,包括 Topic、QueueId、消息体(Body)、属性(Properties)等元数据。
之所以没有为每个Queue单独存储,是因为磁盘顺序写的性能远高于随机写。如果为每个 Topic 甚至每个 Queue 单独写文件,会产生大量的随机 IO(多个文件交替写入),严重拖慢速度。这是 RocketMQ 高吞吐的核心秘诀。
(2)ConsumeQueue(消费队列)
如果要直接从巨大的、混合存储的 CommitLog 中根据 Topic 和 Queue 查找特定消息,效率极低(需要扫描整个文件)。为了解决这个问题,引入了 ConsumeQueue,实际上可以看做是索引。
每个 Queue 在 Broker 上对应一个独立的 ConsumeQueue 文件。ConsumeQueue 文件由固定大小的条目(Entry)组成。默认每条Entry 20字节,包含 3 个关键信息:
- CommitLog Offset (8 Bytes): 该条消息在 CommitLog 文件中的物理偏移量(起始位置)。
- Size (4 Bytes): 该条消息在 CommitLog 中的长度。
- Tags HashCode (8 Bytes): 消息 Tag 的 HashCode(用于服务端基于 Tag 的快速消息过滤)。
消息写入 CommitLog 后,系统异步将消息的索引信息(上述三个字段)写入对应 Queue 的 ConsumeQueue 文件。消费者消费时,先从 ConsumeQueue 中读取索引条目(通过待会讲到的ConsumeOffset 定位),再根据 Offset 和 Size 从 CommitLog 中读取完整的消息内容。写入时只做顺序写(CommitLog),读取时通过轻量级的索引文件快速定位,兼顾了写入性能和读取效率。

(3)ConsumeOffset(消费位置记录)
消费者需要知道自己消费到了哪里,以便在重启、故障转移或负载均衡后能继续从正确的位置消费。这就是 Consumer Offset 的作用。Consumer Offset 表示 某个 Consumer Group 对某个 Topic 的某个 Queue 的消费进度。它本质上是一个长整型数字 (long),代表该 Consumer Group 在这个 Queue 上已经成功消费并确认的最后一条消息在对应的 ConsumeQueue 文件中的逻辑位置(索引下标)。
注意:它指向的是 ConsumeQueue 中的条目位置(索引下标),而不是 CommitLog 的物理偏移量。例如,Offset = 5 表示该 Group 已经成功消费了这个 Queue 的 ConsumeQueue 中第 0 条到第 5 条索引对应的消息(共 6 条消息)。
RocketMQ 默认将消费进度 (Consumer Offset) 持久化存储在 Broker 服务器上。消费者本地内存也会维护一份当前的消费进度,用于每次拉取请求。消费者在消费一批消息成功后,会向 Broker 上报这批消息中最后一条消息对应的 Offset。

(4)IndexFile(索引文件)
如果遇到这样一种场景:运维需要查某条具体的消息,比如“订单号 12345 的消息到底有没有发出去?”。这时候,你既不知道它属于哪个 Queue,也不知道它在 ConsumeQueue 里的索引位置。你只有一个“Key”(比如订单号),想直接定位这条消息。ConsumeQueue 帮不了你,因为它只按 Queue 组织,不按 Key 组织。
IndexFile 就是为解决这个问题而生的“备用索引”。IndexFile 提供了基于 Message Key 或 msgId 快速查找消息的能力。
- Message Key:是你在发送消息时自己设置的业务唯一标识,比如订单号、用户 ID。
- msgId:是 RocketMQ 自动生成的全局唯一 ID(包含生产者 IP、进程 ID、时间戳等)。
消息写入 CommitLog 后,RocketMQ 会异步地将消息的 Key 和它在 CommitLog 中的物理位置(Offset)记录到 IndexFile 中。IndexFile采用 Hash 索引结构,当你想通过 Key 查消息时,可以快速定位到消息在CommitLog中的位置。
正常的消息消费流程不需要通过 Key 查消息。消费者是通过 ConsumeQueue 按顺序拉取消息的,根本用不到 IndexFile。IndexFile 的主要用途是:
- 消息轨迹查询:排查“某条消息到底有没有发出去、被谁消费了”。
- 按业务 Key 查询:比如客服要查某个订单的支付消息。
- 运维排障:定位“某条消息丢失”的具体原因。
4. 核心功能
RocketMQ 支持丰富的消息类型和灵活的消费模式,让它能适应各种业务场景。
(1)丰富的消息类型
- 同步消息:Producer 发完消息后,会阻塞等待 Broker 返回确认。可靠性最高,但性能相对最低。适用于重要通知,如支付结果。
- 异步消息:Producer 发完消息立即返回,Broker 处理完后会异步回调 Producer 告知结果。性能和可靠性兼顾。
- 单向消息:Producer 只管发送,不等待确认也不关心结果。适用于日志收集等允许少量丢失的超高吞吐场景。
- 顺序消息:保证同一个 Queue 内的消息被 Consumer 按照发送顺序消费。通过将需要保证顺序的消息发送到同一个 Queue 实现(局部有序),比如同一订单的创建、支付、发货消息要按顺序处理。全局有序需要单 Queue,但会牺牲并发能力。99% 的场景下局部有序就够了。
- 延迟消息: 消息发送后,不会立刻被 Consumer 消费,而是在指定的延迟时间(如 30 分钟、1 小时后)后才可消费。Broker 收到消息后不会立即投递,而是存储在特定的 SCHEDULE_TOPIC 下,由定时任务扫描到期后再投递到目标 Topic。常用于定时任务、订单超时关闭、预约提醒等。
- 事务消息: 解决分布式事务问题,后面会讲到。
(2)灵活的消费模式
- 集群模式(Clustering): 默认模式。同一个 Consumer Group 下的多个实例共同消费 Topic 的消息,实现负载均衡。一条消息只会被组内的一个实例消费。
- 广播模式(Broadcasting): 同一个 Consumer Group 下的每个实例都会消费 Topic 的所有消息。
(3)常用的应用场景
消息队列的主要用途有三个:解耦、异步、削峰。
- 应用解耦:比如订单系统创建订单后,需要通知库存系统扣减库存、通知用户系统发优惠券、通知物流系统准备发货。如果订单系统直接同步调用这些系统,耦合度高,任一系统故障都会影响下单主流程。通过MQ,订单系统只需发一条“订单创建成功”的消息,其他系统各自订阅消费,实现解耦,提升核心链路稳定性和可维护性。
- 异步化处理:将非核心、耗时长的操作异步化。例如用户注册成功后,需要发送欢迎邮件、初始化用户画像、赠送积分等。注册服务只需发消息,后续操作由不同的 Consumer 异步完成,显著缩短用户注册响应时间。
- 流量削峰:应对突发流量,保护下游系统。比如秒杀活动,瞬间涌入大量下单请求。可以先快速将请求信息写入 MQ,再由下游的订单处理服务按照自身能力从 MQ 中拉取消息进行处理,避免瞬间压垮数据库或服务。
5. 扩展架构
RocketMQ 显然是一个磁盘 IO 密集的应用。但是,得益于其顺序写磁盘、零拷贝、高效的网络模型,使其单机可轻松支持数万甚至十万级别的QPS,并且可以做到端到端毫秒级别的延迟和极高的消息堆积能力(完全取决于磁盘的大小)。
前面我们也提到,消息队列其实也可以看做是一种特殊的数据库。我们在讲解数据库的时候,讲到为了提高性能和可用性的扩展架构,包括:主从复制、读写分离、数据分片。其实,消息队列也可以应用这些扩展架构。
- 主从复制:也就是Master-Slave结构,Producer将数据写入Master Broker之后,同步或者异步复制到Slave Broker,这样,即便Master Broker宕机,因为还有Slave Broker,所以,消息不会丢失,可以人工手动或者基于Raft等协议自动切换到Slave Broker继续服务,这样就提高了整体的可用性。
- 读写分离:Producer将消息写入Master Broker,Consumer负责从Slave Broker中读取消息,这样分担了Master的既要写又要读的压力。
- 数据分片:也叫做水平扩展。部署多个Broker,将不同的 Topic 分布到不同的 Broker 上,或者为同一个 Topic 创建更多的 Queue并分布在多个 Broker 上,从而提升整体吞吐能力。
关于这几种扩展架构,我们放到后面集中详细讲解。
6. 最后总结
这节课我们重点讲解了消息队列中的代表RocketMQ,它具有高吞吐、低延迟、海量消息堆积能力,常用于系统解耦、削峰填谷、以及实现异步处理。它通过清晰的架构(Producer + Broker + Consumer + NameServer)、高效的存储设计(CommitLog + ConsumeQueue + ConsumeOffset + IndexFile)、丰富的功能(同步消息、异步消息、事务消息、顺序消息、延迟消息等)、强大的扩展能力(主从复制、读写分离、数据分片),为构建高性能、高可用、松耦合的分布式系统提供了坚实的基础支撑。
八、分布式协调
提到分布式协调服务,你可能一头雾水,不知道是啥玩意,但是,如果我提到Zookeeper、Etcd、Nacos,你肯定就不陌生了。实际上,这篇文章要不要写,怎么写,我犹豫了很久,因为如果要展开深入的讲解,估计一本书都不够讲(有一本讲Zookeeper的书有400多页),但是,如果只是简单介绍一下,可能就会有老六跳出来说写得水。进退两难,但是,最终还是决定要写一下,毕竟“系统、全面、完整”要更加重要!
1. 核心问题
我们先来看到底什么是分布式协调服务,它到底协调什么?
我们可以想象一下自己的开发团队,如果没有技术Leader、项目经理、产品经理等协调工作,每个人各自为战,就没法凝聚成一个高效的团队。分布式系统内部大规模集群协同工作,也需要这样的协作基础设施。比如以下几个功能,在大多数分布式系统中都会遇到。我们把这些共性的功能抽取出来做成一个独立的服务,就是分布式协调服务,比如前面提到的Zookeeper、Etcd、Nacos。
(1)配置管理
分布式应用通常需要大量的配置信息:数据库连接、功能开关、超时设置、降级策略……如果把这些配置分散在每个应用实例的配置文件里,改一个配置就要重新打包、发布几十甚至上百个实例,简直是噩梦。
分布式协调服务提供了一个中心化的、可靠的配置存储库。应用从这里拉取配置,还可以监听配置变更,实现“不改代码、不重启,配置实时生效”。这就是配置中心的价值。
(2)服务发现
在微服务架构中,服务实例是动态变化的,随时可能启动、停止、扩容、缩容、故障宕机。服务消费者怎么知道它要调用的服务现在在哪个 IP、哪个端口上?
分布式协调服务充当了注册中心的角色:
- 服务提供者启动时,向中心注册自己的信息(服务名、IP、端口、健康状态)。
- 服务消费者启动时,向中心查询所需服务的位置列表。
- 当提供者宕机时,中心能检测到并通知消费者,及时剔除失效节点。
(3)分布式锁
当多个服务节点需要互斥访问共享资源时(比如并发修改用户余额、抢占定时任务执行权),单机上的锁已经不管用了。分布式锁确保同一时刻只有一个节点能操作。节点向协调服务申请一个全局唯一的锁标识,第一个拿到锁的节点执行关键操作(如库存扣减)。操作结束后显式释放锁,其他等待的节点被唤醒竞争。
(4)Leader选举
前面我们在讲数据库和消息队列时,多次提到“主从复制”“故障自动转移”。当主节点宕机后,需要从其他从节点中选举出一个新的主节点,这个过程就是 Leader 选举。
分布式协调服务已经实现了现成的 Leader 选举功能。节点启动时注册参与选举,协调服务按规则(如最小节点 ID)选定 Leader 并广播结果。Leader 心跳超时触发新一轮选举,新 Leader 自动接管流量(如 MySQL 主库切换),保障服务连续性。
2. 主流方案
从2006年分布式协调服务的鼻祖Chubby诞生到现在,已经有近20年的时间了,分布式协调服务随着技术的发展有了很大的进化,总结一句的话,就是从大而全、复杂难用,到小而美、简单好用方向发展。
(1)奠基者诞生
2006 年,Google 发表了论文,披露了 Chubby 的设计。Chubby 的目标是解决 Google 内部 GFS、BigTable 等分布式系统面临的分布式锁、元数据存储、分布式协作等问题。同一时期,分布式存储(GFS、BigTable)和分布式计算(MapReduce)开始进入大众视野。
当年有一个很有趣的现象:只要 Google 发布一个新东西,市面上很快就会有一个对应的开源版本。这也说明,在 IT 行业,技术实现从来不是最难的部分,创新才是。GFS 的开源版本是 HDFS,BigTable 的开源版本是 HBase,MapReduce 的开源版本是 Hadoop MapReduce。Chubby 也不例外,它的开源版本就是 Zookeeper。
Zookeeper 于 2007 年由雅虎内部开发,诞生的初衷也是因为内部的分布式系统需要一个通用的协调服务,后来选择开源。凭借 Hadoop 生态的崛起(HDFS、HBase、Kafka 早期版本都依赖它),Zookeeper 迅速被业界接受,成为分布式协调服务的标杆。
当时的开源生态还不像现在这么丰富,Zookeeper 在功能上“照顾”了很多场景,可以说是大而全。基于它,可以实现数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁、分布式队列等,几乎你能想到的协调功能,它都支持。
(2)颠覆者诞生
不管是Chubby还是Zookeeper,底层都依赖Paxos共识算法实现,当然,确切的说,Zookeeper依赖ZAB算法,但是,ZAB也不过是Paxos在实现上改进而已。关于共识算法,我们待会再讲。Paxos是分布式系统领域最基础的共识算法,也是出了名的晦涩难懂,当年作者发表了三次论文、经过三次重新描述才被大众理解,即便如此,实现起来也非常复杂。
在共识算法这一领域,Paxos统治了将近20年,直到2013年,Diego Ongaro 和 John Ousterhout 在斯坦福大学发布了论文 《In Search of an Understandable Consensus Algorithm》,首次提出替代性的Raft共识算法,才结束Paxos的统治地位。Raft算法解决Paxos的晦涩难懂的问题,实现起来也更加简单。
Raft 算法发布后,迅速被工业界关注。新一代的分布式协调服务纷纷切换到 Raft 实现,比如 etcd 和 Consul,以及后来服务于微服务的 Nacos。
(3)英雄迟暮
随着分布式技术的发展,很多基础系统开始拥抱分布式,迭代版本中加入了更多对分布式部署的支持,甚至很多新开发的系统从一开始就拥抱分布式。比如,Redis提供了Redis Sentinel、Redis Cluster。
加上Raft协议的可实现性降低了开发门槛,从2020年以后,新一代分布式系统掀起了“去Zookeeper化”运动,使分布式协调服务,特别是Leader选举这一功能,从“独立中间件”下沉为“系统内生功能”。比如,Kafka把协调功能塞进了自己的KRaft模块,RocketMQ用DLedger内置选举,连Redis都自己实现了故障转移。
为什么大家纷纷“抛弃” Zookeeper?原因无非三点:
- 减少网络开销:内置 Raft 后,协调通信在系统内部完成,不需要跨组件调用。
- 降低运维负担:少一个独立部署的中间件,就少一套监控、备份、扩容、故障处理流程。
- 可定制化和专项调优:内置后,可以根据自身系统的特点做深度优化,而不用受限于通用中间件的设计。
前面提到分布式协调的几个功能场景(配置中心、服务发现、分布式锁、Leader选举),部分功能被内置(比如,Leader选举),部分功能被替代(Redis 在分布式锁领域更轻量),分布式协调服务中间件的功能逐渐收缩,于是像Nacos这样聚焦于配置管理和服务发现,并且简单易好用的分布式协调服务,在微服务中得到了更多应用。
Leader选举主要应用于主从架构中的故障自动转移,一般都是集成在基础系统内的功能,作为业务开发者大多不会去了解,分布式锁有替代实现方案,比如MySQL悲观锁、乐观锁、或者Redis Lua脚本,仅剩下的配置中心和服务发现又有Nacos来实现。
每个功能场景都有独立的解决方案,集大成的分布式协调服务不再盛行,因此,很多开发者,特别是业务开发者,对分布式协调这个概念就淡化了,甚至都没听过,这也是我不大想讲这个知识点的原因。但是,了解这些可以让你对技术有个更高层次的认识,更容易将零散的技术关联起来,构建更合理的知识架构。这也是我最终决定讲一讲的原因。
3. 共识算法
我们来简单介绍下共识算法,当然,我们不会去讲实现原理,因为并不是我们课程的重点,感兴趣的话可以自行Google,有大量的资料和论文讲解。
共识算法解决的是分布式系统中一个非常核心的问题:让多个独立节点在不可靠的网络环境下,对某个值(比如“谁是 Leader”)形成一致性确认。
以 Leader 选举为例:当主节点故障,三个从节点如何决定谁是新 Leader?你可能会想,直接让编号最小的从节点当 Leader 不就行了?
但事情没那么简单。在存在网络分区的情况下,每个节点对其他节点是否存活的认识可能完全不同。网络故障可能将集群分割成多个独立分区,每个分区里的节点都以为其他节点已经挂了,于是各自独立选举出自己的 Leader。这就出现了多个“大脑”同时决策的现象,也就是著名的 脑裂问题。
共识算法的作用,就是在这种不可靠、可能分区的网络环境下,通过多轮投票、消息交换,保证所有节点最终对某个值达成一致。这正是 Raft、Paxos 这类算法要解决的难题。
有细心的读者可能会问:只有 Leader 选举跟共识算法有关吧?配置管理和服务发现跟它有什么关系?
确实,从功能上讲,配置管理和服务发现不需要共识算法。但是,数据要存储吧?服务地址要存储吧? 为了高性能、高可靠、高可用地存储这些数据并提供读写服务,我们又回到了前面讲到的数据库扩展架构问题:主从复制、故障自动转移、读写分离……你看,绕来绕去,还是得解决 Leader 选举问题,最终还是需要共识算法作为底层支撑。
所以说,共识算法是分布式协调服务的“地基”。没有它,上面那些配置管理、服务发现、分布式锁,都只能是单点的、不可靠的。
4. 最后总结
分布式协调服务这二十年的发展,其实就干了一件事:把复杂的东西变简单。最早只有Google的Chubby这样的“黑科技”,后来雅虎开源了ZooKeeper,将原来分布式协调可以当公共工具用! 。但那时候用起来还是挺费劲的,光Paxos算法就能把人绕晕。直到2013年Raft算法出现,事情才开始变化,共识算法变得没那么晦涩难懂难以实现。于是涌现出了更多像etcd、Consul这些更轻量的工具。而最近五年的趋势更直接:干脆别用外部协调服务了!故障转移从专家技能变为内置功能!
