一个周末晚上,店长最怕的不是顾客投诉,而是拼团拼到晚上十点还差一个人,整场开不了。剧本杀这个生意很有意思,它本质上卖的是“凑齐一桌人”的能力,同样的剧本、同样的DM,四人和六人的体验完全不是一回事。而单人玩家想玩,必须等平台把散客拼成一辆车,这个“拼车聚合”的过程,就是平台最核心的难点。
这个剧本杀店铺服务拼团平台,用的是微服务分布式架构,后端以SpringBoot为基础、SpringCloud为微服务治理框架,前端用Vue做用户端和管理端,承载了用户、剧本、店铺、场次、拼团、订单、支付、通知这一整条业务链路。我不是把它当课设写的,而是按一个能应对几十场并发拼团、能水平扩容、能撑住运营活动的生产级系统来做的。这篇文章就把整套系统的架构设计、核心链路、分布式问题的处理思路和部署落地经验完整写出来,里面有大量的代码片段、配置文件和踩坑记录,适合正在做类似微服务项目、或者准备从单体往SpringCloud迁移的读者参考。
1. 从剧本杀的“拼车”到微服务:架构选型背后的业务理由
1.1 拼团业务的核心矛盾:离散需求与实时成团
先说业务模型,只有把业务吃透,后面所有的技术选型才有依据。
剧本杀的拼团和电商拼团有本质区别。电商拼团是“先把商品卖出去,再等参团人数达到门槛”,商品库存是静态的;剧本杀拼团是“人够了才能开局”,场次、座位、DM、剧本库存在同一时刻绑定在一起。一场6人本,A玩家锁了2号座,这时候B玩家想锁同一个座位,就必须等A取消或超时释放。再加上玩家随时可能跳车,散车后座位要回流,这套状态流转如果做成单体应用里的一张表,并发一上来就会出事。
所以平台的核心矛盾在于:离散、动态、实时。玩家需求是碎片化的,成团需要一个实时凑齐的过程,而且这个过程中还牵涉支付、退款、通知、线下核销。这些需求在架构层面天然要求模块边界清楚、数据独立、可以各自扩容。
1.2 单体增量演进:先单体模块化,再按域拆分
这个平台一开始并不是直接把微服务全套铺开的。我的做法是先做单体模块化:Maven多模块工程,按用户、拼团、订单、支付、剧本、店铺分成包结构,所有模块共用数据库、但表结构严格按领域分开,禁止跨领域直接查库。这一步非常关键,它让后续的服务拆分变成“搬包”而不是“重构”。
等到拼团流量起来之后,才按DDD的限界上下文把服务裁开。裁的依据很简单:看并发压力和变更频率。拼团服务是高频变更和高并发点,订单和支付涉及资金需要稳定,用户和剧本是低频读多场景,店铺和场次是典型的管理型CRUD。不同特征的服务对稳定性、扩展性、事务性的要求完全不同,硬塞在一个进程里只会互相拖累。
1.3 服务清单与领域边界
最终拆出来的服务如下表:
| 服务名 | 职责范围 | 关键数据 | 默认端口 |
|---|---|---|---|
| gateway | API入口、路由转发、统一鉴权、限流 | 无 | 8080 |
| auth-service | 登录注册、JWT签发与校验 | user_account | 8101 |
| user-service | 玩家/店长信息、积分、收藏 | user_profile | 8102 |
| play-service | 剧本库、难度标签、DM管理 | play, play_tag | 8103 |
| shop-service | 店铺、房间、场次、座位 | shop, session, seat | 8104 |
| group-service | 拼团创建、上车、状态流转 | group_info, group_member | 8105 |
| order-service | 订单创建、支付状态回调 | order_info, payment_record | 8106 |
| notify-service | 短信、站内信、模板消息 | message_record | 8107 |
这套拆分看起来中规中矩,但有一个原则是硬性的:拼团服务的核心状态机绝对不能和订单、座位直接共享表。拼团只负责记录“人齐了没有、车开了没有”,订单负责“谁付了钱、付了多少”,座位归属由shop-service负责,三者通过事件解耦。这样任何一个服务出了问题,都不至于把其他领域的表锁死。
2. 网关、服务调用与数据流:SpringCloud通信细节落地
2.1 API网关路由与统一鉴权
微服务架构里,前端永远不会直接面对每一个后端的IP,统一从API网关进。网关用的是Spring Cloud Gateway,基于WebFlux,吞吐量比Zuul 1.x好一个量级。核心路由配置长这样:
spring: cloud: gateway: routes: - id: auth-route uri: lb://auth-service predicates: - Path=/api/auth/** filters: - StripPrefix=1 - id: group-route uri: lb://group-service predicates: - Path=/api/group/** filters: - StripPrefix=1 - id: order-route uri: lb://order-service predicates: - Path=/api/order/** filters: - StripPrefix=1Path为/api/auth/**的请求会去掉前缀/api,转发到auth-service的/auth/**。加一个StripPrefix=1是必须的,否则服务端Controller的RequestMapping就必须写成带/api的完整前缀,后面一旦调整接口结构非常别扭。
鉴权放在网关的GlobalFilter里统一做,只放行/api/auth/login这类白名单路径,其余请求都要校验JWT。校验通过之后把userId塞进header传给下游服务,下游服务不自己解析Token,这样权限校验逻辑收敛到一层,服务间调用也不会到处传递用户身份。
限流用的是网关层按IP和用户维度的RequestRateLimiter,配合Redis做令牌桶。运营活动期间拼团流量窗口很陡,单机限流不顶用,网关层限流才是兜底的第一道闸门。
2.2 OpenFeign 调用规范与超时、降级
服务间同步调用统一走OpenFeign。比如创建拼团时,group-service需要确认shop-service里的场次是否还存在、座位是否有效,不能直接在配置里拼HTTP URL,要用声明式接口:
@FeignClient(name = "shop-service", fallback = ShopClientFallback.class) public interface ShopClient { @GetMapping("/shop/session/{sessionId}") Result<SessionVO> getSession(@PathVariable("sessionId") Long sessionId); }Feign这里有两个非常容易被忽略的点:连接超时和读取超时必须分开设置,而且读取超时不能太短,否则一次慢SQL就触发熔断误伤。个人实践值是连接超时2秒、读取超时5秒。降级类里返回一个明确的错误码,而不是吞掉异常返回null,否则上游拿到的数据校验逻辑会炸。
熔断用的Sentinel,按接口QPS和慢调用比例两个维度配置规则。拼团服务调用shop-service的熔断阈值是200 QPS、慢调用比例超过30%就熔断10秒。这里熔断不是为了防止服务挂掉,是为了防止一个慢服务拖垮整条调用链,这是服务治理里最基础的一条。
2.3 一次“上车”请求完整走一遍
我把一次完整的“用户加入拼团”请求数据流画在文字里,读者跟着走一遍就知道服务间怎么协作:
- 玩家在Vue页面点击“我要上车”,前端请求
POST /api/group/join。 - 网关校验JWT,解析出userId,转发到group-service。
- group-service先查Redis里的拼团信息缓存,确认拼团状态是“招募中”。
- 同步调用shop-service的Feign接口,锁定场次下的一个座位。
- 座位锁定成功,group-service本地事务创建拼团成员记录。
- 返回前端“上车成功,请支付”,前端跳转支付页。
- 支付完成后pay-service回调order-service,order-service发MQ消息。
- group-service消费消息,更新拼团人数,当人数达到上限,状态变更为“已成团”。
- notify-service监听成团事件,给所有成员推送“车已开,准时到店”。
这一趟链路里同步调用只出现在第4步,其余全部通过消息异步解耦。同步调用越少,链路越长系统的脆弱性越低,这是微服务设计里最值得强调的一句话。
3. Nacos:注册中心与配置中心的两种关键用法
3.1 选型比对:为什么直接上Nacos
Spring Cloud微服务必然要面临注册中心选型。Eureka 2.x已经停止维护很久了,Consul在服务发现上很强但配置管理弱,ZooKeeper更适合做协调服务、当成注册中心用开发体验并不好。Nacos是阿里开源的服务发现与配置中心一体组件,而且国内社区生态非常活跃,遇到问题基本都能搜到解决方案。
Nacos同时解决了两个问题:服务注册发现和配置动态刷新。它的命名空间和分组设计天然适合多环境隔离,一套Nacos集群可以同时给dev、test、prod三套环境用。
3.2 多环境配置与动态刷新
配置管理有一个很关键的坑。Spring Cloud 2020版本之后,bootstrap.yml默认不再生效,必须用spring.config.import引入Nacos配置,很多从旧教程抄代码的读者在这上面卡了很久。正确写法是:
spring: application: name: group-service config: import: nacos:group-service.yaml?group=DEFAULT_GROUP&refreshEnabled=true cloud: nacos: discovery: server-addr: ${NACOS_ADDR:127.0.0.1:8848} namespace: ${NACOS_NAMESPACE:public} config: server-addr: ${NACOS_ADDR:127.0.0.1:8848} namespace: ${NACOS_NAMESPACE:public}Nacos配置中心里我按服务和环境分了几个配置文件:
user-service.yaml:用户服务通用配置group-service.yaml:拼团服务配置,里面放拼团人数上下限、成团超时时间common-shared.yaml:所有服务共享的Redis、MQ、数据库配置
配置中心里存放的是带环境变量的模板,比如数据库密码不直接写在Nacos明文里,而是${DB_PASSWORD}。这样即使配置中心被拖库,也不会直接泄露生产环境密码。
配置变更后,服务里的@RefreshScopeBean会自动刷新。比如运营想临时调整拼团人数上限,直接在Nacos改配置发布,不用重启服务。我实测下来Nacos配置推送在多数情况下是秒级生效的,但极端网络分区下可能延迟,所以依赖动态配置的关键逻辑里必须加一层兜底默认值。
3.3 服务健康检查与实例漂移的坑
Nacos用的时候印象最深的一个问题是:服务出现“不健康实例”,但实际服务是活着的,接口也能访问。排查过程分三步:
先看服务的健康检查心跳配置。Spring Cloud Alibaba的Nacos注册默认用的是服务主动上报心跳,如果服务所在机器负载极高、GC停顿过长,心跳上报就会出现间隙,Nacos会把它标记为不健康。这种情况不是服务死了,而是“心跳超时”,需要在application.yml里调大心跳间隔和超时时间:
spring: cloud: nacos: discovery: heart-beat-interval: 5000 heart-beat-timeout: 15000再看是否误用持久化实例。Nacos实例分临时实例和持久实例,默认是临时实例,注册后超过心跳时间未上报会被自动剔除。如果把服务注册成持久实例,一旦服务宕机,Nacos不会删除实例,可能一直存在“死实例”,负载均衡会把流量打到死节点上。除非有特殊需求,否则默认临时实例就好。
最后看网络。Nacos控制台显示不健康,但服务日志又一切正常,大概率是服务所在节点的网络到Nacos之间有防火墙阻断了服务健康检查端口。这个坑在云环境里特别常见,我的排查顺序是先抓包、再看日志、最后才怀疑配置。
4. 并发抢座与Redis分布式锁:拼团核心链路
4.1 座位扣减的并发模型
拼团平台的技术核心只有一个:在并发抢座时保证不超卖、不重复。一个场次有6个座位,10个人同时发起锁座请求,最终最多只能有6个人锁定成功。
最朴素的方案是数据库乐观锁:UPDATE seat SET locked = 1 WHERE id = ? AND locked = 0。这确实能保证不超卖,但问题是高并发下大量的更新操作都堆积在数据库行锁上,MySQL的InnoDB行锁虽然效率高,但仍会带来大量锁等待和死锁。而且座位锁定之后还要去创建拼团成员、扣减库存、记录流水,如果这些操作全部依赖数据库事务,一个慢事务就能拖垮整个接口。
我的方案是Redis分布式锁 + 数据库唯一索引兜底,两级防线确保万无一失。
4.2 Redisson锁粒度与看门狗机制
分布式锁直接用Redisson,不用自己造轮子。它的内部实现了看门狗自动续期,避免了“锁已过期但业务还没执行完”这个经典问题。锁的key设计是核心中的核心:
@Autowired private RedissonClient redissonClient; public boolean lockSeat(Long sessionId, Long seatId, Long userId) { String lockKey = "lock:seat:" + sessionId + ":" + seatId; RLock lock = redissonClient.getLock(lockKey); // 等待3秒获取锁,不传leaseTime时看门狗自动续期30秒 boolean locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { return false; } try { // 双重检查座位状态 if (!seatService.isAvailable(sessionId, seatId)) { return false; } return seatService.batchDeduct(sessionId, seatId, userId); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }锁的粒度不是“场次”,而是“场次+座位ID”。如果把整个场次锁住,一次拼团活动的所有抢座请求都会串行化,TPS最多也就几十,完全扛不住活动峰值。锁到座位ID上,6个座位就可以并行锁,并发能力直接翻6倍。
这里还有个细节值得说:tryLock(3, TimeUnit.SECONDS)不传leaseTime时才启用看门狗自动续期,如果自己传了30秒的leaseTime,看门狗就失效了。实际开发中不建议手动传leaseTime,让看门狗自动续期更安全,除非业务方明确知道操作时长上限。
4.3 状态机、幂等键与兜底索引
拼团状态流转我设计成一个有限状态机:招募中 -> 已成团 -> 已核销,以及招募中 -> 已超时 -> 已解散。状态变更统一走一个状态机服务类,禁止在业务代码里随意update ... set status。之所以这么严格,是因为拼团场景有大量请求是重复的,用户连续点两次“上车”、MQ消息重复投递,这些都会触发状态流转,没有状态机约束就很容易出现同一辆车被打到已取消又变回已成团的脏状态。
接口幂等靠的是请求唯一键requestId。前端每次发起“上车”时生成一个UUID,后端在创建拼团成员的SQL里带上唯一索引:
CREATE TABLE group_member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, group_id BIGINT NOT NULL, user_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, request_id VARCHAR(64) NOT NULL, status TINYINT DEFAULT 0, create_time DATETIME, UNIQUE KEY uk_group_user_request (group_id, user_id, request_id) );即使Redis锁在极端情况下偶发失效,两个线程同时插入相同request_id的记录,数据库唯一索引会强制只让一条成功,另一条抛DuplicateKeyException后回滚,把超卖风险彻底堵死。
5. 分布式事务与最终一致性:支付回调和订单状态流转
5.1 这个场景为什么不做强事务
很多刚学微服务的人一听到跨服务操作多个表,第一反应就是上Seata全局事务。但拼团这个场景里,全局强事务反而不是最优解。
原因有两点:第一,拼团流程跨了shop-service(锁座)、order-service(下单)、pay-service(支付)、group-service(状态流转)四个服务,如果全程开启全局锁,相当于把所有参与者的数据库资源都hold住直到事务结束。玩家从点击支付到输入密码、指纹确认,中间可能有几十秒,这几十秒内座位资源一直被全局锁占用,并发能力直接清零。第二,这个场景并不需要实时强一致。支付成功之后让玩家等一两秒看到“成团进度+1”,体验上完全没问题,只要最终状态是对的就行。
所以支付回调到订单状态、订单创建到拼团人数更新,这两段用的是最终一致性方案。
5.2 本地消息表 + 消息队列落地
订单服务在本地事务里完成订单创建,同时往本地消息表插入一条待发送消息。这两个操作在同一个数据库事务里,要么都成功、要么都失败,保证了业务数据和消息数据的一致性。核心结构如下:
CREATE TABLE message_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型: ORDER_PAID, GROUP_MEMBER_JOINED等', biz_id VARCHAR(64) NOT NULL, payload TEXT COMMENT '消息体JSON', status TINYINT DEFAULT 0 COMMENT '0待发送, 1已发送, 2已确认', retry_count INT DEFAULT 0, create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_biz_type_biz_id (biz_type, biz_id) );订单服务的事务提交后,有个定时任务每隔5秒扫描status=0的消息,把消息可靠投递到RabbitMQ的order.exchange,然后更新消息状态为1。group-service消费队列消息,更新拼团人数,处理成功后回调订单服务的一个确认接口,订单服务把消息状态更新为2。如果RabbitMQ投递失败,重试次数超过10次就标记为死信,人工介入兜底。
这套方案的可靠性在于:消息一定有落库、投递一定有重试、消费一定有幂等。无论哪个环节出故障,最终都能通过定时任务扫表把消息重新投递出去,不会出现业务成功但消息丢失的情况。
5.3 重复消费、对账与手工修复
MQ消费端最大的敌人是重复消息。RabbitMQ在极端情况下会把同一条消息投递多次,消费者必须在业务层面做幂等。group-service消费“ORDER_PAID”消息时,先查拼团成员记录里的pay_status字段,如果已经是已支付就直接返回ACK,不再重复更新人数。
对账机制也是最终一致性方案里必须有的最后一道防线。我写了一个定时任务,每10分钟扫描一次订单表里“已支付”但拼团成员状态还是“待支付”的记录,并主动调用group-service的查询接口对齐状态。这个任务平时几乎不会触发,但一旦MQ集群故障,它就是保证数据不出问题的逃生舱。
6. Vue前端如何与微服务协同:请求封装、动态路由与实时拼团状态
6.1 前端工程组织与请求层设计
前端用的是Vue 3 + Vite + Pinia + Element Plus,管理端和用户端是两个独立工程,但请求层封装逻辑完全一致。所有请求统一走axios实例,baseURL指向网关的/api前缀,不直接配置任何一个微服务的地址,这也是前端对接微服务架构的核心原则。
// request.js import axios from 'axios' import { useUserStore } from '@/store/user' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) request.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { // 跳转登录页 } return Promise.reject(error) } )有一个和网关配合的细节必须提醒:Vue工程本地开发时,/api请求要代理到网关地址,避免跨域问题。生产环境则用Nginx把/api反向代理到网关。不要把Vue工程和各个微服务直接暴露到同一个域下,也不要在前端跨域请求多个服务端口,否则浏览器CORS策略会让你痛不欲生。
6.2 角色菜单与动态路由实现
平台有三个角色:玩家、店长、运营。三者看到的菜单完全不同。店长要管理店铺和场次,运营要看全局数据和拼团统计,玩家只需要浏览剧本和参与拼团。前端实现动态路由的方式是:登录之后根据角色编码向后端拉取可访问的路由表,前端用router.addRoute动态注册。
/api/auth/userinfo接口返回当前用户的角色编码和权限标识列表,前端在Pinia里存一份,permission模块负责过滤约定好的静态路由配置,然后动态注册。比如店长登录后注册/shop/manage路由,玩家访问该路径则直接404。
动态路由的实现要格外注意“刷新页面路由丢失”的问题。我在地刷新时先调userinfo接口拿到用户信息,再动态注册路由,然后next(to.path)重新进入目标路由,避免在路由守卫里造成无限循环。
6.3 拼团动态的WebSocket方案
拼团页面最影响体验的是一个实时感:你正在拼的车突然又进来两个人,列表要立刻更新,不能等玩家手动刷新。这个功能用WebSocket实现,前端在进入拼团详情页时建立连接,后台通过notify-service推送成员变化事件。
因为网关是WebFlux应用,天然支持WebSocket代理,只需要在网关配置里加一条WebSocket路由:
- id: websocket-route uri: lb:ws://notify-service predicates: - Path=/ws/**前端WebSocket连接的是ws://gateway/ws,和普通REST请求共用同一个网关域名,减少了跨域和鉴权成本。连接建立后,前端做心跳检测,每30秒发一次ping,如果连续3次没有收到pong就主动重连。后端推送的消息体携带groupId和最新人数,前端拿到后局部刷新当前拼团卡片,不需要重新拉取整个列表。
实测这个方案在移动端弱网环境下体验明显优于轮询接口,服务端每秒最多推送几十条消息,网关完全无压力。
7. 容器化部署与压测复盘
7.1 服务编排与资源规划
部署环境我全部用Docker Compose管理,不整K8s,因为拼团平台的规模还不到需要K8s的复杂度,Compose足够支撑两三台机器的集群。编排文件里核心服务有:gateway、auth-service、user-service、play-service、shop-service、group-service、order-service、notify-service(8个Java服务),以及Nacos、MySQL、Redis、RabbitMQ、MinIO。
Java服务基础镜像用的eclipse-temurin:17-jre,每个服务限制内存1G。注意Spring Boot 3.x要求JDK17,如果读者用的Spring Boot 2.7,JDK8或者JDK11都行,但版本不要混。Compose里统一定义了自定义网络,服务名就是注册到Nacos的地址,服务之间通过服务名访问,不写死IP。
一个部署上的教训:Nacos、MySQL、Redis这些中间件的数据目录全部挂载到宿主机持久卷,否则容器重建后配置和数据全部丢失,等于白干。
7.2 压测手法与扩容对比
压测用JMeter,线程组设置200个并发线程,Ramp-up时间10秒,持续压测5分钟。压测目标是/api/group/join上车接口,看服务在并发下的TPS、RT和错误率。
单副本2C4G的group-service压测数据:TPS约320,平均RT 180ms,错误率0.2%。瓶颈不在代码,而在单实例的数据库连接池和线程池。把group-service水平扩容到3副本后,TPS提升到约860,平均RT下降到90ms。这说明微服务架构在拼团这个场景下,水平扩容能力是单体应用完全达不到的。
扩容时还有一个细节:注册中心和数据库连接数要一起调。数据库连接池最大值在单副本时是50,三副本时不改成150,新增的副本会频繁等待获取数据库连接,扩容效果大打折扣。
7.3 三个避不开的分布式坑
第一个坑:Nacos配置刷新导致应用启动异常。生产环境Nacos短暂不可用时,新启动的服务实例拿不到配置会直接启动失败。解决办法是本地保留一份兜底配置,Spring Boot允许配置中心拉取失败时使用本地配置启动,等Nacos恢复再动态刷新。这个兜底配置只用于启动,运行时还是已Nacos为准。
第二个坑:分布式锁过期导致库存双扣。Redisson看门狗机制有效,但业务代码里有外部HTTP调用(比如调shop-service锁座),一次调用耗时超过看门狗续期阈值时仍然存在锁过期风险。我的最终兜底方案是:锁内不发起任何远程调用,把Feign调用放在获得锁之前;锁内只做本地内存校验和数据库更新,耗时压到毫秒级,这样锁过期的概率趋近于零。
第三个坑:消息堆积导致成团通知延迟。某次运营活动半小时内拼团请求暴涨,订单服务投递到MQ的消息量是平时的20倍。消费端按默认配置每条消息处理后手动ACK,处理速度跟不上生产速度,消息堆积到几十万条,玩家支付成功后好几分钟才看到成团更新。解决办法是消费者端开启批量消费,每次拉取50条、逐条处理但批量确认,同时消费端服务扩容到2副本分摊压力。从那以后我都在消息中间件加了一级监控,队列积压超过1万条立刻告警。
最后聊两句实在话
这个项目做完,我最深的感触是:微服务架构不是拿来炫技的,它是业务复杂度逼出来的结果。拼团这个场景天然有高并发、多领域、异步协作的特征,用微服务拆是合理的。但如果只是一个普通的内部管理系统,用户几百人、并发个位数,老老实实用单体就够了,强行上SpringCloud只会给自己增加几倍的运维负担。
如果你想从这个项目里学习,我建议按这个顺序切入:先把拼团核心链路的状态机和分布式锁跑通,这是整个系统最硬核的部分;再去研究Nacos的注册与配置机制,理解服务发现的意义;最后才是前端对接和部署。每一步都可以独立验证,不用非得完整跑通整套系统才能有收获。
后续这个平台还能往几个方向扩展:地图选店和位置推荐、玩家画像和剧本推荐、拼团优惠券引擎,还有基于历史成团记录的价格弹性预测。架构上面不用大改,只需要在现有微服务边界上再加独立服务就行,这就是当初拆分时边界划清楚的长期收益。