日均几十万次 LLM 调用、超时限流内容过滤服务端 500 轮番上阵——任何一个不稳定环节,都可能让用户”作业卡住”。一开始我们觉得”加个重试就好了”,结果线上连着出了几次事故才明白,稳定性不是单点修补,而是一整套分层防御体系。我们最终从基础设施、应用、容错、灾备四个层次做了系统化的稳定性设计,每一层解决一类故障,层与层之间互相兜底。这篇文章把整套稳定性方案拆开讲,重点是每个设计背后的取舍和踩过的坑。
一、稳定性指标
稳定性设计之前,先把目标定清楚。没有指标,一切稳定性工作都无从量化,团队也不知道该把力气花在哪。我们定了四个核心 SLA,每个季度复盘一次:
| 指标 | 目标 | 当前 |
|---|---|---|
| 可用性 | 99.9% | 99.95% |
| P99 响应时间 | < 2s | 1.5s |
| 错误率 | < 0.5% | 0.2% |
| MTTR(平均恢复时间) | < 30min | 18min |
这四张表每个季度复盘一次。比如 P99 从目标值到当前值的差距,反映的是链路里偏慢的那 1% 请求,优化它们比优化平均值更能提升用户体验。MTTR 数字好看的背后,是灾备演练和回滚流程在起作用,单纯盯着指标调优是学不来的。指标的意义不只是给自己看,也是跟用户讲清楚”我们稳定性到什么水平”的依据。
二、基础设施层
基础设施是稳定性的地基。评测平台没有传统电商那么大的并发量,但对连续性要求极高——批量任务跑到一半基础设施挂了,前面几十分钟的工作就白费了。
1. 多实例部署
平台 4 个应用实例 + 2 个前端 CDN:
负载均衡 (Nginx)
├── App-1
├── App-2
├── App-3
└── App-4
共享 MySQL 主从 + Redis Cluster + RabbitMQ 集群
任一实例挂掉不影响整体。
多实例不只是”多一份保险”,它同时承担了发布时的灰度能力。新版本先发一个实例观察,异常时负载均衡自动摘除,用户无感。我们实践中发现,多实例真正起作用的前提是应用必须无状态,Session、本地缓存这类东西全部外置,否则实例多了反而添乱,多挂一台就多一个不一致的副本。
2. 数据库高可用
- MySQL 1 主 2 从,主写从读;
- 故障自动切换(MHA);
- 慢查询自动告警;
- 定期主从一致性校验。
数据库高可用里最容易被忽视的是”从库读延迟”。评测结果的实时查询如果走了延迟的从库,用户会看到不一致的数据,前后两次结果对不上。我们给关键查询加了强制主库的开关,只牺牲一点点读性能,换来的是一致性。主从一致性校验也是手工脚本定期跑,曾经抓到过主从 binlog 位置漂移的问题,这种隐患不跑校验根本发现不了。
3. Redis Cluster
- 6 节点(3 主 3 从);
- 单 key 跨 slot 访问用
{}哈希标签; - 内存使用率监控,> 70% 告警。
Redis 集群踩过最深的坑是”大 key”。评测会话的 JSON 动辄上 MB,删除时阻塞了集群 5 秒,线上卡顿一整片。后来我们把规范定成单 key 不超过 100KB,超长的数据拆成 hash 分片。内存 70% 告警线也不是拍脑袋定的,是因为 70% 时留出的扩容窗口刚好够一次故障处理的时间,这个数字是我们用一次扩容事故换出来的。
三、应用层容错
基础设施保障”机器不挂”,应用层容错保障”业务不断”。这一层处理的是单次请求级别的异常,也是最需要细心打磨的一层。
1. 多级超时
每个调用场景的超时都不一样,评测、流式、后台批量的等待容忍度完全不同,我们按场景拆开配置:
| 场景 | 超时 |
|---|---|
| Prompt Lab 单次 | 12s |
| SxS 评测 | 30s |
| 评分员 | 20s |
| 后台批量 | 150s |
超时配置的经验是:宁可设短,不可设长。后台批量任务我们一开始给的是 300 秒,结果一个卡住的 LLM 调用能把队列阻塞几分钟,后面的任务全部排队。后来压到 150 秒并配合重试,队列健康度明显好转。每个超时值都要能对应到”用户能接受的最长等待”,而不是”理论上应该多久”,后者会害死人。
2. 异常分类
不是所有异常都值得重试,重试策略按异常类型分开,否则重试反而放大故障:
| 类型 | 重试 | 降级 |
|---|---|---|
| TIMEOUT | 是,2-3 次 | 切备选模型 |
| RATE_LIMIT | 是,5-45s | 等待 |
| AUTH_ERROR | 否 | 立即告警 |
| CONTENT_FILTER | 否 | 标记拒答 |
| SERVER_ERROR | 是,2 次 | 切备选 |
这张表的判断逻辑是”重试会不会让情况更糟”。AUTHERROR 重试只会浪费请求,还可能是密钥被误删,必须立刻告警。RATELIMIT 重试间隔要按响应里的 Retry-After 走,盲目重试会把限流打得更死。异常分类表是我们排查故障的第一张地图,每次新异常进来先归类再决定策略,而不是临时拍脑袋。
3. 熔断器
熔断是”下游故障时主动断开”的保护机制,平台用 Resilience4j:
@CircuitBreaker(name = "llm-call",
fallbackMethod = "fallback",
slidingWindowSize = 20,
failureRateThreshold = 50)
public ChatResult callLlm(ChatRequest req) {
return llmClient.call(req);
}
public ChatResult fallback(ChatRequest req, Throwable t) {
log.warn("熔断 fallback 触发: {}", t.getMessage());
return ChatResult.fallback("服务暂时不可用,请稍后重试");
}
熔断后 30s 内所有请求直接走 fallback,避免雪崩。
熔断器我们调过好几轮参数,教训是”阈值不能太敏感”。最开始 failureRateThreshold 设 30,一个模型偶尔波动就把整条链路熔断了,误伤严重,正常流量也被拒绝。后来放宽到 50,并给每个 provider 单独建熔断器,这样单一模型故障不会拖累所有评测,别的模型照常服务。
4. 限流保护
限流保护的是”我们自己不被打穿”,从 IP、用户、接口、全局四个维度层层收紧:
- IP 维度:100 次/分钟;
- 用户维度:60 次/分钟;
- 接口维度(评测提交):5 次/分钟;
- 全局:10000 次/秒。
限流响应携带 Retry-After 头告诉前端重试时间。
限流的维度选择很关键。只按 IP 限流会被 NAT 和代理绕过,必须加上用户维度;评测提交这种重操作单独压到 5 次/分钟,是为了防止用户误操作重复提交烧钱。全局限流是最后一道防线,防止某个活动把整个平台打挂,全局值我们盯了半年才定到 10000 次/秒,太高没意义,太低误伤正常业务。
四、LLM 调用容错
LLM 调用是平台最特殊的外部依赖,它的故障模式比普通 HTTP 服务更丰富,单独讲一层。普通 API 挂了就是挂了,LLM 供应商的故障往往是一段时间内间歇性的,需要更细的容错手段。
1. 备选模型链
主模型失败自动切备选模型,是 LLM 容错的核心手段:
public ChatResult callWithFallback(ChatRequest req) {
List<String> models = List.of(
req.getModelCode(),
req.getFallback1(),
req.getFallback2()
).stream().filter(Objects::nonNull).toList();
for (String m : models) {
try {
ChatResult r = callWithRetry(m, req);
if (r != null) return r;
} catch (Exception e) {
log.warn("模型 {} 失败: {}", m, e.getMessage());
}
}
throw new BusinessException("所有模型都失败了");
}
备选模型链有个隐藏问题:不同模型输出质量有差异,用户可能明显感觉到”这次回复不如上次”。我们允许用户在配置里选择备选模型,而不是系统强推,把选择权交给业务方。另外切模型后要记录在日志里,方便事后分析是不是主模型出了长期故障,该换主备关系就换,别让备选顶太久。
2. 断路 + 重试
重试要加抖动(jitter),避免所有请求在同一时刻重试形成雷群,这一点我们在大故障时亲身体会过:
RetryConfig cfg = RetryConfig.custom()
.maxAttempts(3)
.intervalFunction(IntervalFunction.ofExponentialBackoff(
Duration.ofSeconds(2), 2.0, 0.3)) // jitter
.retryOnException(t -> isRetryable(t))
.build();
指数退避 + jitter 的组合,把重试风暴的概率降得很低。我们的实际经验是:固定间隔重试在大规模故障时等于加速自我雪崩,指数退避让流量逐渐恢复,比固定重试稳定得多。重试前还要判断 isRetryable,不是所有异常都值得重试,这一点和前面的异常分类表是一套逻辑。
3. Token 预扣与回滚
计费必须和调用成败挂钩,预扣 + 结算 + 回滚三段式,少一环就会出乱账:
// 调用前预扣
boolean ok = budgetService.preCheck(userId, estimatedCost);
if (!ok) throw new BusinessException("预算不足");
ChatResult res;
try {
res = llmClient.call(req);
// 成功后按实际扣
budgetService.settle(userId, actualCost);
} catch (Exception e) {
// 失败回滚预算
budgetService.rollback(userId, estimatedCost);
throw e;
}
这套设计最容易被漏掉的环节是回滚。调用失败时如果不回滚,用户很快会发现”失败的任务还在扣钱”,信任直接崩。我们线上有过一次回滚逻辑漏写,导致重复扣费,后来补了每天的对账任务,把预扣和结算对不上的记录自动拉出来。容错不只是让调用成功,失败时的账也要算清楚。
五、数据层容错
LLM 层稳了,数据层的问题还没完。评测平台的数据有强一致性场景,也有最终一致性场景,处理方式完全不同,混用会两头不讨好。
1. 事务回滚
批量任务的创建必须原子,要么全成要么全败:
@Transactional
public Long createBatch(BatchCreateRequest req) {
Long batchId = batchDao.insert(batch);
for (String model : req.getModels()) {
Long taskId = taskDao.insert(task);
for (Long caseId : req.getCaseIds()) {
subtaskDao.insert(subtask);
mqSender.send(subtaskId);
}
}
return batchId;
}
任一失败全部回滚,避免脏数据。
这里有个坑:mqSender.send 放在事务里,如果事务回滚但消息已经发出,就会产生”消息有、数据无”的孤儿任务,消费者拿到一个不存在的任务 ID。我们后来把发消息改成事务提交后的 TransactionSynchronization 回调,保证消息只在事务成功后才发出,这个坑我们修了很久才想明白根因。
2. 幂等设计
评测提交是高频重复操作,用户可能双击、网络重试,后端必须幂等,否则会重复创建会话、重复扣费:
@Idempotent(key = "#req.userId + '_' + #req.question",
expire = 60)
public Long createSession(EvalCreateRequest req) {
// ...
}
@Idempotent 注解 + Redis SETNX,60s 内相同请求返回缓存结果。
幂等键的选择要小心:太宽会误拦截真实请求,用户发两条相同内容的问题就被吞了;太窄起不到去重作用。我们用”用户 ID + 问题内容”做键,再叠加 60 秒过期,既能拦双击,又不会长期锁住相同内容的合法请求。上线后重复会话的投诉直接归零。
3. 最终一致性
MQ 消费失败不能丢,要有重试和死信兜底:
@RabbitListener(queues = "eval.subtask")
public void process(SubtaskMessage msg) {
try {
processInternal(msg);
} catch (Exception e) {
if (msg.getRetryCount() < 3) {
mqSender.sendWithDelay(msg.getId(),
msg.getRetryCount() + 1,
Duration.ofSeconds(5 * msg.getRetryCount()));
} else {
mqSender.sendToDeadLetter(msg);
}
}
}
重试三次后进死信队列,配合人工/脚本兜底处理。死信队列不是终点,必须有人看。我们给死信队列挂了告警,超过阈值就通知值班人员,防止”消息死在队列里无人知晓”。有一次我们没看死信,积压了一晚上的失败任务,用户第二天起来才发现评测没出结果,这个教训提醒我们:最终一致性要靠监控闭环,不能只靠队列机制。
六、WebSocket 稳定性
实时进度推送依赖 WebSocket,它的稳定性问题主要是连接保活和断线重连。这块不做好,用户看到的进度条就是坏的。
1. 心跳保活
服务端定期发 ping,维持连接不被代理切断:
// 服务端每 30s 发 ping
@Override
protected void handlePing(WebSocketSession session, PingMessage message) {
session.sendMessage(new PongMessage());
}
心跳保活踩过的坑是 Nginx 的 proxy_read_timeout。默认 60 秒,心跳间隔必须小于它,否则连接会被静默断开,客户端还浑然不觉,以为连着呢。我们把心跳设为 30 秒,Nginx 超时调大到 3600 秒,双向都留足余量。这个坑是用户反馈”进度不动了”之后排查出来的,属于典型的配置层问题。
2. 断线重连
前端 3s 自动重连,重连时立即发 SNAPSHOT 同步当前状态。
只重连不恢复状态等于白连。我们给每个批量任务维护服务端快照,客户端重连后先拉 SNAPSHOT 再续增量,进度不会从 0 重新开始。这是从一次”断线后进度清零”的线上事故中学到的,当时用户重连后看到进度归零,直接打电话投诉,我们连夜加了快照同步。
3. 跨实例推送
// Redis Pub/Sub
public void broadcast(long batchId, String msg) {
localPush(batchId, msg);
redis.convertAndSend("ws:batch:" + batchId, msg);
}
跨实例推送的补充说明和前面评测平台文章里一致:Pub/Sub 无持久化,进度推送丢了靠 SNAPSHOT 兜底,两者配合。如果消息要求一条不丢,就要考虑换成 Stream 或消息队列,按业务容忍度决定。我们把进度推送定位为”可丢失但可恢复”,用快照兜底,成本最低。
七、监控告警
监控是容错设计的地基,没有监控,前面所有容错机制坏了都没人知道。再完善的容错,看不见就等于不存在。
1. 业务指标
metrics.counter("eval.session.create", "model", "gpt-4o").increment();
metrics.histogram("eval.session.duration").record(durationMs);
metrics.gauge("eval.session.running", runningCount);
业务指标要和告警规则配套。我们早期只采了系统指标,业务指标是后补的,结果有一次”评测提交量暴跌”没人发现,因为 CPU 内存都很健康。业务指标是系统的”业务心电图”,比系统指标更能反映问题,系统指标告诉你机器累不累,业务指标告诉你业务还活着没有。
2. 系统指标
- CPU / 内存 / 磁盘 / 网络(Node Exporter);
- JVM GC 次数 + 暂停时间;
- MySQL QPS / 慢查询 / 连接数;
- Redis 命中率 / 内存 / 慢命令。
系统指标里最容易忽略的是 JVM GC。评测平台做大量字符串拼接和 JSON 解析,GC 压力大,我们有一次 Full GC 导致 P99 飙升,用户明显感觉变卡,后来专门加了 GC 监控,并在大对象处理上做了优化,把频繁的 Full GC 压下去了。系统指标不是越多越好,而是要覆盖你的技术栈里最容易出问题的位置。
3. 告警规则
# Prometheus AlertManager
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05
for: 2m
annotations:
summary: 错误率超过 5%
- alert: LlmApiSlowDown
expr: histogram_quantile(0.99, rate(llm_call_duration_seconds_bucket[5m])) > 5
for: 5m
annotations:
summary: LLM 调用 P99 超过 5s
飞书/钉钉 webhook 告警到值班人。
告警规则最怕误报和漏报两极化。我们用了 for: 2m 的持续时间条件,抖动不触发,持续异常才告警,值班人不会被噪音淹没。告警文案里直接带摘要信息,值班人收到后不用登录平台就能判断严重性,这个细节大大缩短了响应时间,MTTR 能到 18 分钟和这个习惯有关系。
八、灾备演练
稳定性不是配出来的,是练出来的。文档里写的切换步骤,不演练永远不知道哪里会卡住。平台每月做一次灾备演练,四类故障轮流演练:
1. 数据库主挂
演练步骤:
1. kill 主库
2. 观察 MHA 切换
3. 验证从库接管写
4. 验证应用无感知
5. 恢复主库
2. Redis 挂
1. kill Redis 主节点
2. 验证哨兵切换
3. 验证应用降级到本地缓存
4. 验证缓存重建
3. 应用实例挂
1. kill 任意实例
2. 验证负载均衡摘除
3. 验证用户无感知(WebSocket 客户端自动重连)
4. LLM Provider 故障
1. 切到 mock provider 模拟 500
2. 验证熔断器触发
3. 验证备选模型切换
4. 验证 fallback 提示用户
演练的意义在于”平时发现问题,而不是故障时发现问题”。第一次演练我们就发现数据库切换后应用连接池不自动重建,需要重启,当场补了连接池探活。这些坑如果等真实故障才暴露,代价就是一次线上事故。现在演练已经成为月度例行,每次都有新发现,稳定性就是这么一点点磨出来的。
九、变更管理
容错设计再完善,变更引入的故障依然是最常见的。评测平台把变更管理当作稳定性的一部分,因为统计下来,我们线上大半的事故都跟发布相关。
1. 灰度发布
新版本先发布 1 个实例(25% 流量),观察 30 分钟无异常再全量:
# Kubernetes Canary
apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
strategy:
canary:
steps:
- setWeight: 25
- pause: { duration: 30m }
- setWeight: 100
灰度发布的关键是”观察什么”。我们定义了几条发布观察指标:错误率、P99、关键业务指标(如评测提交成功率)。只观察 CPU 内存远远不够,有一次新版本把评分结果算错了,系统指标全绿,业务指标一查就露馅,被灰度拦截下来,没扩散到全量。灰度不是走形式,是让问题暴露在小范围里。
2. 数据库变更
- 加字段、加索引走 Online DDL(pt-online-schema-change);
- 不在业务高峰改库;
- 改完立即做一致性校验。
数据库变更是最容易引发线上故障的变更类型。我们定下的铁律是”加字段不锁表、改库不赶高峰、改完必校验”。有一次直接在业务高峰加索引,线上卡顿五分钟,从此排期里新增了”变更窗口”这个概念。数据变更的每一步都要可回退,这是比功能发布更高的标准。
3. 紧急回滚
每个 release branch 保留 deployable artifact,发现问题 5 分钟内回滚。
回滚要能”一键”。我们把每个版本的镜像和配置做成不可变 artifact,回滚就是切换版本号,而不是重新部署,避免回滚过程本身出错。演练时实测过,从发现异常到全量回滚,五分钟内可以完成。回滚能力是 MTTR 达标的核心支撑,没有这个,出问题就只能现场修,时间完全不可控。
十、踩过的坑
稳定性设计没有一次到位,下面是几个用事故换来的教训,写出来给大家避雷:
- 熔断器雪崩:单个 LLM provider 故障触发熔断,但 fallback 把所有请求挤到另一个 provider 又打挂。熔断器要分级(按 provider 独立计数)。
- Redis 大 key DEL 阻塞:1MB 的 session JSON 删除阻塞 5s。规范单 key < 100KB。
- MySQL 死锁:批量更新没排序。统一按主键顺序。
- WS 心跳未生效:Nginx
proxy_read_timeout60s 断开 WS。设成 3600s。 - 熔断恢复期请求失败:熔断 half-open 状态直接放行没限制。要限制 half-open 状态的并发数。
这五个坑有一个共同点:都不是”配置错了”那么简单,而是机制之间的相互作用。比如熔断雪崩是”熔断机制”和”备选模型链”叠加产生的,单独看每一层都合理,合起来就出问题。所以每加一层容错,都要想一想它和已有机制怎么共存,不能只盯着单层优化。
十一、最佳实践清单
把整套稳定性方案收敛成七条可执行清单,新模块上线前逐条打勾:
- 所有外部调用都有超时;
- 所有写操作都有重试 + 幂等;
- 所有读操作都有缓存 + 降级;
- 所有任务都有死信队列;
- 所有错误都有监控告警;
- 所有发布都有灰度回滚;
- 所有 DB 变更都有备份。
这七条看起来简单,执行起来靠流程而不是靠自觉。我们把清单做成上线 checklist,纳入发布流程,任何一条不满足就打回。稳定性最终是靠制度和习惯保证的,而不是靠某个人的责任心,一个人再细心也会漏,流程不会。
常见问题(FAQ)
Q1:99.9% 和 99.99% 差多少?
99.9% = 月宕机 43min,99.99% = 月宕机 4min。差 10 倍成本。
Q2:熔断和降级的区别?
熔断是”下游有问题”主动断开;降级是”主动放弃非核心功能”保核心。
Q3:需要做混沌工程吗?
需要,但小团队可以从”故障演练”开始,逐步引入 chaos-mesh。