从单体思维到微服务:服务拆分与组件选型实践

一个抢票系统的微服务化复盘:为什么拆、怎么拆、用什么拆。

背景

最近在写一个抢票系统,功能覆盖演出搜索、选座下单、支付回调、订单管理,麻雀虽小五脏俱全。一开始图省事,一个 Spring Boot 单机就上了,controller/service/dao 按包分层,所有表塞在一个库,跑起来飞快。

直到开始压"抢票"这个场景------

同一个演出场次、同一个价位,几百号人在等待倒计时结束的瞬间同时点"立即购买"。单机处理这种并发下单已经吃力,更别说后面还有支付回调、订单超时取消、库存实时同步这些链路。问题不是"能不能实现",而是"能不能扛住"。

摆在面前就两条路:要么继续往单机上堆内存、加线程池,要么把系统拆了。

选了后者。

拆哪几块

拆服务从来不是目的,而是手段。拆之前先想清楚几个问题:

  • 哪些业务上下文是独立的、可以被单独部署?

  • 哪些操作必须在一个数据库事务里,哪些可以最终一致?

  • 并发热点在哪里,能不能通过服务隔离来分散?

最终拆成 5 个核心服务:

graph TB Client[浏览器 / App] --> Gateway[API 网关 :6085] Gateway --> User[用户服务 :6082] Gateway --> Program[节目服务 :6086] Gateway --> Order[订单服务 :8081] Gateway --> Pay[支付服务 :6087] Gateway --> Common[基础数据服务 :6083] User --- Nacos[(Nacos 注册/配置中心)] Program --- Nacos Order --- Nacos Pay --- Nacos Common --- Nacos Program -.->|Feign| Order Order -.->|Feign| User Order -.->|Feign| Pay Gateway -->|全局过滤器| Redis[(Redis)] Gateway -->|API 日志| Kafka[(Kafka)] style Client fill:#f9f,stroke:#333 style Gateway fill:#ff9,stroke:#333 style Nacos fill:#9cf,stroke:#333 style Redis fill:#f66,stroke:#333 style Kafka fill:#fc6,stroke:#333

拆分原则:按业务能力纵向切,不要按技术层横向切。

坑点:一开始我想着也拆一个"库存服务"出来,后来发现库存和座位状态与节目数据耦合太紧,拆出去反而要加一层 RPC 调用,徒增延迟。当拆分增加的延迟超过收益时,就不要拆。

服务怎么被发现和调用

Nacos:注册 + 配置一把抓

为什么选 Nacos?三个字:够省事。

Eureka 2.0 停更后需要注册中心 + 配置中心的组合方案,Nacos 一个组件解决两个问题,而且自带控制台,SPI 检测到 MySQL 就自动切持久化模式,不用再搞个中间层。

每个服务启动时的配置:

yaml 复制代码
spring:

application:

name: ${prefix.distinction.name:app}-program-service

cloud:

nacos:

discovery:

server-addr: 10.0.0.1:8848

username: admin

password: admin

prefix.distinction.name 这个设计值得讲一下。同一个注册中心可能会跑多套环境(dev/test/staging),一个环境变量就能切命名空间,不用动代码:

java 复制代码
// 所有服务的 Feign Client 都走这个前缀

public static final String SPRING_INJECT_PREFIX_DISTINCTION_NAME =

"${prefix.distinction.name:app}";

比如 staging 环境启动时设一个环境变量 prefix.distinction.name=app-staging,所有服务就注册为 app-staging-program-service,和开发环境天然隔离。后续做灰度和多租户也复用同一套机制。

Gateway:不止是路由

网关承担了三个角色。第一个角色当然是路由分发,基于 Nacos 服务发现做负载均衡:

yaml 复制代码
spring:

cloud:

gateway:

routes:

- id: app-program-service

uri: lb://app-program-service

predicates:

