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 拆分粒度的判断标准
服务拆分是微服务架构里最容易翻车的环节。我见过不少团队一上来就拆成十几个服务,结果光是服务间的调用链路就复杂到没人能理清楚。我的经验是:初期宁可拆粗一点,也不要拆太细。
具体怎么判断?我用了三个标准:
- 业务边界是否清晰:用户管理和订单处理之间的边界很明确,但订单和库存之间就有大量交互,初期可以考虑放在一起。
- 数据是否强关联:如果两个模块共享同一张核心表,强行拆开会导致分布式事务问题,不如先合着。
- 团队规模是否匹配:一个人维护三四个服务已经是极限了,如果团队只有三五个人,拆成十几个服务就是自找麻烦。
最终我把原来的单体应用拆成了五个服务:
- 用户服务:负责注册、登录、权限校验、用户信息管理
- 订单服务:负责订单创建、状态流转、订单查询
- 库存服务:负责库存扣减、回滚、库存查询
- 通知服务:负责短信、邮件、站内信的发送
- 网关服务:负责路由转发、鉴权、限流
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 网关的核心职责划分
网关不是简单的反向代理,它承担了五个核心职责:
- 路由转发:根据请求路径把流量分发到对应服务
- 统一鉴权:在网关层校验Token,避免每个服务都写一遍鉴权逻辑
- 限流熔断:保护后端服务不被突发流量打垮
- 日志记录:统一记录请求日志,方便排查问题
- 跨域处理:统一配置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.06.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.jarAgent的配置文件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控制台看不到实例。
排查思路:
- 检查网络连通性:
telnet nacos-host 8848 - 检查namespace和group是否匹配
- 检查服务名是否包含非法字符(下划线在某些版本会有问题)
- 查看Nacos服务端日志
logs/nacos.log
我的实际原因:namespace配置了一个不存在的值,Nacos不会报错,但实例注册到了错误的namespace里。
8.2 Feign调用超时问题
现象:偶发性的Feign调用超时,但后端服务日志显示处理很快。
排查思路:
- 检查Feign的超时配置是否合理
- 检查是否有大对象序列化耗时
- 检查网络是否有抖动
- 检查服务端线程池是否满了
我的实际原因:服务端Tomcat线程池默认200,但有个接口处理慢,把线程占满了,导致后续请求排队。解决方案是给慢接口单独配置线程池隔离。
8.3 分布式事务一致性
现象:订单创建成功,但库存扣减失败,数据不一致。
解决方案:我用了RocketMQ的事务消息 + 定时补偿:
- 订单服务先发半消息到MQ
- 执行本地事务(写入订单)
- 本地事务成功则提交消息,失败则回滚消息
- 库存服务消费消息,执行扣减
- 如果扣减失败,发送回滚消息给订单服务
- 定时任务扫描异常订单,进行补偿
这个方案不是强一致性,但保证了最终一致性,对于大部分业务场景够用了。
8.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 服务注册不上 | namespace/group不匹配 | 检查配置 | 统一namespace |
| Feign调用404 | 路径不匹配 | 检查Controller路径 | 核对Path和StripPrefix |
| 配置刷新不生效 | 缺少@RefreshScope | 检查注解 | 加上注解或手动监听 |
| 网关限流不生效 | Redis未连接 | 检查Redis配置 | 修复Redis连接 |
| 链路追踪断链 | 异步线程未传递上下文 | 检查线程池 | 使用TransmittableThreadLocal |
| 熔断规则丢失 | 未持久化 | 检查规则存储 | 配置Nacos持久化 |
9. 这套架构跑了一年后的真实体会
这套架构上线到现在差不多一年了,中间经历过几次流量高峰,整体表现还算稳定。最大的感受是:微服务不是银弹,它解决了一些问题,但也引入了一些新问题。
好处很明显:服务可以独立部署了,改通知模板不用重新部署订单服务;故障隔离了,库存服务挂了不会拖垮用户服务;扩容灵活了,订单高峰期单独给订单服务加机器就行。
但代价也不小:运维复杂度上来了,以前盯一个应用就行,现在要盯五个服务加一堆中间件;排查问题变难了,一个请求跨了三个服务,没有链路追踪根本查不下去;分布式事务让人头疼,虽然最终一致性方案能解决大部分场景,但代码复杂度确实增加了。
如果让我重新选一次,我可能还是会走微服务这条路,但会更谨慎地控制拆分粒度。能拆三个服务解决的问题,绝不拆五个。架构是演进来的,不是设计出来的,一开始就追求完美架构,往往适得其反。
最后分享一个我在实际运维中总结的小技巧:给每个服务都加上一个/health接口,返回当前服务的健康状态和依赖组件的连通性。然后在网关层配置定时探测,发现某个服务不健康就自动从路由列表里摘除。这个简单的机制在几次中间件故障时帮了大忙,避免了大量请求打到已经不可用的服务上。