☰
高可用微服务系统设计:从架构拆分到故障演练
2026/9/28 14:03:03 网站建设 项目流程

微服务在国内走过这么多年,到了2026年这个时间点,几乎每个像样一点的团队都在搞微服务,但真正把“高可用”三个字落地的其实不多。我见过太多项目是这样的:用KubeKey装了三台master的Kubernetes集群,MySQL做了主从,Nacos也搭了集群,看起来一切都在往“高可用”靠拢。结果线上一次数据库抖动,整个链路直接雪崩,服务一个都没挂,但用户请求全部失败。

问题出在哪?出在“高可用”被理解成了“服务不挂”,但高可用微服务系统设计真正要解决的是“链路不塌”。单机挂了有副本顶上,这只是最基础的一层;真正的挑战在于,几十个微服务互相调用的时候,任何一个慢节点、一个满了的线程池、一次没完没了的重试,都可能让整条链路在几分钟内彻底瘫痪。这篇文章我就结合自己做微服务改造和高可用治理的实际经验,把“高可用微服务系统设计与实现”这件事从架构拆分、到基础设施、到代码细节、再到流量治理和落地部署,一条线讲透。文章既适合正在做微服务改造的架构师,也适合刚接手微服务项目、天天被线上问题追着跑的后端开发。

1. 高可用不是“服务不挂”,而是“链路不塌”

1.1 一个最简单的数学题:99.9%的单服务可用性,救不了三十个节点的链路

很多团队讲高可用,张嘴就是“我们要做到99.9%”。99.9%什么概念?一年365天,允许不可用时间是8.76小时,平均到每个月只有43分钟。看起来挺严格了对不对?但如果你的业务链路只有10个微服务依赖,每个服务都做到了99.9%可用性,那么这10个服务同时可用的概率是:0.999的10次方,约99%。一旦链路拉到30个服务,0.999的30次方只剩97%。也就是说,单个服务看每个都“很可用”,组合起来用户体验就是一个月挂好几次。

这个数学题做完了,你就明白高可用微服务系统设计的第一个核心命题:你关注的不应该是“某个服务挂了没有”,而应该是“用户的这一次请求,走完整个链路成功没有”。链路越长,可用性被乘数效应稀释得越厉害,所以你要么拼命缩短链路,要么在链路的每个环节都设计好冗余、超时、降级和快速失败机制。这也是为什么微服务拆分在一定规模之后,高可用治理的优先级会远高于新功能开发。

1.2 故障域与爆炸半径:设计之前先划清边界

再聊一个很多人忽略的概念——故障域。故障域就是“一个故障能影响到的范围”。一台机器挂了,影响的是部署在这台机器上的所有实例;一个机房断电,影响的是整个机房;一个Redis集群主节点挂了但没自动切换,影响的是所有依赖这个Redis的服务。

高可用设计本质上就是在不断缩小故障域、压缩爆炸半径。拆服务、做隔离、做限流、做降级,这些东西的底层逻辑全部指向同一个目标:把故障关在一个笼子里,不让它蔓延到整条链路。举个例子,你有一个订单服务,它同时依赖库存服务和积分服务。如果积分服务是个老系统,稳定性本来就差,那么最常见的高可用做法不是把积分服务重构一遍,而是在订单服务里给积分调用加一个独立的线程池和熔断器。积分服务抖了,最多就是拿不到积分这一小块降级数据,订单主流程完全不受影响。这就叫故障隔离。

1.3 一条可落地的高可用设计路线

基于上面两个认知,我把高可用微服务系统的设计拆成五层,接下来按这条线展开。

  • 架构层:服务拆分和状态设计,决定故障域的边界。
  • 基础设施层:K8s集群、数据库、注册中心,解决“底座不塌”。
  • 代码层:超时、重试、幂等、熔断降级,解决“单个服务的自保能力”。
  • 流量层:限流、排队、容量保护,解决“流量尖峰来了扛不扛得住”。
  • 观测层:监控、链路追踪、告警和故障演练,解决“出了事能不能快速发现、快速定位”。

这五层每一层都有大量细节,少做一层,高可用就是个半吊子。

2. 服务拆分与状态设计:高可用的第一道分水岭

