ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零搭建分布式微服务架构:Spring Cloud Alibaba实战与踩坑记录

从零搭建分布式微服务架构:Spring Cloud Alibaba实战与踩坑记录

1. 为什么我要从零搭一套分布式微服务架构

去年年底,我接手了一个内部工具平台的重构任务。原来的系统是一个典型的单体应用,所有功能模块——用户管理、订单处理、库存计算、消息通知——全部塞在一个代码仓库里,部署时打成一个包扔到一台机器上跑。刚开始用户量小的时候没什么问题,但后来日活慢慢涨到几千,问题就集中爆发了:改一个通知模板的文案,要重新部署整个应用;库存模块的一个内存泄漏,把订单服务也拖垮了;每次发版都像拆炸弹,谁也不敢保证不出事。

于是我决定动手把它拆成分布式微服务架构。这篇文章就是记录整个搭建过程——从服务拆分、通信选型、服务注册发现、配置中心、网关路由,到链路追踪和容器化部署,我会把每一步的决策逻辑、踩过的坑、以及最终跑通的方案完整写出来。如果你也正在面对单体应用的维护困境,或者想了解微服务架构到底怎么落地,这篇内容应该能帮你少走不少弯路。

先说一下最终的技术栈选型,方便你有个全局印象:

层次选型核心理由
服务框架Spring Boot + Spring Cloud Alibaba生态成熟,中文文档丰富,社区活跃
服务注册与发现Nacos同时支持注册中心和配置中心,运维成本低
配置中心Nacos Config与注册中心复用同一套基础设施
API网关Spring Cloud Gateway响应式非阻塞,性能优于Zuul
服务调用OpenFeign + LoadBalancer声明式调用,代码侵入性小
熔断限流Sentinel控制台可视化,规则动态推送
链路追踪SkyWalking无侵入式探针,对业务代码零改造
消息队列RocketMQ事务消息支持好,适合订单场景
容器化Docker + Docker Compose初期规模不大,暂不上K8s

这套组合不是拍脑袋选的,每一个都有具体的考量,后面我会逐个拆解。

2. 服务拆分:怎么拆才不至于把自己拆死

2.1 拆分粒度的判断标准

服务拆分是微服务架构里最容易翻车的环节。我见过不少团队一上来就拆成十几个服务,结果光是服务间的调用链路就复杂到没人能理清楚。我的经验是:初期宁可拆粗一点,也不要拆太细。

具体怎么判断?我用了三个标准:

  • 业务边界是否清晰:用户管理和订单处理之间的边界很明确,但订单和库存之间就有大量交互,初期可以考虑放在一起。
  • 数据是否强关联:如果两个模块共享同一张核心表,强行拆开会导致分布式事务问题,不如先合着。
  • 团队规模是否匹配:一个人维护三四个服务已经是极限了,如果团队只有三五个人,拆成十几个服务就是自找麻烦。

最终我把原来的单体应用拆成了五个服务:

  1. 用户服务:负责注册、登录、权限校验、用户信息管理
  2. 订单服务:负责订单创建、状态流转、订单查询
  3. 库存服务:负责库存扣减、回滚、库存查询
  4. 通知服务:负责短信、邮件、站内信的发送
  5. 网关服务:负责路由转发、鉴权、限流

2.2 数据库拆分的实操细节

服务拆了,数据库也得跟着拆。原来一个库里有二十多张表,现在要按照服务边界分到不同的库里。这一步比拆代码麻烦得多,因为涉及到数据迁移和一致性问题。

我的做法是:

  • 每个服务独享一个数据库实例(初期可以用同一个MySQL实例的不同schema,但逻辑上必须隔离)
  • 跨服务的关联查询一律通过API调用解决,不允许跨库JOIN
  • 对于必须保证一致性的操作(比如订单创建时要扣库存),采用最终一致性方案:先本地事务写入订单,再通过RocketMQ发送扣减库存的消息,库存服务消费消息后执行扣减,如果扣减失败则发送回滚消息

注意:跨库JOIN是微服务架构里的大忌。一旦你允许跨库查询,服务边界就形同虚设,后面想独立部署和扩容都做不到。

2.3 共享代码的处理方式

拆分过程中有一个很现实的问题:原来很多工具类、常量定义、DTO对象是共享的,拆成独立服务后怎么办?

