文章目录
《系统频繁故障?让我来带你搭建坚不可摧的稳定性体系》
1. 监控先行!揭开系统稳定性评估的核心密码
- 如何评估系统稳定运行?构建完善监控体系,24 小时追踪各项指标是关键。它如预警员,发现异常即告警,助力快速定位解决问题,保障系统稳定。
- 接下来将从以下几项展开阐述。
1.1 全局监控
首先是全局监控,我们需要掌握系统的上下游:应用拓扑、RPC提供者/消费者、MQ生产者/消费者、数据集群(mysql/redis/es等)。除了整体的监控,还需要看到接口调用量、成功率、失败数、错误日志数等指标。
1.2 机器监控
再说机器监控,整个服务在运行期间,会部署多台机器提供支持。其中不可避免就会出现某些机器故障,这里我们就需要机器维度的监控,如TCP连接数、网络连通性、出入流量、磁盘指标、CPU指标、内存指标等。一旦监控到某些指标不合理,就需要及时的告警通知,进行流量摘除,并及时置换机器。
1.3 数据监控
一个系统能运行,底层必然离不开诸多的数据支持。比如mysql、redis、es、rpc、mq等。我们要监控各个数据源的集群健康度,如mysql磁盘使用率、主从延迟、慢sql等,redis的大key、热key、同步延迟、内存使用率,es磁盘使用率、CPU使用率等,rpc调用时延,mq生产/消费详情,是否存在消费积压等。
1.4 调用链路
有了机器和数据的稳定支持,接下来就需要关注服务细节,我们对外提供服务,要梳理调用链路,并将其执行情况生成对应的监控看板。在流量高峰期或者日常,我们可以通过链路看板观察服务链路中各个节点的运行情况。
如上图的调用链路,我们只要在核心节点处进行监控埋点,最终就可依据节点监控生成调用链路看板。
1.5 接口监控
调用链路的监控形成需要依赖于各个节点的埋点,即接口监控。在编码期间我们可以通过抽象层、过滤器、拦截器、AOP等统一埋点,另外对于一些细节的逻辑,需要手动埋点。这里可以监控到接口的的tp、调用量、可用率、机房流量、机器流量等。
1.6 业务监控
有了接口维度的监控,但在一些核心数据处理或业务逻辑中还存在一些需要业务监控的场景,如业务异常监控、分支执行情况监控等。
1.7 jvm监控
上文说明了服务调用链路、接口、业务监控都是服务内部的监控,偏向于业务逻辑。作为一个java服务集群,我们还需要关注服务自身的虚拟机情况。这里就需要监控jvm的指标,如内存使用率(堆内存/堆外内存)、CPU使用率、GC情况(老年代、新生代GC次数和耗时)、线程数等。
2. 日志-排查故障风险的追踪神器
- 在系统稳定性建设的宏大版图中,监控体系无疑是守护系统健康的 “瞭望塔”。但当监控系统发出异常告警,问题根源的探寻往往才刚刚开始。这时,日志作为系统运行的 “黑匣子”,就成为了深入剖析问题的关键突破口。
- 日志就像系统运行的 “编年史”,详细记录了服务运行过程中每一个关键事件、每一次函数调用、每一条数据处理流程。接下来将从以下几项展开阐述。
2.1 数据源出入参
在mysql、redis、mq、es、配置中心等数据源要适当的打印出入参日志。为什么是适当呢,有的节点请求响应数据很大,针对这种情况可以只输出其中的一个元素,或者集合大小即可,防止频繁打印大对象导致磁盘使用率过高。
2.2 关键节点出入参日志
在链路中的关键节点要打印出入参,一方面在测试环境验证逻辑是否合理(如果有单元测试那就另说了),一方面线上出问题之后排查起来更容易。主要是核心业务流程的入口与出口、复杂算法的执行点、数据转换的关键步骤等。
2.3 全链路日志追踪
一次用户请求往往需要经过多个系统、多个服务节点的协同处理才能完成。一次请求在各个系统要有唯一的链路id,对于排查自身系统来说可以串联日志,对于上下游来说也更精准的定位问题。
- 这里说明一下全链路日志追踪的实现方案:
在入口处根据一定的规则生成traceId,将其放到mdc中,可以在单系统中传递。对于跨系统的传递,rpc可以使用context实现,mq可以使用消息参数实现,http可以放到请求头中等。开源方案这里不再赘述,如Zipkin。
2.4 日志中体现调用线程
日志打印中要体现线程id,不然有些异步逻辑无法串联支路。在多线程并发场景下,线程信息也有助于排查资源竞争、死锁等问题。
2.5 使用占位符而不是拼接
打印日志时使用占位符可以走预检查,延迟计算,避免日志级别调整后出现无效的计算(如JSON.toJsonString(obj))。
3. 集群-服务高可用的核心保障
- 在系统稳定性的构建蓝图中,监控与日志作为运行期间的 “黄金搭档”,已然为系统的健康监测与故障排查筑起坚实防线。
- 若想进一步提升服务的稳定可用性,服务部署环节的优化不可或缺,而集群式部署正是其中的核心策略。单机部署在面对大流量冲击时,极易出现性能瓶颈甚至崩溃,同时,单一机器的故障也可能导致服务全面中断。相较之下,集群式部署通过整合多台服务器协同工作,实现负载均衡与资源共享,不仅能轻松应对高并发场景,还能在部分机器故障时,通过故障转移机制保障服务持续运行。
- 即使集群式部署,也需要跨机房,每个机房中有多个机器提供服务,这样即使某些机器故障,全局依然可用,另外即使某个机房故障,我们也可以用其他机房保障服务。
3.1 单元化
集群部署可以解决机器故障的问题,跨机房可以解决机房故障的问题。那么是否需要某些策略来保障这个集群能发挥出最大的性能呢?这个就需要提到单元化,即北京周围用户访问北京的数据中心,上海周围用户访问上海的数据中心,其中底层数据共享通过数据同步机制进行复制。
- 集群间数据同步见另一篇文章:《数据同步实战篇》https://blog.csdn.net/for62/article/details/147701697
- 单元化数据同步,针对不同的数据源可以统一采用生产者消费者模式实现。根据数据源实现不同的生产者/消费者,源数据发送到生产端,消费端负责写入新集群。
3.2 负载均衡
- 如上图,可以看到访问到北京中心之后具体请求落到哪一台机器需要经过负载均衡。那么这里我们就详细介绍一下负载均衡以及常用的实现方案。
- 负载均衡作为一种关键技术,通过将网络请求或工作负载均匀地分配到多个服务器上,不仅有效避免了单点服务器的过载,还显著提升了系统的整体性能、可用性和可扩展性。
- 负载均衡器是通过软件实现负载均衡功能的解决方案,常见的有 Nginx、HAProxy、LVS(Linux Virtual Server)等,下面我们逐一介绍。
3.2.1 Nginx
是一款高性能的 HTTP 和反向代理服务器,同时也具备强大的负载均衡能力。它支持多种负载均衡算法,如轮询、加权轮询、IP 哈希等,并且能够根据服务器的响应时间动态调整请求分配。
- 优点:轻量级,资源占用少,性能出色,能够处理大量的并发请求;配置简单灵活,易于上手;支持多种协议,如 HTTP、HTTPS、WebSocket 等。
- 缺点:主要适用于 HTTP 协议的负载均衡,对于其他协议的支持相对有限;在处理大规模流量时,可能需要进行复杂的优化配置。
3.2.2 HAProxy
是一款支持多种协议的负载均衡器,可用于 TCP、HTTP、SSL 等协议的流量分发。它提供了丰富的负载均衡算法和健康检查机制,能够实时监测服务器的状态,确保请求被分配到健康的服务器上。
- 优点:功能强大,支持多种协议和负载均衡算法,适用于多种应用场景;具备良好的性能和稳定性,能够处理高并发请求;提供详细的统计信息和监控功能,便于系统管理和优化。
- 缺点:配置相对复杂,对于初学者来说可能需要一定的学习成本;在处理某些复杂的业务逻辑时,可能需要进行额外的开发和定制。
3.2.3 LVS
是基于 Linux 内核的负载均衡解决方案,工作在网络层,可对 TCP、UDP 等协议的流量进行分发。它提供了多种负载均衡模式,如 NAT 模式、DR 模式、TUN 模式等,能够满足不同的网络环境和业务需求。
- 优点:性能极高,能够处理大规模的网络流量,适用于高并发的应用场景;基于内核实现,稳定性好,可靠性高;支持多种负载均衡模式,可根据实际需求进行灵活选择。
- 缺点:配置相对复杂,需要对 Linux 内核和网络原理有深入的了解;主要适用于 TCP、UDP 协议的负载均衡,对于应用层协议的支持相对较少。
4. 灰度 + 回滚:保障系统稳定的最后防线
4.1 灰度场景
-
灰度发布本质上是一种平滑过渡的发布策略,它像给新功能戴上“观察滤镜”,在确保核心用户群稳定体验的同时,小范围验证新功能的可靠性与兼容性。除了按人群、区域、IP划分外,灰度还常基于用户画像标签(如消费等级、使用频次)、设备类型(iOS/Android)等条件精准筛选目标群体。例如某电商APP上线“AR虚拟试衣”功能时,先对年轻女性用户、高配机型用户开放,优先获取更适配的反馈。
-
在具体实施流程中,灰度发布通常分为多个阶段:首先是“冒烟测试”阶段,面向内部员工或测试用户群,在真实环境中快速验证功能基础逻辑;接着进入“小流量灰度”,将新功能暴露给1% - 5%的真实用户,重点监测性能指标(如响应时间、接口成功率)与用户行为数据(页面停留时长、功能使用率);最后通过逐步放量,每次递增5% - 10%用户,直至全量发布。
-
灰度发布的核心价值不仅在于故障止损,还体现在业务决策层面。通过灰度期收集的用户反馈,产品团队能及时调整功能设计,例如某社交APP在灰度测试“语音聊天匹配”功能时,根据用户投诉调整了匹配算法;同时,灰度也是A/B测试的天然载体,可并行验证多个方案,如不同界面布局、推荐策略的转化效果。此外,灰度还能缓解技术团队的发布压力,分阶段监控日志与监控指标,定位问题的时间成本大幅降低。当故障发生时,完善的灰度架构支持“一键回滚”,通过流量调度策略快速将用户切回稳定版本,将故障影响时长控制在分钟级。
4.2 回滚触发条件
- 业务逻辑异常:新功能上线后,用户反馈出现订单丢失、数据统计错乱等业务层面的严重错误,直接影响核心业务流程的正常运转。
- 性能指标恶化:通过监控系统发现,接口响应时间大幅增加,甚至频繁出现超时现象,系统吞吐量显著下降,导致用户体验急剧恶化。
- 错误日志暴增:服务器日志中错误信息数量呈爆发式增长,涵盖各类运行时异常、数据库连接错误等,预示着系统内部存在严重隐患。
- 资源占用异常:CPU、内存等系统资源使用率持续居高不下,可能导致系统崩溃或服务中断。
4.3 AB / 降级控制回滚
- AB 测试回滚:在灰度发布基于 AB 测试进行时,若实验组出现异常,可直接关闭对应实验策略,将流量切换回对照组。
- 功能降级回滚:当新功能出现故障时,通过降级开关将其替换为简化版或旧版本功能。
- 流量调整回滚:逐步缩减新功能的灰度流量比例,直至完全关闭。这种方式适用于问题影响范围尚不明确的情况,通过渐进式操作,最大限度减少对用户的冲击。
4.4 直接回滚部署
- 当异常情况严重,如出现系统崩溃、数据丢失风险等,AB / 降级控制回滚无法快速解决问题时,需采取直接回滚部署的方式。直接将系统恢复至上一稳定版本的部署状态,即删除或覆盖当前灰度版本的代码和配置,重新部署旧版本程序,确保系统迅速回归稳定状态。
5. 限流-服务稳定坚守的底线
为了保证在大流量场景下服务不崩,限流是必须坚守的底线。要评估服务集群中的压力点,如数据库、缓存等、以及单机的压力点,如CPU、内存占用等。
5.1 集群限流
5.1.1 基于redis实现令牌桶算法
令牌桶算法是一种常用的限流算法,它以固定的速率向桶中添加令牌,每个请求需要从桶中获取一个或多个令牌才能被处理。如果桶中没有足够的令牌,请求将被拒绝。
import redis.clients.jedis.Jedis;
public class TokenBucketRateLimiter {
private static final String REDIS_KEY = "token_bucket";
private static final int CAPACITY = 100; // 令牌桶容量
private static final int RATE = 10; // 令牌生成速率(每秒)
private static final long INTERVAL = 1000; // 时间间隔(毫秒)
private Jedis jedis;
public TokenBucketRateLimiter() {
this.jedis = new Jedis("localhost", 6379);
// 初始化令牌桶
jedis.set(REDIS_KEY, String.valueOf(CAPACITY));
}
public boolean tryAcquire() {
long currentTime = System.currentTimeMillis();
// 计算当前应该拥有的令牌数
long tokens = Long.parseLong(jedis.get(REDIS_KEY));
long newTokens = Math.min(CAPACITY, tokens + (currentTime - lastUpdateTime) * RATE / INTERVAL);
if (newTokens > 0) {
// 消耗一个令牌
jedis.set(REDIS_KEY, String.valueOf(newTokens - 1));
lastUpdateTime = currentTime;
return true;
}
return false;
}
private long lastUpdateTime = System.currentTimeMillis();
}
5.1.2 zset实现毫秒级限流
在 Redis 中,可以使用有序集合(zset)来实现毫秒级的限流。其核心思路是将每个请求的时间戳作为分数存储在有序集合里,通过移除过期的时间戳并统计当前集合内的元素数量,以此判断是否超出限流阈值。
public class ZSetRateLimiter {
private static final String REDIS_HOST = "localhost";
private static final int REDIS_PORT = 6379;
private static final int LIMIT = 10; // 限流阈值
private static final long WINDOW = 1000; // 时间窗口,单位毫秒
private Jedis jedis;
public ZSetRateLimiter() {
this.jedis = new Jedis(REDIS_HOST, REDIS_PORT);
}
public boolean tryAcquire(String key) {
long currentTime = System.currentTimeMillis();
// 移除时间窗口外的元素
jedis.zremrangeByScore(key, 0, currentTime - WINDOW);
// 添加当前请求的时间戳
jedis.zadd(key, currentTime, UUID.randomUUID().toString());
// 统计当前时间窗口内的请求数量
Long count = jedis.zcard(key);
return count <= LIMIT;
}
}
5.2 单机限流
5.2.1 RateLimiter
- RateLimiter 是 Google Guava 库中用于实现限流的工具,它有两种类型:突发型(普通的 RateLimiter)和预热型(WarmingUpRateLimiter)。
5.2.1.1 突发型 RateLimiter
突发型 RateLimiter 基于经典的令牌桶算法实现,令牌以固定的速率添加到令牌桶中。当有请求到来时,会尝试从令牌桶中获取所需数量的令牌。如果桶中有足够的令牌,请求会立即被处理;如果没有足够的令牌,请求会被阻塞,直到桶中生成足够的令牌。
适用场景
- 允许突发流量的场景:在某些业务场景中,可能会出现短时间内的流量突发情况,但系统有能力在短时间内处理这些突发请求。
- 对响应时间要求较高的场景:如果业务对请求的响应时间非常敏感,不允许请求长时间等待,那么突发型 RateLimiter 是一个不错的选择。它可以在有令牌的情况下立即处理请求,减少请求的等待时间。
import com.google.common.util.concurrent.RateLimiter;
public class BurstyRateLimiterExample {
public static void main(String[] args) {
// 创建一个每秒允许 5 个请求的突发型 RateLimiter
RateLimiter rateLimiter = RateLimiter.create(5.0);
for (int i = 0; i < 10; i++) {
// 获取一个令牌
double waitTime = rateLimiter.acquire();
System.out.println("第 " + i + " 次请求,等待时间:" + waitTime + " 秒");
}
}
}
5.2.1.2 预热型 RateLimiter
预热型 RateLimiter 在令牌桶算法的基础上增加了预热期的概念。在系统启动初期,令牌的生成速率较低,随着时间的推移,令牌的生成速率逐渐增加,直到达到预设的稳定速率。这样可以让系统在启动阶段有一个逐渐适应流量的过程,避免系统在启动初期因突然到来的大量请求而崩溃。
适用场景
- 系统启动阶段需要预热的场景:对于一些需要初始化资源或者加载数据的系统,在启动初期可能无法承受高并发的请求。例如,数据库连接池在启动时需要初始化一定数量的连接,缓存系统需要加载大量的数据到内存中。在这种情况下,使用预热型 RateLimiter 可以让系统在启动阶段以较低的速率处理请求,随着系统资源的逐渐准备就绪,再逐渐提高处理请求的速率。
- 资源消耗随流量增加而增加的场景:有些系统的资源消耗会随着请求流量的增加而增加,例如计算密集型的服务,随着请求量的增加,CPU 的使用率会逐渐升高。使用预热型 RateLimiter 可以让系统在处理请求的过程中,逐渐适应资源的消耗,避免因资源突然耗尽而导致系统崩溃。
import com.google.common.util.concurrent.RateLimiter;
import java.util.concurrent.TimeUnit;
public class WarmingUpRateLimiterExample {
public static void main(String[] args) {
// 创建一个每秒允许 5 个请求,预热时间为 5 秒的预热型 RateLimiter
RateLimiter rateLimiter = RateLimiter.create(5.0, 5, TimeUnit.SECONDS);
for (int i = 0; i < 10; i++) {
// 获取一个令牌
double waitTime = rateLimiter.acquire();
System.out.println("第 " + i + " 次请求,等待时间:" + waitTime + " 秒");
}
}
}
5.1.1.3 选型建议
- 如果业务场景允许短时间内的流量突发,且系统有能力处理这些突发流量,同时对请求的响应时间要求较高,那么选择突发型 RateLimiter。
- 如果系统在启动阶段需要一定的时间来初始化资源或者加载数据,或者系统的资源消耗会随着请求流量的增加而增加,那么选择预热型 RateLimiter。
6. 熔断 + 降级:系统运行期间的故障转移
- 熔断和降级通常是协同工作的。当服务调用失败触发熔断后,后续的请求会立即执行降级操作,返回一个稳定的结果,这样可以避免用户长时间等待以及故障的进一步扩散。同时,熔断机制为降级提供了触发条件,两者相互配合,共同保障了系统的稳定性和可用性。
6.1 熔断
6.1.1 断路器
- 定义:熔断机制借鉴了电路中保险丝的原理。在分布式系统里,当某个服务的调用失败率(如超时、异常等情况)达到预先设定的阈值时,就会触发熔断。此时,后续对该服务的请求不再实际执行调用,而是快速返回一个预设的默认值或错误提示,这样可以防止因故障服务的持续调用导致资源耗尽,进而避免故障在系统中扩散。
- 状态及转换:
- 关闭状态(Closed):正常情况下,服务处于关闭状态,所有请求都能正常调用目标服务。系统会持续监控服务调用的失败率,一旦失败率超过设定的阈值,就会切换到打开状态。
- 打开状态(Open):服务熔断后进入打开状态,在这个状态下,所有对该服务的请求都会立即返回,不会进行实际的调用操作。打开状态会维持一段时间,即熔断超时时间,例如10秒。在此期间,即使有新的请求到来,也不会尝试调用服务,以此避免故障服务被过度请求。
- 半开状态(Half - Open):经过熔断超时时间后,系统会进入半开状态。在半开状态下,会允许少量请求去尝试调用服务。如果这些请求调用成功,说明服务可能已经恢复正常,系统会切换回关闭状态;如果请求再次失败,就表明服务尚未恢复,系统会重新回到打开状态,并且可能会适当延长熔断超时时间。
- 实现方式:在Java中,常见的实现熔断的工具包括Hystrix、Sentinel等。以Hystrix为例,开发人员可以通过在方法上添加
@HystrixCommand
注解,并配置fallbackMethod
属性来指定降级方法,从而实现熔断功能。
6.1.2 容错模式
- 超时:为设置一个超时时间,超时就直接返回,不会造成服务堆积
- 限流:限制最大并发数,单机or集群
- 熔断:当错误数超过阈值时快速失败
- 隔离:把每个依赖或调用的服务都隔离开来,防止级联失败
- 降级:服务失败或异常后,返回指定的默认信息
6.2 降级
6.2.1 降级场景
- 定义:降级机制是指当系统出现资源不足(如CPU使用率过高、内存不足)、服务负载过高或者依赖服务故障等情况时,为了保证核心业务能够正常运行,主动降低某些非核心功能或服务的性能和质量,甚至暂时关闭这些功能。与熔断不同,熔断主要针对外部依赖服务的故障,而降级更侧重于系统自身的资源保护和业务调整。
- 场景:
- 服务依赖故障:当所依赖的第三方服务出现故障时,例如支付接口不可用,此时可以将支付功能降级为提示“稍后重试”,避免因为支付失败而导致整个订单流程中断。
- 系统资源不足:在高并发场景下,当系统的CPU、内存等资源接近耗尽时,可以对一些非实时性的功能进行降级,比如暂时关闭用户行为分析统计功能,以减少资源的消耗。
- 流量突发:在电商大促等流量高峰时期,为了确保核心的商品展示、下单等功能能够正常运行,可以将商品评论展示、用户积分计算等功能降级。比如简化评论展示的样式,延迟积分计算等。
- 实现方式:
- 代码层面:可以在业务代码中添加条件判断逻辑,当满足降级条件时,执行相应的降级代码。
- 框架层面:借助Sentinel、Hystrix等框架,通过配置规则来实现自动降级。以Sentinel为例,可以配置流量控制规则,当接口的调用量超过阈值时,自动执行降级操作,返回预设的降级结果。
6.2.2 降级方式
- 延迟操作:将用户的多步操作隔离后,部分逻辑在低峰期慢慢处理
- 直接关闭指定服务:比如关闭推荐能力、评价能力等
- 异步请求降级:对于页面上异步加载的请求,直接处理隐藏
- 页面跳转:跳转总会唱,默认兜底页
- 写降级:库存扣减,先用缓存抗压,再mq写数据库,保证最终一致性
- 读降级:只读缓存数据,用缓存历史数据兜底