2.1 拆分边界怎么定:别按功能拆,按“独立交付能力”拆

高可用微服务设计里,第一步不是画架构图,而是定服务边界。边界定错了,后面所有的高可用手段都是在给一个错误的结构打补丁。

我见过最典型的反面案例是把“用户管理”“订单管理”“商品管理”这种按数据表拆出来的服务当成微服务。问题是,这种服务往往只对应某一张表,业务逻辑没有完整边界,改一个需求可能要同时改三个服务,而且这三个服务根本没法定独立版本发布。拆完之后,发布的复杂度不降反升,高可用更无从谈起。

我自己在业务中判断一个服务拆得对不对,就用三个标准:

  • 这个服务能不能独立部署、独立升级,而不强依赖同批次发布另一个服务。
  • 这个服务有没有自己独立的数据存储,而不是和其他服务共享同一个数据库。
  • 这个服务对外提供的接口,是否对应一个完整的业务能力,而不是一张表的增删改查。

满足这三条,这个服务才算一个真正的微服务。边界清晰了,故障域才能清晰。比如支付服务挂了,影响的是支付能力,订单服务还在,用户还能下单,这是一个可接受的局部故障;但如果订单服务和支付服务共享一张订单表,那支付挂了对数据库的依赖会导致订单服务也跟着雪崩,这就是典型的边界没划好。

2.2 无状态化是命门:副本永远能互相接管

服务拆分完之后,高可用的另一个基础是“无状态化”。这句话听起来像老生常谈,但你真去看很多团队的代码,session还在服务内存里,临时文件还在本地磁盘上,定时任务也是直接跑在服务进程里。这样的服务看起来可以水平扩展,实际上副本之间根本没法治愈——杀掉一个实例,它的状态就丢了。

无状态化的含义很简单:任何服务实例,都可以随时被杀掉、重新拉起,而不丢失数据、不影响正在进行的业务。要做到这一点,几个经典的状态必须外置:

  • 用户会话状态放到Redis,不能放在本地内存。否则用户第一次请求打到A实例,第二次请求打到B实例,登录态就没了。
  • 上传的临时文件、生成的报表放到对象存储或共享文件系统,不能放本地磁盘。否则要扩容的时候,新实例根本没有那些文件。
  • 定时任务状态要可重入,或者用分布式锁保证同一时刻只有一个实例在处理。否则多个副本同时跑任务,数据就乱了。

为什么无状态化对高可用这么重要?因为Kubernetes里Pod是随时可能被重新调度的。节点宕机、镜像更新、资源不足驱逐,任何一个动作都会杀掉你的实例。只有无状态化的服务,才能让这些动作变得“不疼不痒”。反过来,有状态的服务做高可用,就需要额外引入主从切换、数据同步、分布式一致性协议,复杂度和出故障的概率会直线上升。

2.3 状态外置之后,数据一致性怎么办

服务无状态化之后,还有一个绕不开的问题:数据库层的一致性。微服务的核心原则之一是数据按业务边界拆分,一个服务一个库,不允许跨服务直接查别人的表。但业务往往需要跨服务的数据流转,比如下单之后要扣减库存、加积分、发物流单。这时候你不能用传统数据库事务去保证ACID,只能用最终一致性。

我最常用的方案是本地消息表加消息队列:订单服务在自己的库里写入订单数据和一条“订单创建成功”的消息,放在同一个本地事务里。事务提交后,后台任务把消息发到MQ,库存服务、积分服务消费消息去更新自己的数据。这个方案看起来老,但胜在简单可靠:本地事务保证了“订单和消息不会少一条”,MQ中间件保证了消息最终会被消费。如果消费失败,就重试,重试到一定次数进死信队列,人工处理。整个过程是最终一致的。

需要强调的是,高可用不等于强一致。为了达到高可用,很多时候你必须在一致性上做一些妥协——接受短暂的数据不一致,用对账任务和补偿机制兜底。设计阶段就要把这个原则定下来,不然所有服务都想去抢一个分布式事务,性能和可用性都会很难看。

2.4 连接池和线程池也是状态,别把资源池当成无限大的

还有一个容易被忽视的“状态”——数据库连接池、HTTP客户端连接池、线程池。这些资源如果配置不合理,高可用就变成了一纸空文。