我的方案是抽出一个公共依赖包,包含:

  • 通用的工具类(日期处理、加密解密、JSON序列化)
  • 统一的返回体定义(Result、PageResult)
  • 服务间调用的DTO对象
  • 公共异常定义

这个包通过内部Maven仓库管理,各服务按需引入。但要注意控制这个包的膨胀速度——如果什么代码都往里塞,它很快就会变成一个“分布式单体”的粘合剂。

3. 服务注册发现与配置中心:Nacos的落地实践

3.1 为什么选Nacos而不是Eureka或Consul

选Nacos主要看中三点:

第一,一个组件解决两个问题。服务注册发现和配置管理它都包了,不用再单独部署一套Config Server。对于我这种人手有限的团队来说,少维护一个组件就少一份运维负担。

第二,支持AP和CP两种模式切换。默认是AP模式(可用性优先),适合服务发现场景;配置管理可以切到CP模式,保证配置一致性。Eureka只支持AP,Consul用Raft协议是CP,Nacos给了灵活选择的空间。

第三,控制台好用。服务列表、配置管理、健康状态都在一个界面里看得清清楚楚,排查问题很方便。

3.2 Nacos集群部署的关键配置

单机模式的Nacos只能用于开发环境,生产环境必须上集群。我用了三台机器做集群部署,每台配置2核4G。

核心配置项(application.properties):

# 数据库配置,Nacos集群必须用外部MySQL spring.datasource.platform=mysql db.num=1 db.url.0=jdbc:mysql://127.0.0.1:3306/nacos_config?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true db.user=nacos db.password=你的密码 # 集群节点配置 # cluster.conf文件中列出所有节点 # 192.168.1.101:8848 # 192.168.1.102:8848 # 192.168.1.103:8848

启动时有个坑要注意:三台机器的系统时间必须同步,否则Raft协议选举会出问题。我一开始没注意,导致集群一直选不出Leader,排查了半天才发现是时间偏差超过了阈值。

3.3 服务注册的代码配置

在Spring Boot项目中集成Nacos注册发现,依赖如下:

<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency>

配置文件里加上:

spring: application: name: order-service cloud: nacos: discovery: server-addr: 192.168.1.101:8848,192.168.1.102:8848,192.168.1.103:8848 namespace: prod group: DEFAULT_GROUP ephemeral: true

这里有几个参数值得说明:

  • namespace:用来隔离不同环境(开发、测试、生产),我建议每个环境一个namespace,避免服务互相串门。
  • ephemeral:临时实例还是持久化实例。临时实例通过心跳保活,适合无状态服务;持久化实例需要主动注销,适合有状态服务。大部分业务服务用临时实例就行。
  • group:可以用来做灰度发布,比如把新版本服务注册到GRAY_GROUP,网关按比例转发流量。

3.4 配置中心的动态刷新机制

Nacos配置中心最实用的功能是动态刷新——改了配置不用重启服务。实现方式有两种:

方式一:@RefreshScope注解

@RestController @RefreshScope public class ConfigController { @Value("${custom.config.value:default}") private String configValue; @GetMapping("/config") public String getConfig() { return configValue; } }

方式二:@ConfigurationProperties绑定

@Component @ConfigurationProperties(prefix = "custom.config") @RefreshScope public class CustomConfig { private String value; // getter/setter }

实操心得:@RefreshScope的原理是销毁Bean并重新创建,所以如果Bean里有状态(比如缓存),刷新后会丢失。对于有状态的Bean,建议用@ConfigurationProperties配合监听器手动处理刷新逻辑。

4. 网关路由与鉴权:Spring Cloud Gateway实战

4.1 网关的核心职责划分

网关不是简单的反向代理,它承担了五个核心职责:

  1. 路由转发:根据请求路径把流量分发到对应服务
  2. 统一鉴权:在网关层校验Token,避免每个服务都写一遍鉴权逻辑
  3. 限流熔断:保护后端服务不被突发流量打垮
  4. 日志记录:统一记录请求日志,方便排查问题
  5. 跨域处理:统一配置CORS,不用每个服务单独处理

4.2 路由配置的两种方式

方式一:配置文件方式

spring: cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=2 - id: order-service uri: lb://order-service predicates: - Path=/api/order/** filters: - StripPrefix=2

方式二:代码配置方式

@Configuration public class GatewayConfig { @Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route("user-service", r -> r .path("/api/user/**") .filters(f -> f.stripPrefix(2)) .uri("lb://user-service")) .route("order-service", r -> r .path("/api/order/**") .filters(f -> f.stripPrefix(2)) .uri("lb://order-service")) .build(); } }

