Spring Cloud Alibaba 在高并发场景下的生产实战手记

CI掉线了
2026-01-03 17:16
阅读 663

去年夏天,我还在用 Vim 调 Swift 代码,为 iOS App 的启动速度优化到 0.1 秒而沾沾自喜。没想到转眼就被老板“亲切关怀”:“老张啊,咱们后端微服务要重构,你不是架构感不错吗?来带一带。”

我当时心里一万只羊驼奔腾——我是写 iOS 的!连 Java 的泛型擦除都还没搞明白呢!但架不住领导画的大饼香,再加上跳槽市场寒冬,只能硬着头皮接下这个“跨端”任务。

结果一查项目需求:基于 Spring Cloud Alibaba 构建高可用、高并发的订单中心,支撑大促峰值 5w QPS。好家伙,这哪是重构,这是直接上战场啊。


为什么选 Spring Cloud Alibaba?

我们组之前用的是纯 Spring Cloud Netflix(Eureka + Hystrix + Zuul),但自从 Netflix 宣布停止维护,加上团队里运维同学天天抱怨 Eureka 注册中心在节点扩容时同步慢得像蜗牛,早就想换了。

调研一圈,Spring Cloud Alibaba(SCA)成了最务实的选择:

  • Nacos:服务发现 + 配置中心一体化,支持 AP/CP 切换,比 Eureka 灵活太多
  • Sentinel:流量控制、熔断降级比 Hystrix 更细粒度,还带实时监控大盘
  • Seata:分布式事务方案,虽然我们暂时没用上,但架构上得预留
  • Dubbo 兼容性:公司历史系统多是 Dubbo,SCA 原生支持,迁移成本低

最关键的是——阿里系组件在国内有真实超大流量验证。双11扛得住,我们这点量算啥?


实战第一坑:Nacos 注册中心脑裂

项目刚上线测试环境,就遇到诡异问题:服务列表一会儿有 A 节点,一会儿没了,客户端疯狂报 No instance available

翻日志发现 Nacos 集群三个节点之间心跳同步异常。后来才知道,我们部署在 Kubernetes 上,Nacos 默认走内网 IP 注册,但 Pod IP 是动态的,一旦滚动更新,旧 IP 就成僵尸节点,集群状态不一致。

解决方案:强制使用固定域名 + hostNetwork 模式(或者用 StatefulSet + Headless Service)。配置如下:

# nacos-peer-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: nacos-headless
spec:
  clusterIP: None
  ports:
    - port: 8848
      name: server
  selector:
    app: nacos
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: nacos
spec:
  serviceName: nacos-headless
  replicas: 3
  template:
    spec:
      containers:
        - name: nacos
          env:
            - name: NACOS_SERVERS
              value: "nacos-0.nacos-headless:8848 nacos-1.nacos-headless:8848 nacos-2.nacos-headless:8848"

另外,在客户端 application.yml 中显式指定:

spring:
  cloud:
    nacos:
      discovery:
        ip: ${HOST_IP}  # 通过 Downward API 注入宿主机 IP
        port: ${SERVER_PORT}

上线后注册稳定性提升 100%,再也不用半夜被 PagerDuty 叫醒。


Sentinel 流控:从“一刀切”到精细化治理

最初我们对所有接口统一限流:QPS > 1000 就熔断。结果大促预演时,支付回调接口因为第三方响应慢,触发了全局流控,导致用户下单成功但订单状态卡住——差点背锅。

痛定思痛,我们重新设计了 Sentinel 规则体系:

接口类型 限流策略 熔断条件
创建订单 QPS 限流 2000,排队等待 500ms 异常比例 > 30% 持续 10s
查询订单详情 线程数限流 50 RT > 800ms 持续 5s
支付回调 关联资源流控(依赖支付网关) 慢调用比例 > 50%

关键代码(配合注解 + 动态规则加载):

@SentinelResource(
    value = "createOrder",
    blockHandler = "handleCreateOrderBlock",
    fallback = "fallbackCreateOrder"
)
public Order createOrder(OrderRequest req) {
    // 业务逻辑
}

// 动态加载规则(从 Nacos 配置中心拉取)
@PostConstruct
public void initFlowRules() {
    ReadableDataSource<String, List<FlowRule>> flowRuleDataSource = 
        new NacosDataSource<>("localhost", "SENTINEL_GROUP", "flow-rules", source -> JSON.parseObject(source, new TypeReference<List<FlowRule>>() {}));
    FlowRuleManager.register2Property(flowRuleDataSource.getProperty());
}

现在,即使支付网关抖动,也只是回调接口降级,不影响主链路下单。运维同学终于敢在群里发“系统稳如老狗”了。


配置中心:别再把密码写死在 Git 里!

早期开发为了快,数据库密码、Redis 密钥全写在 application-prod.yml 里,然后 commit 到 GitLab。某天安全扫描告警直接飙红,CTO 亲自下场问责。

我们立刻用 Nacos 配置中心接管所有敏感信息,并结合 KMS 加密:

  1. 所有配置按环境隔离(dev/test/prod)
  2. 敏感字段用 AES 加密,密钥由 KMS 管理
  3. 应用启动时自动解密
# Nacos 配置 dataId: order-service-prod.yaml
spring:
  datasource:
    url: jdbc:mysql://xxx
    username: root
    password: ENC(AesEncryptedStringHere)  # 标记为加密字段