最常见的故障场景是这样的:某天某个上游服务变慢了,调用它的下游服务里有大量线程卡在等待响应的状态。由于每个请求占着一个连接池里的连接和一个工作线程,而系统的线程池是有上限的,新请求进来发现线程不够用,就开始排队。队列越来越长,响应时间越来越长,调用方开始超时重试,重试的请求又涌进来,最终整个服务被自己拖死。这个现象就是“线程池耗尽”,它比服务宕机更难排查。

所以高可用的服务设计里,一定要根据依赖方的情况给线程池设上限,给连接池设上限。数据库连接池不是越大越好,每一条连接在MySQL端都是一个线程、一份内存,盲目把连接池从50调到500,数据库大概率先扛不住。实践里DB连接池一般建议是“CPU核数乘以2到4”,如果业务特别复杂再结合压测往上调整。线程池则要遵循“一个依赖一组线程池”的隔离原则,后面第四章细讲。

3. 集群与数据底座:Kubernetes和MySQL的高可用实现

3.1 K8s三master高可用,关键不在master而在etcd

很多人搭高可用Kubernetes集群,第一反应就是“多搞几台master”,用KubeKey装个三master的HA集群。方向没错,但我得说一句大实话:三台master的意义不在于让kube-apiserver高可用,而在于让etcd高可用。apiserver本身是个无状态组件,前面挂一个负载均衡就能解决高可用问题;controller-manager和scheduler自带Leader Election,多副本自己会选主。真正有状态、需要奇数节点、需要quorum机制的,是存储集群所有元数据的etcd。

etcd用的是Raft一致性协议,三节点时允许挂一个,五节点时允许挂两个。注意一个非常反直觉的坑:etcd的写入需要quorum,也就是“多数节点写入成功”才算成功。当三节点集群挂了一个节点时,集群还能服务;但如果挂掉两个,剩下的一个节点永远凑不齐quorum,集群会变成只读甚至拒绝写入。所以etcd集群最好保持固定规模的奇数节点,不要随便扩缩,更不要在它上面跑一些无关的高负载任务。

实际操作中,完整的K8s高可用安装要做的几件事:

  • 三台master节点部署etcd,etcd配置里指定集群成员列表,形成静态集群。
  • master节点前面挂一个VIP或负载均衡(keepalived加HAProxy是最常见的组合),kube-apiserver通过这个VIP对外提供服务。
  • worker节点中kubelet、kube-proxy访问apiserver时都走VIP,不直接写具体IP。
  • 组件都开启Leader Election,controller-manager、scheduler的启动参数里加上--leader-elect=true。

我之前帮一个朋友团队排查过一个问题:他们明明装了三台master,结果一台master宕机之后,整个集群的Pod调度全停了。后来发现原因是kubelet配置里写的apiserver地址是某一台master的IP,而负载均衡只配给了外部访问,集群内部各组件并没有走VIP。这个问题提醒我们,HA集群不是装完就完事,还要逐个组件检查它访问apiserver的入口是否经过了高可用层。

3.2 MySQL高可用的几种常见方案:选型决定了你的RPO和RTO

微服务底座里,数据库通常是整个系统里最“有状态”的组件,也是故障影响最大的组件。做MySQL高可用,常见的有主从复制加切换工具、半同步复制、MGR(MySQL Group Replication),还有云数据库自带的高可用。它们的核心差异在于两点:RPO(最多丢多少数据)和RTO(恢复需要多久)。

先看一张对比表。

方案同步原理RPORTO适用场景
异步主从 + MHA切换主库写binlog,从库异步拉取可能丢最近几秒事务30秒左右,依赖脚本检测和切换对数据丢失不太敏感的业务
半同步复制主库等至少一个从库收到并落盘binlog才提交基本不丢30秒左右大多数交易类业务,MySQL 5.7开始支持
MGR单主模式组复制多节点强一致,自动选主不丢(若多数派存活)秒级需要数据库层自动切换、且团队有运维能力的场景
云数据库RDS依赖云厂商内部高可用极低感知不到或分钟级不想自己运维K8s和数据库的团队

