一个抢票系统的微服务化复盘:为什么拆、怎么拆、用什么拆。
背景
最近在写一个抢票系统,功能覆盖演出搜索、选座下单、支付回调、订单管理,麻雀虽小五脏俱全。一开始图省事,一个 Spring Boot 单机就上了,controller/service/dao 按包分层,所有表塞在一个库,跑起来飞快。
直到开始压"抢票"这个场景------
同一个演出场次、同一个价位,几百号人在等待倒计时结束的瞬间同时点"立即购买"。单机处理这种并发下单已经吃力,更别说后面还有支付回调、订单超时取消、库存实时同步这些链路。问题不是"能不能实现",而是"能不能扛住"。
摆在面前就两条路:要么继续往单机上堆内存、加线程池,要么把系统拆了。
选了后者。
拆哪几块
拆服务从来不是目的,而是手段。拆之前先想清楚几个问题:
-
哪些业务上下文是独立的、可以被单独部署?
-
哪些操作必须在一个数据库事务里,哪些可以最终一致?
-
并发热点在哪里,能不能通过服务隔离来分散?
最终拆成 5 个核心服务:
拆分原则:按业务能力纵向切,不要按技术层横向切。

坑点:一开始我想着也拆一个"库存服务"出来,后来发现库存和座位状态与节目数据耦合太紧,拆出去反而要加一层 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 再转发给下游,保持各服务内部的路径干净。
第二个角色是纵深防御------在请求到达业务服务之前,网关先过一道防线:
核心过滤器的实现骨架:
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 原子库存扣减
-
分库分表踩坑
-
抢票引擎的四代演进
项目正在持续迭代中,后续文章会陆续更新。