- Path=/app/program/**

filters:

- StripPrefix=2

- id: app-order-service

uri: lb://app-order-service

predicates:

- Path=/app/order/**

filters:

- StripPrefix=2

lb:// 走的是 Nacos 的服务列表 + Ribbon/Spring Cloud LoadBalancer,自动负载均衡。StripPrefix=2/app/program/order/create 截成 /order/create 再转发给下游,保持各服务内部的路径干净。

第二个角色是纵深防御------在请求到达业务服务之前,网关先过一道防线:

flowchart LR A[请求到达] --> B{全局限流开关} B -->|on| C[Semaphore.acquire] B -->|off| D[跳过] C --> D D --> E{路径白名单?} E -->|no| F[RSA 签名验证] F --> G[Token 校验] G --> H{API 限流?} H -->|yes| I[Lua 脚本限流] I --> J[转发下游] H -->|no| J E -->|yes| J

核心过滤器的实现骨架:

java 复制代码
@Component

public class RequestValidationFilter implements GlobalFilter, Ordered {

// 全局信号量限流

@Override

public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

if (rateLimiterProperty.getRateSwitch()) {

rateLimiter.acquire(); // Semaphore.tryAcquire,拿不到直接抛异常

return doFilter(exchange, chain);

}

return doFilter(exchange, chain);

}

// RSA 签名验签 + Token 校验 + API 级限流

private Map<String,String> doExecute(String originalBody, ServerWebExchange exchange) {

// 1. RSA 签名验证(防篡改)

boolean checkFlag = RsaSignTool.verifyRsaSign256(bodyContent, channelDataVo.getSignPublicKey());

if (!checkFlag) throw new BizException(ErrorCode.SIGN_ERROR);

// 2. Token 校验(白名单路径跳过)

if (!skipCheckTokenResult) {

UserVo userVo = tokenService.getUser(token, code, channelDataVo.getTokenSecret());

}

// 3. 单用户 + 单接口维度的 Redis Lua 限流

apiRestrictService.apiRestrict(userId, url, request);

}

}

流量过来之后,网关是第一层(全局限流 + 签名校验),具体接口还有第二层(按用户 + 接口的细粒度限流)。

第三个角色是链路透传。网关解析出 userId、traceId、channelCode 后,通过 Header 一路透传到下游所有 Feign 调用:

java 复制代码
// FeignRequestInterceptor:所有 Feign 调用自动携带链路上下文

public void apply(RequestTemplate template) {

RequestAttributes ra = RequestContextHolder.getRequestAttributes();

if (ra != null) {

HttpServletRequest request = ((ServletRequestAttributes) ra).getRequest();

// 从当前请求的 Header 里取出下游需要的参数,原样注入 Feign 请求

String traceId = request.getHeader(TRACE_ID);

String code = request.getHeader(CODE);

String gray = request.getHeader(GRAY_PARAMETER);

// 如果网关没有传 gray,降级到当前实例的 Nacos 元数据

if (StringUtil.isEmpty(gray)) {

gray = serverGray; // 来自 ${spring.cloud.nacos.discovery.metadata.gray:false}

}

template.header(TRACE_ID, traceId);

template.header(CODE, code);

template.header(GRAY_PARAMETER, gray);

}

}

这样每个服务打日志时,traceId 是同一根线上的珠子,排查问题不用在多个服务的日志文件里跳来跳去。

Feign + 熔断:让远程调用长得像本地方法

同步跨服务调用我选的是 OpenFeign,理由也简单:声明式接口 + OkHttp 传输层 + 熔断兜底。

java 复制代码
@FeignClient(

value = SPRING_INJECT_PREFIX_DISTINCTION_NAME + "-order-service",

fallback = OrderClientFallback.class

)

public interface OrderClient {

@PostMapping("/order/create")

ApiResponse<String> create(OrderCreateDto dto);

@PostMapping("/order/internal/status")

ApiResponse<OrderStatusVo> status(OrderGetDto dto);

}

调用方写起来跟本地方法一样,不用关心连接池、序列化、负载均衡。而这些事 OkHttp 在底层全包了:

yaml 复制代码
feign:

okhttp:

enabled: true # 用 OkHttp 代替默认的 HttpURLConnection

compression:

request:

enabled: true # 请求体 gzip

min-request-size: 512

response:

enabled: true # 响应体 gzip

hystrix:

enabled: true # 开启熔断

熔断降级的逻辑统一收敛在 Fallback 实现里:

java 复制代码
@Component

public class OrderClientFallback implements OrderClient {

@Override

public ApiResponse<String> create(OrderCreateDto dto) {

return ApiResponse.error(ErrorCode.SYSTEM_ERROR); // 下单熔断直接返回系统错误

}

}

要不要在 Fallback 里做更复杂的兜底逻辑(比如降级成 MQ 异步下单),这个后面在"引擎演进"那篇文章聊。

把横切关注点抽成工程语言

分布式锁和接口幂等性,这两个东西几乎每个抢票接口都要用。但如果每个方法里都写一遍 tryLock() / isExecuted(),代码很快就会被噪声淹没。

我们搞了两个自定义注解,用 AOP 把模式抽干净。

@ServiceLock:锁对你而言就是一行注解

java 复制代码
@Target({ElementType.METHOD})

@Retention(RetentionPolicy.RUNTIME)

public @interface ServiceLock {

LockType lockType() default LockType.Reentrant; // 可重入/公平/读写

String name() default ""; // 业务名称

String[] keys(); // SpEL 动态 Key

long waitTime() default 10; // 等待时间

TimeUnit timeUnit() default TimeUnit.SECONDS;

LockTimeOutStrategy lockTimeoutStrategy() default LockTimeOutStrategy.FAIL;

String customLockTimeoutStrategy() default ""; // 超时后的自定义兜底方法

}

切面做的事情:

java 复制代码
@Around("@annotation(servicelock)")

public Object around(ProceedingJoinPoint joinPoint, ServiceLock servicelock) throws Throwable {

// 1. SpEL 解析动态 Key

String lockName = lockInfoHandle.getLockName(joinPoint, servicelock.name(), servicelock.keys());

// 2. 根据 lockType 选不同的 Redisson 锁实现

ServiceLocker lock = serviceLockFactory.getLock(servicelock.lockType());

// 3. tryLock 加锁

if (lock.tryLock(lockName, servicelock.timeUnit(), servicelock.waitTime())) {

try {

return joinPoint.proceed(); // 执行业务

} finally {

lock.unlock(lockName); // 无论如何释放

}

}

// 4. 超时处理:默认抛异常,也可以指定一个同参方法做降级

if (StringUtil.isNotEmpty(servicelock.customLockTimeoutStrategy())) {

return handleCustomLockTimeoutStrategy(servicelock.customLockTimeoutStrategy(), joinPoint);

}

servicelock.lockTimeoutStrategy().handler(lockName);

}

实际使用一行为止:

java 复制代码
// 对"节目+场次+票档"维度加可重入锁

@ServiceLock(name = "PROGRAM_ORDER_CREATE", keys = {"#programId", "#showTimeId", "#ticketCategoryId"})

public String createOrder(Long programId, Long showTimeId, Long ticketCategoryId) { ... }

// 对"订单号"维度加公平锁

@ServiceLock(lockType = LockType.Fair, name = "ORDER_CANCEL", keys = {"#orderNumber"})

public void cancelOrder(String orderNumber) { ... }

锁类型的选择其实有讲究:

@RepeatExecuteLimit:防重提交的终极方案

java 复制代码
@Target({ElementType.METHOD})

@Retention(RetentionPolicy.RUNTIME)

public @interface RepeatExecuteLimit {

String name() default "";

String[] keys(); // SpEL 动态 Key

long durationTime() default 0L; // 幂等窗口期

String message() default "提交频繁,请稍后重试";

}

切面用了两级锁 + 双检查 为什么本地锁 + 分布式锁?因为 Redis 虽然只有几毫秒延迟,但在秒杀场景里大量请求同时打过来,全走 Redis 的 tryLock 也会形成网络瓶颈。本地 Caffeine 锁把同一 JVM 内的重复请求直接拦截,省掉一次网络 IO。

java 复制代码
// 实际使用:一次下单请求,按节目+用户+票档防重

@RepeatExecuteLimit(

name = "PROGRAM_ORDER_CREATE",

keys = {"#programId", "#userId", "#ticketCategoryId"},

durationTime = 10

)

public String submitOrder(OrderRequest request) { ... }

durationTime=10 意味着这个组合在 10 秒内只能执行成功一次------即便客户端网络重试、用户手抖连点两次,后端不会重复处理。

配套怎么管

最后提几个不写代码但同样重要的事。

文档 。5 个服务加起来几十个接口,团队协作时不可能靠口头对齐。Gateway 上启用了 Knife4j 的聚合模式,所有服务的 OpenAPI 文档收敛到一个地址 http://gateway:6085/doc.html,切服务标签就能看,不用在每个服务的地址之间跳。

yaml 复制代码
knife4j:

gateway:

enabled: true

strategy: discover # 自动发现 Nacos 上的所有服务

discover:

version: openapi3

enabled: true

监控。Spring Boot Admin 单开了一个服务挂在 Nacos 上,所有服务的心跳、堆内存、线程池、GC 状态一目了然。前期发现过几次内存泄漏------一个 ScheduledExecutorService 没有 shutdown,Bean 被 Spring 回收但线程还活着------全靠监控面板上的堆曲线持续走高才定位到。

模块管理。根 pom 的依赖版本全收敛在 properties 里:

xml 复制代码
<properties>

<spring-boot.version>3.3.0</spring-boot.version>

<spring-cloud.version>2023.0.2</spring-cloud.version>

<spring-cloud-alibaba.version>2023.0.1.0</spring-cloud-alibaba.version>

<redisson.version>3.32.0</redisson.version>

<shardingsphere.version>5.3.2</shardingsphere.version>

<mybatis-plus.version>3.5.7</mybatis-plus.version>

</properties>

每个内部 starter 版本跟主 pom 走 ${revision},确保 mvn deploy 时不会出现不同服务引了不同版本的情况。这种事情出过一次就长记性------Feign 序列化失败,debug 半天发现是 common 包版本不一致。

小结

这一篇走完了从单体到微服务的第一程:拆了什么、怎么发现的、怎么调用的、怎么防御的、怎么把横切关注点抽成注解。后面几篇会逐步深入到具体的技术块------

  • Lua 原子库存扣减

  • 分库分表踩坑

  • 抢票引擎的四代演进

项目正在持续迭代中,后续文章会陆续更新。

相关推荐
花生了什么事o1 小时前
线程池参数调优:corePoolSize 怎么设置
java·后端
想要成为糕糕手1 小时前
🚀 从零玩转 Agent 开发:原理、架构与框架实战完全指南
后端
灵性(๑>ڡ<)☆1 小时前
Java学习笔记 --面向对象进阶
java·笔记·学习
卷无止境1 小时前
pytest 从零到实战:要想代码好,测试少不了
后端·python
K神1 小时前
Spring gRPC集成Spring Security OAuth2
后端·grpc
Ai拆代码的曹操1 小时前
Dubbo 直连模式翻车现场:2.7.x 调 3.x 的序列化大坑
后端
写了20年代码的老程序员1 小时前
Java Lambda 类型安全查询是怎么实现的?从字节码层面拆一遍
java·开源
Chenyiax1 小时前
AC 自动机原理:把一组关键词编译成一次线性扫描
后端
程序员cxuan1 小时前
读懂 Claude Code 架构分析系列,第二篇:Claude Code 是怎样启动的?
人工智能·后端·程序员