我自己的建议是:交易链路相关的数据库,至少用半同步复制,把RPO压到接近零的水平。因为对用户来说,一笔订单刚下成功,过两秒查不到了,这种事故比“数据库暂时连不上”更难接受。纯异步的方案虽然性能好,但主库宕机的瞬间丢数据几乎是必然的,适合日志、报表这种丢了能重建的场景。

还有一个老生常谈但总有人犯的错:做了主从高可用,但应用层代码里把数据库地址写死在配置里,切换工具把主库切到了从库上,应用却还在连已经宕机的老主库。解决思路有两条:要么通过VIP访问数据库,主从切换时把VIP漂移过去,应用感知不到;要么利用Nacos这类配置中心动态刷新数据源配置。无论是哪条路,上线之前都要做一次真实的故障切换演练,别只在PPT里演练。

3.3 注册中心和配置中心自己不能挂:本地缓存是最后一道防线

微服务架构里,注册中心(Nacos、Eureka、Consul)承担的是服务发现职责,配置中心承担的是动态配置下发职责。这俩组件的高可用级别往往决定了整个系统的可用性上限。把它们做成集群是基本操作:Nacos至少三节点,配置存储用内嵌或外部数据库都可以,节点之间通过Raft或内嵌协议同步。

但比集群更重要的是一个设计原则:注册中心挂了,服务之间的调用不能跟着挂。国内很多基于Spring Cloud或者Dubbo的项目,都依赖在本地维护一份服务列表缓存。理想情况下,服务启动时从注册中心拉取全量服务列表,之后在本地内存里维护这份列表,注册中心的下线推送只是一种增量更新手段。如果注册中心整体不可用,服务之间仍然可以基于本地内存里的旧列表继续调用——新上线的实例可能发现不了,但存量调用不受影响。

这个原则我在好几个项目里实测过。有一次运维大清早升级Nacos集群,升级过程中注册中心短暂不可用,因为有本地缓存,业务流量完全没受任何影响。如果当时没有这一层设计,所有服务到Nacos拉列表都会超时,那场面就是连锁雪崩。

4. 后端代码里的高可用细节:超时、重试与幂等

4.1 超时控制:给每一次调用都定好“生命的边界”

代码层面的高可用,第一个要补的短板就是超时。我接手过的几乎所有线上故障里,没有超时或者超时设置过长,是压垮系统的第一块多米诺骨牌。

超时一定要分三段独立设置:连接超时、读取超时、写入超时。连接超时表示TCP连接建立的最长等待时间,一般设置500毫秒到1秒;读取超时表示请求发出去之后等待响应的最长时间,这个要根据业务场景来定,正常接口300到500毫秒,批量接口可以放宽到2到3秒;写入超时是为发出请求预留的时间,通常设置得比读取超时短,否则会出现“请求已经写出去了但一直等不到结果”的悬空状态。

举个例子,用Go写一个HTTP客户端,超时应该这样配:

client := &http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: 500 * time.Millisecond, }).DialContext, TLSHandshakeTimeout: 1 * time.Second, ResponseHeaderTimeout: 500 * time.Millisecond, IdleConnTimeout: 90 * time.Second, }, }

这里有个容易被忽略的细节:HTTP客户端的总超时不要单独设成一个很大数字,否则传输层超时和业务层超时叠加起来,一个请求可能卡好几秒。更合理的做法是每一层都设一个小一点的超时,让故障可以被快速发现。全链路超时还有一个原则:逐层递减。比如从网关到订单服务是800毫秒,订单服务调用库存服务就只给400毫秒,库存服务查数据库只给150毫秒。这样做的目的是,上游等待时间必须小于下游的超时时间,否则下游已经开始重试了,上游还在傻等,链路会因为超时叠加变得特别脆弱。

4.2 重试是双刃剑:不加判断的重试就是雪崩加速器

网络调用不可避免地会遇到瞬时抖动,所以重试机制是必要的。但无脑重试比不重试更可怕,因为它能把一个服务节点的故障放大到整个集群。最典型的场景:某个服务超时了,调用方自动重试3次,2万个并发请求同时超时,意味着打进故障服务的一共有8万个请求,彻底把它打挂。然后其他依赖它的服务也开始超时重试,故障像滚雪球一样扩散。