我最终选了配置文件方式,因为改路由规则不用重新编译代码,配合Nacos配置中心还能动态刷新。

4.3 全局鉴权过滤器的实现

鉴权的核心逻辑是:从请求头里取Token,校验有效性,把用户信息塞到请求头里传给下游服务。

@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); // 白名单直接放行 if (isWhiteList(path)) { return chain.filter(exchange); } String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (token == null || token.isEmpty()) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 校验Token并解析用户信息 try { UserInfo userInfo = jwtUtil.parseToken(token); ServerHttpRequest request = exchange.getRequest().mutate() .header("X-User-Id", userInfo.getUserId()) .header("X-User-Name", userInfo.getUserName()) .build(); return chain.filter(exchange.mutate().request(request).build()); } catch (Exception e) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } } @Override public int getOrder() { return -100; // 优先级最高 } }

注意:网关层解析完用户信息后,通过请求头传给下游服务。下游服务不要再解析Token,直接信任网关注入的请求头即可。但前提是下游服务不能直接暴露给外部,必须只能通过网关访问。

4.4 限流策略的配置

Spring Cloud Gateway内置了RequestRateLimiter过滤器,基于Redis实现令牌桶限流:

spring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path=/api/order/** filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 100 redis-rate-limiter.burstCapacity: 200 key-resolver: "#{@ipKeyResolver}"

参数说明:

  • replenishRate:令牌桶每秒填充速率,即平均QPS上限
  • burstCapacity:令牌桶最大容量,即允许的突发流量
  • key-resolver:限流维度,可以按IP、按用户、按接口路径

我配置的是按IP限流,单IP平均100 QPS,突发不超过200 QPS。这个值是根据压测结果定的——后端服务在4核8G的机器上能扛住大约300 QPS,网关层留出余量。

5. 服务间调用与容错:OpenFeign + Sentinel组合拳

5.1 OpenFeign的声明式调用

服务间调用我用的是OpenFeign,代码写起来很简洁:

@FeignClient(name = "inventory-service", fallbackFactory = InventoryFallbackFactory.class) public interface InventoryClient { @PostMapping("/inventory/deduct") Result<Boolean> deduct(@RequestBody DeductRequest request); @PostMapping("/inventory/rollback") Result<Boolean> rollback(@RequestBody RollbackRequest request); }

调用方只需要注入这个接口,像调本地方法一样调远程服务。但有几个坑要注意:

坑一:超时时间默认太短。OpenFeign默认连接超时10秒,读取超时60秒,但实际业务中可能需要调整:

feign: client: config: default: connectTimeout: 5000 readTimeout: 10000

坑二:GET请求传对象要加@SpringQueryMap。如果GET请求的参数是一个对象,必须加这个注解,否则Feign会把它当成POST处理。

坑三:文件上传要引入feign-form依赖。否则文件类型参数无法正确编码。

5.2 Sentinel熔断降级的规则配置

Sentinel的核心概念是资源和规则。资源就是被保护的代码块或接口,规则定义了什么时候触发保护。

我配置了三类规则:

流控规则:限制QPS

FlowRule rule = new FlowRule(); rule.setResource("createOrder"); rule.setGrade(RuleConstant.FLOW_GRADE_QPS); rule.setCount(50); // 每秒最多50次请求 rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP); rule.setWarmUpPeriodSec(10); // 预热10秒 FlowRuleManager.loadRules(Collections.singletonList(rule));

降级规则:当异常比例超过阈值时熔断

DegradeRule rule = new DegradeRule(); rule.setResource("inventoryDeduct"); rule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO); rule.setCount(0.5); // 异常比例超过50%触发熔断 rule.setTimeWindow(10); // 熔断持续10秒 rule.setMinRequestAmount(10); // 至少10个请求才统计 DegradeRuleManager.loadRules(Collections.singletonList(rule));

热点规则:对特定参数限流

ParamFlowRule rule = new ParamFlowRule("getUserInfo") .setParamIdx(0) // 第一个参数 .setCount(100); // 该参数值每秒最多100次 ParamFlowRuleManager.loadRules(Collections.singletonList(rule));

实操心得:Sentinel的规则默认存在内存里,重启就丢了。生产环境必须配置持久化,我用的方案是Nacos配置中心 + Sentinel Dashboard双向同步。Dashboard里改规则会推送到Nacos,服务启动时从Nacos拉取规则。

5.3 降级兜底逻辑的设计

降级不是简单返回一个错误,而是要给用户一个可接受的替代结果。比如库存扣减失败时,不是直接报错,而是:

@Component public class InventoryFallbackFactory implements FallbackFactory<InventoryClient> { @Override public InventoryClient create(Throwable cause) { return new InventoryClient() { @Override public Result<Boolean> deduct(DeductRequest request) { // 记录日志,发送告警 log.error("库存扣减失败,进入降级逻辑", cause); // 返回一个"处理中"的状态,后续通过定时任务补偿 return Result.success(true); } @Override public Result<Boolean> rollback(RollbackRequest request) { log.error("库存回滚失败,进入降级逻辑", cause); return Result.success(true); } }; } }

这种设计的前提是:业务上允许最终一致性。如果业务要求强一致性,那就不能用降级,必须让请求失败并让用户重试。

6. 链路追踪与日志聚合:SkyWalking落地记录

6.1 SkyWalking的部署架构

SkyWalking的架构分三部分:

  • Agent:挂在每个服务上,负责采集数据
  • OAP Server:负责接收、分析、存储数据
  • UI:负责展示

我用的存储是Elasticsearch,因为数据量比较大,用H2内存存储扛不住。

部署命令(Docker方式):

# 启动Elasticsearch docker run -d --name elasticsearch \ -p 9200:9200 -p 9300:9300 \ -e "discovery.type=single-node" \ -e "ES_JAVA_OPTS=-Xms2g -Xmx2g" \ elasticsearch:7.17.0 # 启动OAP Server docker run -d --name oap \ -p 11800:11800 -p 12800:12800 \ -e SW_STORAGE=elasticsearch \ -e SW_STORAGE_ES_CLUSTER_NODES=elasticsearch:9200 \ apache/skywalking-oap-server:9.0.0 # 启动UI docker run -d --name skywalking-ui \ -p 8080:8080 \ -e SW_OAP_ADDRESS=http://oap:12800 \ apache/skywalking-ui:9.0.0

6.2 服务接入Agent的配置

每个服务启动时加上JVM参数:

java -javaagent:/path/to/skywalking-agent/skywalking-agent.jar \ -Dskywalking.agent.service_name=order-service \ -Dskywalking.collector.backend_service=192.168.1.200:11800 \ -jar order-service.jar

Agent的配置文件agent.config里还可以细调:

# 采样率,生产环境建议调低 agent.sample_n_per_3_secs=100 # 忽略特定路径 agent.ignore_suffix=.jpg,.jpeg,.png,.css,.js # 跨线程传递上下文 agent.trace_context_ignore_suffix=.jpg,.jpeg

注意:采样率不要设太高,否则OAP和ES的压力会很大。我一开始设了全量采集,结果ES的磁盘三天就满了。后来调到每3秒100条,既能看清链路,又不会压垮存储。

6.3 日志聚合方案

链路追踪解决了“请求经过了哪些服务”的问题,但“每个服务打印了什么日志”还需要日志聚合来解决。我的方案是:

  • 每个服务的日志输出到标准输出(Docker环境下)
  • 通过Filebeat采集日志,发送到Elasticsearch
  • 在Kibana里按TraceId查询完整链路日志

关键是要在日志里打上TraceId。SkyWalking的Agent会自动把TraceId注入到MDC里,日志配置里加上%X{tid}即可:

<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{tid}] %logger{36} - %msg%n</pattern>

这样在Kibana里搜一个TraceId,就能看到这个请求在所有服务里的完整日志。

7. 容器化部署与编排:Docker Compose方案

7.1 为什么初期不用K8s

K8s确实是容器编排的终极方案,但对于我这种只有五个服务、三台机器的场景来说,K8s的复杂度太高了。光是搭建一个高可用集群就要折腾好几天,后续的运维成本也不低。

Docker Compose足够用了——一个YAML文件定义所有服务,一条命令启动全部。等以后服务数量超过二十个,或者需要自动扩缩容的时候,再迁移到K8s也不迟。

7.2 Dockerfile的编写要点

每个服务的Dockerfile我用了多阶段构建,减小镜像体积:

# 构建阶段 FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 运行阶段 FROM openjdk:11-jre-slim WORKDIR /app COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]

这样构建出来的镜像只有JRE和JAR包,体积从原来的800MB降到了200MB左右。

7.3 Docker Compose编排文件

version: '3.8' services: nacos: image: nacos/nacos-server:v2.2.0 environment: - MODE=standalone - SPRING_DATASOURCE_PLATFORM=mysql ports: - "8848:8848" networks: - micro-net user-service: build: ./user-service environment: - SPRING_PROFILES_ACTIVE=prod - NACOS_ADDR=nacos:8848 depends_on: - nacos networks: - micro-net order-service: build: ./order-service environment: - SPRING_PROFILES_ACTIVE=prod - NACOS_ADDR=nacos:8848 depends_on: - nacos networks: - micro-net gateway: build: ./gateway ports: - "8080:8080" depends_on: - user-service - order-service networks: - micro-net networks: micro-net: driver: bridge

实操心得:depends_on只保证启动顺序,不保证服务就绪。如果服务启动慢,网关可能先起来但后端还没注册到Nacos。解决方案是在网关里配置重试机制,或者用healthcheck+condition: service_healthy。

8. 踩坑记录与排查技巧实录

8.1 服务注册失败排查

现象:服务启动日志显示注册成功,但Nacos控制台看不到实例。

排查思路:

  1. 检查网络连通性:telnet nacos-host 8848
  2. 检查namespace和group是否匹配
  3. 检查服务名是否包含非法字符(下划线在某些版本会有问题)
  4. 查看Nacos服务端日志logs/nacos.log

我的实际原因:namespace配置了一个不存在的值,Nacos不会报错,但实例注册到了错误的namespace里。

8.2 Feign调用超时问题

现象:偶发性的Feign调用超时,但后端服务日志显示处理很快。

排查思路:

  1. 检查Feign的超时配置是否合理
  2. 检查是否有大对象序列化耗时
  3. 检查网络是否有抖动
  4. 检查服务端线程池是否满了

我的实际原因:服务端Tomcat线程池默认200,但有个接口处理慢,把线程占满了,导致后续请求排队。解决方案是给慢接口单独配置线程池隔离。

8.3 分布式事务一致性

现象:订单创建成功,但库存扣减失败,数据不一致。

解决方案:我用了RocketMQ的事务消息 + 定时补偿:

  1. 订单服务先发半消息到MQ
  2. 执行本地事务(写入订单)
  3. 本地事务成功则提交消息,失败则回滚消息
  4. 库存服务消费消息,执行扣减
  5. 如果扣减失败,发送回滚消息给订单服务
  6. 定时任务扫描异常订单,进行补偿

这个方案不是强一致性,但保证了最终一致性,对于大部分业务场景够用了。

8.4 常见问题速查表

问题现象可能原因排查方向解决方案
服务注册不上namespace/group不匹配检查配置统一namespace
Feign调用404路径不匹配检查Controller路径核对Path和StripPrefix
配置刷新不生效缺少@RefreshScope检查注解加上注解或手动监听
网关限流不生效Redis未连接检查Redis配置修复Redis连接
链路追踪断链异步线程未传递上下文检查线程池使用TransmittableThreadLocal
熔断规则丢失未持久化检查规则存储配置Nacos持久化

9. 这套架构跑了一年后的真实体会

这套架构上线到现在差不多一年了,中间经历过几次流量高峰,整体表现还算稳定。最大的感受是:微服务不是银弹,它解决了一些问题,但也引入了一些新问题。

好处很明显:服务可以独立部署了,改通知模板不用重新部署订单服务;故障隔离了,库存服务挂了不会拖垮用户服务;扩容灵活了,订单高峰期单独给订单服务加机器就行。

但代价也不小:运维复杂度上来了,以前盯一个应用就行,现在要盯五个服务加一堆中间件;排查问题变难了,一个请求跨了三个服务,没有链路追踪根本查不下去;分布式事务让人头疼,虽然最终一致性方案能解决大部分场景,但代码复杂度确实增加了。

如果让我重新选一次,我可能还是会走微服务这条路,但会更谨慎地控制拆分粒度。能拆三个服务解决的问题,绝不拆五个。架构是演进来的,不是设计出来的,一开始就追求完美架构,往往适得其反。

最后分享一个我在实际运维中总结的小技巧:给每个服务都加上一个/health接口,返回当前服务的健康状态和依赖组件的连通性。然后在网关层配置定时探测,发现某个服务不健康就自动从路由列表里摘除。这个简单的机制在几次中间件故障时帮了大忙,避免了大量请求打到已经不可用的服务上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进