应用端加一个 @ConfigurationProperties 解密器:

@ConfigurationProperties(prefix = "spring.datasource")
public class DataSourceProperties {
    private String password;

    public void setPassword(String password) {
        if (password.startsWith("ENC(")) {
            this.password = KmsClient.decrypt(password.substring(4, password.length() - 1));
        } else {
            this.password = password; // dev 环境可明文
        }
    }
}

从此告别“删库跑路”风险(至少不会因为密码泄露)。


Go?没错,我们在 Java 项目里混进了 Go!

别误会,不是重写后端。而是——用 Go 写了个轻量级 Sidecar,解决 Java 进程内存占用高的问题。

我们的订单服务单实例堆内存 2GB,K8s 资源配额吃紧。但有些边缘功能(比如异步日志上报、指标采集)其实不需要 JVM。

于是,我用周末两天撸了个 Go 程序:

  • 监听本地 8081 端口,接收 Java 应用发来的 metrics
  • 聚合后批量推送到 Prometheus
  • 同时监听 Nacos 配置变更,动态调整采样率

部署方式:同一个 Pod 里跑两个容器(Java 主容器 + Go Sidecar),共享 localhost 网络。

# Dockerfile-go-sidecar
FROM golang:1.21-alpine
WORKDIR /app
COPY . .
RUN go build -o sidecar .
CMD ["./sidecar"]

效果:主 Java 进程内存下降 15%,GC 压力明显减轻。而且 Go 二进制只有 10MB,启动快如闪电。

产品经理看到架构图里冒出个 Go,一脸懵:“这不是后端语言吗?”
我:“现在谁还分前后端啊,能跑就行。”


数据库设计:别让 MySQL 成为瓶颈

高并发下,DB 往往最先扛不住。我们的教训:

  • 不要迷信 ORM:MyBatis 虽好,但 SELECT * 在大表上就是自杀
  • 分库分表要早做:订单 ID 用 Snowflake 算法生成,天然支持分片
  • 读写分离必须配:主库写,从库读,但要注意主从延迟

我们最终采用 ShardingSphere-JDBC(SCA 生态兼容),配置如下:

spring:
  shardingsphere:
    datasource:
      names: ds0,ds1
      ds0:
        url: jdbc:mysql://master-db0
      ds1:
        url: jdbc:mysql://slave-db0
    rules:
      readwrite-splitting:
        data-sources:
          rw_ds:
            write-data-source-name: ds0
            read-data-source-names: ds1
      sharding:
        tables:
          t_order:
            actual-data-nodes: ds$->{0..1}.t_order_$->{0..3}
            table-strategy:
              standard:
                sharding-column: order_id
                sharding-algorithm-name: snowflake-mod

压测显示,QPS 从 3000 提升到 12000+,TP99 从 400ms 降到 90ms。


线上事故复盘:一次“优雅”的雪崩

上周五大促前夜,系统突然全线超时。排查发现:某个下游服务响应变慢 → Sentinel 触发熔断 → 订单服务线程池满 → Tomcat 连接耗尽 → 网关 502。

表面看是下游问题,根因却是线程池配置不合理。默认 Tomcat 最大线程 200,但我们的 Feign 调用都是同步阻塞,一个慢请求占一个线程,200 个慢请求就把服务打挂了。

改进措施

  1. Feign + Resilience4j 改为异步非阻塞(反应式编程)
  2. Tomcat 线程池拆分为:核心业务线程池(高优先级) + 非核心线程池(低优先级)
  3. 网关层增加“快速失败”策略,避免请求堆积

代码示例(WebFlux + Reactor):

@GetMapping("/order/{id}")
public Mono<Order> getOrder(@PathVariable Long id) {
    return orderService.fetchFromCache(id)
        .switchIfEmpty(Mono.defer(() -> orderService.fetchFromDb(id)))
        .timeout(Duration.ofMillis(300))
        .onErrorReturn(FALLBACK_ORDER);
}

现在即使下游挂了,前端也能在 300ms 内收到兜底数据,用户体验不崩。


写在最后:从 iOS 到 SCA,我的跨界感悟

干了两年后端,我反而更理解移动端的设计哲学了——稳定压倒一切。iOS 上一个 ANR 可能丢一个用户,后端一个 5xx 可能让公司损失百万。

Spring Cloud Alibaba 不是银弹,但它提供了一套经过实战检验的“脚手架”。关键在于:你要知道每个组件的边界在哪里,什么时候该自己造轮子

比如 Sentinel 很强,但如果你的限流维度特别复杂(比如按用户等级 + 商品类目 + 地域组合限流),可能就得自己写 SlotChain。

又比如 Nacos 配置中心方便,但热更新时如何保证配置一致性?我们加了版本号校验 + 回滚机制,才敢在线上用。

所以,别迷信框架。架构的本质,是在约束中寻找最优解

对了,我现在还是 Vim 党。不过 .vimrc 里多了不少 Java 插件。有时候看着满屏的 {},还是会怀念 Swift 的简洁……

但没办法,成年人的世界,就是一边吐槽,一边把活干漂亮。

本文所有配置和代码均来自真实项目(脱敏处理),已在生产环境稳定运行 6 个月+,支撑过 3 次大促,最高单日订单量 280w+。
如有雷同,纯属同行。

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