重试的三条军规:

  • 只在幂等操作上自动重试。查询、删除、以及带了唯一业务ID的更新操作可以重试;纯扣减库存、转账这种操作,要么保证接口幂等,要么绝对不重试。
  • 总重试次数不要超过2次。第一次失败立即重试,第二次失败说明服务大概率有问题,放它一条生路。
  • 重试必须加指数退避和随机抖动。每次重试的间隔时间成倍增加,比如第一次100毫秒,第二次200毫秒,再加一个20毫秒的随机值。防止所有请求在同一时间点集中重试。

还有一种很优雅的做法:用状态机驱动重试。比如订单状态是“创建中”到“创建成功”或者“创建失败”,每次重试都带上订单号,即使上一次请求实际成功但响应丢了,这次重试顶多是把状态从“创建中”幂等地修正为“创建成功”,不会重复下单。这种幂等设计靠的不是运气,而是接口设计一开始就带上了全局唯一的业务键。

4.3 熔断、降级与舱壁隔离:像保护心脏一样保护核心链路

如果超时和重试是代码层的“减速带”,那熔断降级就是代码层的“保险丝”。熔断器的状态机非常直观:正常情况下是Closed,所有请求正常放行;当错误率超过阈值(比如滑动窗口内50%的请求失败),状态变成Open,后续所有请求直接快速失败,不再发往下游;过一段时间进入Half-Open,放几个探测请求试试下游是否恢复,恢复则回到Closed,没恢复则继续Open。

用Resilience4j配置一个熔断器,大概是下面这种感觉:

CircuitBreakerConfig config = CircuitBreakerConfig.custom() .failureRateThreshold(50) .waitDurationInOpenState(Duration.ofSeconds(10)) .slidingWindowSize(20) .minimumNumberOfCalls(10) .build();

这个配置的意思是:统计最近20个请求,最少要10个请求参与统计,如果失败率超过50%,熔断器打开10秒,之后进入半开状态。具体数值要结合业务调整,但思路是确定的:熔断的粒度要细到“一个下游服务或一个接口一组熔断器”,而不是整个服务共用一个熔断器。否则一个不重要的下游服务抖了一下,熔断器直接把包含核心业务在内的所有请求都拒了,这是典型的因小失大。

舱壁隔离的原理我经常用银行柜台来类比。你去银行办事,如果只有一个排队队列、四个窗口,前面一个人办一笔特别复杂的业务卡了半小时,后面所有人都得跟着等。舱壁隔离的做法是给四个窗口各开一个队列,办复杂业务的人会被引导到其中一个队列,哪怕那个队列堵死了,另外三个窗口的存钱取款还能正常办理。代码里对应的就是:每个下游依赖分配一个独立的线程池或信号量,调用积分服务走积分线程池,调用库存服务走库存线程池,它们之间互不干扰。慢依赖的线程池满了只会丢弃它自己的请求,不会吃掉核心业务的工作线程。

4.4 优雅上下线与健康检查:让发布不再是“定时炸弹”

代码跑得好好的,一发布就出问题,这种情况在高可用微服务里特别常见。原因多半是发布流程里没有做好优雅上下线。

上线的时候,新实例注册到Nacos之前要把流量拦在外面。K8s里的Readiness探针就是干这个的:探针返回“不成功”的阶段,Service不会把流量转发给这个Pod,但是Pod里的进程已经在运行。只有当探针确认服务已经初始化完成、依赖连接建立好、能够处理请求了,才把Pod标记为Ready。常见配置是启动延迟10秒后每5秒探测一次,失败阈值3次。

下线的时候,要处理好“存量请求”。进程需要先摘除注册(从Nacos反注册自己的服务地址),再把应用的端口断开让LoadBalancer不再分配新连接,然后进入一个缓冲期,让已经在途的请求有机会完成,最后才关闭工作线程池、释放数据库连接、退出进程。在Go里,这个流程可以用一个优雅退出逻辑实现:

go func() { // 处理SIGTERM信号 <-ctx.Done() // 1. 反注册服务 registry.Deregister() // 2. 停止接收新请求 server.Shutdown(context.WithTimeout(context.Background(), 10*time.Second)) // 3. 关闭连接池 db.Close() }()

K8s里配合preStop钩子做类似的事情。很多人以为配置了K8s就自动优雅了,其实不是。K8s只能保证Pod的编排动作,应用进程内部怎么配合还是要靠代码自己打磨。发布期间多花这几十秒,换来的却是线上稳定性的大幅提升,这笔账很划算。

5. 流量治理与容量保护:限流、排队与全链路压测

5.1 限流算法选型:计数器、滑动窗口、漏桶还是令牌桶

高可用系统里,限流是最常见的自我保护手段。限流算法各有侧重,选型之前先把差异搞清楚。

算法核心思想优点缺点
固定窗口计数器每秒重置一个计数,超过阈值就拒绝实现简单,内存占用小窗口临界瞬间可能打两倍流量,限流不够平滑
滑动窗口按时间切片,窗口内统计请求数比固定窗口平滑,边界问题改善内存占用略高,实现稍复杂
漏桶请求以固定速率流出,桶满则丢弃输出速率绝对均匀无法应对突发流量,可能导致大量请求被延后
令牌桶桶里放令牌,请求来了消耗令牌允许一定突发流量,天然削峰填谷突发处理不当可能压垮下游

具体到业务里,我的习惯是:接口级别的保护优先用令牌桶或滑动窗口,因为它在限流的同时还能容忍一定的流量毛刺;如果我们用的是Sentinel,网关和核心接口默认走的是滑动窗口模式,配置起来也简单。如果系统用的是Redis+Lua脚本做分布式限流,那一定要测试并发情况下Lua脚本的执行性能,别让Redis变成新瓶颈。

5.2 单机限流和分布式限流:一个保护自己,一个保护全局

限流要分两层来做。单机限流保护的是本服务实例的CPU、内存、线程池,简单的做法是在内存里做令牌桶,每个实例各限各的;分布式限流保护的是共享资源,比如数据库、第三方接口、核心下游,需要把计数放到Redis里,所有实例共享一套限流规则。

一个常见误区是“只要做了分布式限流,单机限流就可以不做”。实际上分布式限流有它自己的问题:Redis本身的延迟和可用性会影响限流准确性,网络抖动可能导致误判。更稳妥的方案是两层配合:在网关层做分布式限流,用于全局配额控制,保护整个系统的总容量;在服务内部做单机限流,为每个实例设一个略高于单机容量的阈值,保护本机不被极端流量打爆。

比如网关层限流“用户名下订单接口每秒最多2000次”,但某个实例的本地限流是“每秒最多300次”。当2000个请求分散到5个实例,每个实例最多承受400个,却被本地阈值限制在300个,系统会自动让一部分流量失败。这是在发生故障时的人工选择:宁可让30%的用户看到失败提示,也不允许系统被全部拖垮。高可用从来不是让所有用户成功,而是保证系统不崩溃、核心业务可用。

5.3 排队与异步化:把瞬时洪峰削成平缓水流

限流是“拦”,排队是“吞”。遇到秒杀这种瞬时流量是平时几十倍的场景,光靠限流拒绝请求会导致大量用户根本抢不到机会,体验很差。更好的方案是把同步请求改成异步:请求进来先返回“排队中”,把任务投递到消息队列,后台服务按自己的消费速率处理,处理完通过站内消息或轮询接口通知用户。

同步调用改异步之后有一个新的坑:消息队列自身的高可用变成关键。Broker集群要配置多副本,生产者要开启确认机制,消费者要保证处理消息的幂等性。另外队列里的消息不能无限积压,一定要监控消费延迟,积压超过阈值就告警。否则就是“请求没把服务打挂,反而是队列把整个系统拖到天荒地老”。

5.4 全链路压测:高可用不是拍脑袋,是压出来的

很多团队的高可用设计只到“配置了熔断、限流”这一步,上线之后你敢不敢说系统能扛住多少QPS?八成不敢。因为从来没有做过真实的全链路压测。

全链路压测要模拟的是完整用户行为:一个请求从网关进来,依次经过认证、风控、业务服务、数据库、缓存、第三方接口,最终返回成功。压测的目标是找出每个环节的容量拐点:数据库CPU用到多少开始慢查询增多?缓存命中率掉到多少性能开始变差?线程池在多大并发下开始排队?然后在拐点以下设一个报警阈值,比如拐点是单实例1000 QPS,限流阈值就设为800,留20%余量应对流量抖动。

压测只测单个微服务是不够的,因为服务之间的排队效应只有在链路压测里才能暴露出来。压完之后写一份容量报告,标明每个服务建议的副本数、每个接口的建议限流值、每次大促前需要扩容多少节点。这份报告才是高可用系统真正的“容量底气”。

6. 可观测性:给高可用系统装上仪表盘

6.1 Metrics、Logging、Tracing,三件套一起上

高可用系统和高可观测性是成对出现的。没有观测能力的高可用就是盲人骑瞎马,你根本不知道服务是在正常服务还是在摇摇欲坠。

Metrics负责回答“量”的问题:每秒请求数、错误率、P99延迟、CPU使用率、内存占用、数据库连接数。用Prometheus采集指标,Grafana出面板,再配Alertmanager告警,这是目前后端最标准的观测链路。Logging负责回答“发生了什么”的问题:每一条日志要带上traceId和spanId,这样你才能在成千上万条日志里把一次完整调用串起来。Tracing负责回答“时间花在哪了”的问题:用Jaeger或者SkyWalking追踪每个请求经过的每个服务,每个环节消耗了多少时间,一眼就能看出瓶颈在哪个节点。

三者缺一不可,但最常被忽视的是Tracing。很多团队Metrics和Logging都做了,但是服务之间没有串联的traceId,排查问题的时候只能靠猜:这条错误日志对应的到底是哪个上游请求?哪个下游环节慢?没有traceId,跨服务的排查效率直接减半。

6.2 告警不是越多越好,SLO才是北极星

告警是要命的设计。我见过一个团队,一个服务挂了五个告警,因为CPU、内存、错误率、延迟、重启次数都设了独立告警,值班同学一晚上被吵醒七次,后来干脆把告警全部屏蔽了。告警一旦被屏蔽,那跟没有告警没有任何区别。

更好的做法是围绕SLO(服务水平目标)来设计告警。先定业务的核心SLO,比如“核心下单接口成功率不低于99.9%,P99延迟低于300毫秒”,然后围绕它建立几个有限的告警规则。告警信息要能回答三个问题:什么在变差?影响谁?需要谁去看?比如一个有效的告警文案是:“订单服务错误率超过1%,P99延迟800毫秒,持续5分钟,影响所有下单用户,值班开发请介入。”而不是冷冰冰的一句“CPU超过80%”。

还有一类告警容易被忽略:饱和度。数据库连接数快满了、Redis内存快满了、磁盘快要满了,这些不是突发的错误,但往往是更大事故的前兆。饱和度告警应该提前设置,宁可早叫早处理,也不要等系统完全不可用了才被用户反馈唤醒。

6.3 故障演练:高可用不是靠“运气”,是靠“预案”

最后一个容易被忽略的环节是故障演练。混沌工程这几年在国内也越来越普及,Chaos Mesh、Litmus这些工具可以在K8s环境里直接注入故障,比如杀掉一个Pod、让某个服务延迟几秒钟、模拟网络分区。

但演练的意义不在于“会杀Pod”,而在于每次演练都能暴露一个预案的缺口。第一次杀Pod,可能发现流量没有完全切走,因为有连着的旧连接;第二次模拟MySQL主从切换,可能发现配置中心没有通知应用刷新连接,应用还连在老主库上;第三次模拟某个服务全部副本宕机,可能发现熔断阈值设得太低,连正常流量都开始误伤。每演练一次,修复一个缺口,高可用系统才是真的滚起来了。没有经过演练的高可用方案,大概率只能在故障发生那分钟变成“不可用方案”。

7. 本地联调与部署落地:从IDE里跑通到集群里高可用

7.1 本地启动多个微服务的痛点:能跑起来比能写好代码更考验人

前面聊了这么多设计理念,落到日常开发里,第一个现实问题是:微服务项目代码拉下来之后,本地怎么跑起来?几十个服务不可能全部在本机启动,机器资源也扛不住。我的经验是,用“依赖容器化 + 服务按需启动”的方式来解决。

具体来说,基础设施依赖(MySQL、Redis、Nacos、RabbitMQ)用Docker Compose一键起,本地开发机只启动当前正在开发的那一两个服务,其他下游服务通过注册中心的namespace或环境隔离来区分。比如你在本地起了一个订单服务,它需要调用库存服务,那就通过网关或者内网环境把库存服务的调用转发到测试环境已有实例上,而不是在本地也起一个库存服务。这样既保证了本地环境干净,又避免了每个开发都要启动全套服务的尴尬。

IDE方面,基于IntelliJ IDEA的微服务启动要养成分组管理的习惯。把服务按启动顺序分组:第一组是注册中心和配置中心,第二组是基础中间件连接,第三组是各个业务服务。每组用一个Run Configuration保存启动参数,这样新增一个服务之后,其他人拉代码也能复制你的启动模板,少踩很多环境坑。

7.2 配置与环境隔离:本地、测试、生产,别让配置成为隐患

微服务高可用的一个前提是环境隔离做得好:配置里不写死环境相关内容,每个环境用自己的配置中心命名空间。

以Nacos为例,推荐的做法是:服务里只写应用名和应用版本,启动时指定命名空间(namespace),同一个服务在dev、test、prod各自一套配置,配置项的key完全一致,只是value不同。这样代码在本地跑的时候,只需要把Nacos地址指向本地,启动参数加上对应namespace,就能拿到本地的配置;Jenkins或GitLab CI部署到测试环境时,改成测试环境的Nacos地址和namespace,配置自动切换。

特别要注意的是数据库地址、密码、第三方密钥这些敏感信息,绝对不能写进代码仓库。现在的配置中心都支持加密配置,Nacos、Apollo都有加密插件。安全上省事一时,线上被脱库一次,再回来改成本就高了。

7.3 部署到K8s的高可用检查清单

最终所有服务都是要部署到K8s集群里的。我整理了一个部署上线前的高可用检查清单,每次发布之前过一遍,能拦下大部分低级故障:

  • 服务是否配置了资源请求和限制(request/limit),避免单个Pod吃光节点资源,也避免HPA扩缩容时调度失衡。
  • 是否配置了Readiness和Liveness探针,且探针的阈值与服务的启动时间匹配,不要一启动就探、探失败就重启,形成重启死循环。
  • 是否配置了PDB(PodDisruptionBudget),保证在节点维护或集群升级时,不会一次性把某个服务的所有副本全部抢占式调度掉。
  • 是否配置了反亲和性,把同一个服务的多个副本调度到不同节点。否则物理机宕机的时候,所有副本一起玩完,高可用形同虚设。
  • 是否配置了HPA,基于CPU使用率或自定义指标自动扩缩容。高可用系统要能在流量上涨时自动扩容,而不是等运维半夜起来手动加副本。
  • 是否检测过服务启动时对注册中心、配置中心、数据库的反向依赖顺序,避免出现“服务起来了但依赖没就绪,于是疯狂重试”的羊群效应。

7.4 一个建议:先画依赖图,再决定在哪里设防

最后分享一个我个人的实操习惯。每个微服务项目接手下来,我不会急着看代码,而是先把整个系统的服务依赖图画出来,标出每一条调用链路的依赖方向、协议类型、平均耗时、是否核心链路。然后针对每个依赖问四个问题:它挂了,我能降级吗?它慢了,我有超时吗?它抖了,我的熔断和隔离在哪里?它恢复了,我的半开探测能做对判断吗?

这张图一旦画清楚,限流阈值、熔断策略、线程池隔离、降级开关应该放在哪个服务上,就会非常直观。比如发现支付回调是长链路、强依赖、不可降级,那就必须在它前面做多重保险;比如发现积分服务是弱依赖,那就优先做降级而不是优先做重构。

高可用微服务系统设计说到底是两件事:把故障的爆炸半径控制住,把系统的恢复能力练出来。技术方案复杂,但每一步落到代码和配置里,都不玄乎。按上面的链路一步步做,你的系统就算做不到一年5个9,至少也能做到“出了故障不崩盘,出了事故敢处理”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询