1. 为什么一个售票系统最终选择了微服务架构
先说结论:如果你只是卖普通话剧票、电影票,日峰值几千单,单体架构完全够用,甚至更合适。我们这个项目之所以一开始就奔着微服务+分布式去,是因为业务场景和流量模型决定了单体应用会在几个关键节点上先撑不住。
这是一套面向电子竞技赛事(王者荣耀比赛)的网上售票系统。电竞赛事的售票特征和传统演出票务有本质区别:热门场次开票瞬间的并发请求量极大,且抢票行为高度集中。一场热门比赛,比如总决赛或者人气队伍的对决,售票窗口开启后的前几分钟内,可能有数十万用户同时在刷票、锁座、下单。这种瞬时峰值流量,对系统的冲击点是全方位的:商品(票档/座位)的库存扣减、订单的重复校验、支付回调的状态流转、用户维度的限购判断,全部要在极短时间内完成。单体应用即使能靠水平扩容扛住部分压力,也会在数据库连接池、应用内缓存一致性、定时任务调度这几个环节上不断互相拖累。
另一个关键因素是业务域的天然隔离。这套系统包含前台门户(赛事展示、购票、订单查询)、后台管理(场次编排、座位图配置、票务库存管理)、票务核心(锁座、出票、退票)、用户中心(注册登录、会员等级、观赛记录)、营销模块(优惠券、活动)等多个业务域。业务域之间虽然存在数据关联,但每个域的流量特征、扩展需求、发布频率完全不同。比如后台管理是低频低并发,票务核心是高频高并发,两者放在同一个进程里,意味着一次后台功能的发布就可能影响线上售票主链路。拆开后,各自独立部署、独立扩展、独立发布,运维的隔离性和研发的并行性都显著提升。
第三个原因是团队协作方式。这个项目的开发团队分成了几个小组,前端组、票务组、用户组、运维组并行推进。用微服务架构,服务之间的接口契约(API约定)可以提前定好,各组在各自的代码仓库里开发,最后通过注册中心联通。如果还是一个大工程,光代码合并冲突就能把进度拖垮一大截。
当然,微服务是有代价的。分布式事务、链路追踪、服务间的远程调用延迟、部署运维复杂度,这些都是实打实的成本。所以我们在架构决策时做了一个明确约定:只有同时满足“高并发访问”“独立扩展需求”“独立发布频率”三个条件的业务域,才拆成独立微服务。按这个标准,最终落地了下面的服务划分。
2. 服务拆分与Spring Cloud基础设施选型
2.1 服务划分:七个服务的边界是怎么定的
根据上面的标准,我们把系统拆成了七个服务,每个服务的职责边界都尽量做到单一。
| 服务名称 | 核心职责 | 关键数据 |
|---|---|---|
| user-service | 注册登录、JWT签发、会员等级、观赛人信息管理 | 用户表、观赛人表 |
| match-service | 赛事信息、场次排期、座位图配置、票价档位管理 | 赛事表、场次表、票价档表 |
| ticket-service | 锁座、库存扣减、订单生成、出票/退票、订单状态流转 | 订单表、座位占用表、票品表 |
| payment-service | 支付渠道对接、支付状态回调、退款处理 | 支付流水表 |
| marketing-service | 优惠券发放/核销、活动规则、邀请有礼 | 优惠券表 |
| gateway-service | 统一入口、路由转发、鉴权过滤、限流熔断 | — |
| admin-service | 后台管理端聚合查询、报表导出、运营配置 | 运营配置表 |
几个服务划分时的关键思考点:
ticket-service(票务核心)单独拆出来,是因为它是整个系统里并发压力最大、事务一致性要求最高的服务。锁座和创建订单必须在一个本地事务里完成,不能拆散到多个服务里。至于扣库存和支付,我们采用最终一致性方案,这一点后面专门讲。
match-service(赛事信息服务)和ticket-service(票务核心)必须分离,是因为二者读写模式差异巨大。赛事信息是读多写少,适合做缓存预热和CDN加速;票务核心是读写均衡且写并发极高。如果合在一起,一次后台的场次配置操作就可能拖累高并发的票务接口。分离后,赛事信息变更通过内部消息通知票务服务刷新缓存,解耦干净。
admin-service单独成服务,是为了避免后台的大量列表查询(往往没有合理索引、SQL较重)消耗主库连接,影响线上售票链路的数据库性能。后台的所有查询都走独立的只读库或延迟同步的查询库,这在后面数据库设计部分会展开。
2.2 Spring Cloud体系选型:为什么注册中心、网关都选了Nacos生态
技术栈用的是Spring Cloud + Spring Boot,这里有一个很多初学者容易纠结的问题:Spring Cloud的组件选型到底怎么定?
我们最终确定的是:注册中心、配置中心统一用Nacos,网关用Spring Cloud Gateway,远程调用用OpenFeign,熔断限流用Sentinel,链路追踪用Micrometer Tracing + Zipkin,构建工具用Maven,统一采用Spring Boot 2.7.x + Spring Cloud 2021.x版本组合。为什么这么选?
Nacos相比Eureka的优势,在于它同时解决了注册中心和配置中心两个问题。Eureka 2.0的停摆让很多人转向了Consul或者Nacos。Nacos的配置中心支持配置的动态刷新,这对我们运营人员频繁调整票档、限购规则、接口开关来说非常实用——改配置不用重启服务,这在售票高峰期是刚需。比如比赛前发现某个票档余票异常,直接把阈值配置改了推送下去,比发版快得多。
Spring Cloud Gateway作为网关,替代了Zuul 1.x。Gateway基于WebFlux响应式编程模型,底层是Netty,性能和吞吐量比Zuul 1.x的阻塞式模型好很多。我们的网关层承担三件事:统一鉴权(解析JWT并校验)、路由转发、限流。前两件好理解,限流这一层其实很关键——抢票高峰期,网关层的限流是第一道防线,得先把恶意刷票和异常流量挡在外面,保护后面的票务服务不被击穿。
需要强调的是Feign的一个大坑:Feign默认的通信协议是HTTP,每次远程调用都有序列化和网络开销。在抢票主链路里,我强烈建议避免在大流量路径上做Feign调用。我们在设计时明确要求:购票主链路只访问ticket-service一个服务,所有下单所需的数据(场次信息、票价档位、用户信息)在进入主链路之前,通过本地缓存或预加载的方式准备好。网关层做完鉴权后,把用户信息放进Header里转发给ticket-service,不再反查user-service。这个设计决策很重要,后面会展开讲为什么要这么做。
2.3 服务间通信与接口契约的规范
服务拆开后,接口契约的管理就变得非常重要。我们的做法是:
- 所有服务间的DTO(数据传输对象)独立维护,不允许直接使用数据库实体类跨服务传输。
- 接口版本号从一开始就带上,比如
/api/v1/orders,避免后续接口变更时无法兼容。 - 服务间的接口文档用Springdoc-OpenAPI(Swagger的Spring Boot 3兼容版本)自动生成,服务启动后可以直接访问
/v3/api-docs查看接口定义。 - 所有Feign调用必须配置超时时间和重试机制。我们在坑里得到的经验是:重试要慎重,POST接口的重试可能造成数据重复或多次扣款,所以只对GET请求开启重试,而且重试次数不超过1次。
3. 抢票高并发:分布式锁、库存扣减与订单幂等设计
3.1 为什么必须自己实现分布式锁,而不是直接依赖数据库
售票系统的核心难题就是库存扣减的并发控制。我们使用的是Redis分布式锁来保证同一时间只有一个线程在执行扣减操作。
先说明为什么不能只靠数据库:比如余票剩最后1张,两个请求同时来买,如果只是简单地UPDATE ticket_stock SET stock = stock - 1 WHERE id = ?,虽然行锁能保证最终数据正确,但问题在于库存扣减之后紧接着还要创建订单、锁定座位,这些操作不是一条SQL能完成的,而是一个事务里的一组操作。如果多个请求同时进入这个事务,数据库行锁会排队,事务等待时间一长,连接池很快会被占满,整个数据库就堵死了。所以必须在上层加一把锁,让同时只放一个请求进入这个临界区。
Redis分布式锁的经典实现方式是SET lock_key request_id NX EX timeout。注意这里有几个关键点:
NX表示只有当key不存在时才设置成功,这是获取锁的核心。EX设置过期时间,防止持有锁的线程崩溃导致锁永不释放。value必须是唯一的request_id(通常用UUID或雪花ID),释放锁时要用Lua脚本校验,即只有value匹配才删除,防止误删别人的锁。
我们项目里的核心实现是这样的:
public class RedisLock { private final StringRedisTemplate redisTemplate; public boolean tryLock(String key, String requestId, long expireSeconds) { Boolean success = redisTemplate.opsForValue() .setIfAbsent(key, requestId, expireSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); } public void unlock(String key, String requestId) { // 使用Lua脚本保证"校验+删除"的原子性 String script = "if redis.call('get', KEYS[1]) == ARGV[1] " + "then return redis.call('del', KEYS[1]) " + "else return 0 end"; redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Collections.singletonList(key), requestId ); } }这个实现有两个在实际项目中很容易踩的坑,我展开说一下。
3.2 分布式锁的续期与主从切换问题
第一个坑是锁过期时间设置太短导致业务还没执行完锁就被释放。我们第一版把锁的过期时间设成了10秒,结果有一次数据库慢查询导致扣库存事务执行了15秒,第10秒时锁自动释放了,另一个线程进来发现库存还没扣,又扣了一次,库存变成了负数。这个问题的解决方案有两种:
- 方案A:把锁过期时间设置得足够长。比如预估最快业务执行时间是2秒,设置30秒甚至60秒。问题是如果线程真崩溃了,锁最长要等60秒才能被其他线程获取,影响用户体验。
- 方案B:给锁加自动续期机制——用一个守护线程每隔一段时间(比如过期时间的1/3)检查锁是否还在持有,如果业务还没执行完就续期。这其实是Redisson的watchdog机制的原理。
我们在项目里用的是方案B,核心思路是启动一个定时任务,每次业务执行前续期锁。这里我需要提醒一句,续期逻辑虽然简单,但一定要防止忘记关闭守护线程导致的内存泄漏。我们在业务执行完毕后,在finally块中不仅要释放锁,还要显式关闭续期任务。
第二个坑是Redis主从切换导致的锁丢失问题。这是分布式锁的经典缺陷:如果Redis采用主从架构,客户端A在主节点上获取了锁,主节点还没来得及同步到从节点就挂了,哨兵把从节点提升为主节点,此时客户端B在从节点(新的主节点)上也能获取同一把锁,分布式锁就失效了。对于严格的金融场景,解决方式是使用Redisson的RedLock算法——向多个独立的Redis节点同时加锁,超过半数节点成功才算加锁成功。但RedLock在业界也有争议,因为它在极端情况下仍然不是绝对安全的。我们评估后认为,售票业务的锁竞争窗口非常短(毫秒级),即使出现主从切换导致超卖,也可以通过事后对账和订单状态校正来解决,所以没有引入RedLock的复杂度。这个取舍要在架构评审时明确讲清楚,否则运维同事会质疑为什么不搞RedLock。
3.3 库存扣减的最终实现:三段式设计与扣减顺序
我们最终的库存扣减流程分三段,每一段都有明确的目的:
- 前置校验(网关+Redis预减):网关层根据场次ID+票档ID做Reids限流,限制某个票档每秒最多进入多少请求。同时用Redis的一个哈希结构
show_stock_{showId}保存在内存中的预售库存数(初始值从数据库加载),请求进来先DECR(原子减一),减到小于0就拒绝。这一步把查询请求拦截掉很大一部分。 - 分布式锁保护(Redisson):通过预减进入的请求,在ticket-service内部获取分布式锁,锁的粒度是
lock:show:{showId}:ticketType:{ticketTypeId},这样不同票档的扣减互不干扰,同一个票档则串行执行。 - 数据库事务扣减:获取锁后开启本地事务,执行
UPDATE扣减库存,插入座位占用记录,创建订单(状态为“待支付”),提交事务后释放锁。
为什么有了Redis预减还要数据库扣减?因为Redis里的是“预售库存”,它只是一个粗略的计数器,可能和数据库真实余票有偏差(比如有人锁座后一直不支付,超时释放的票要回补库存)。数据库扣减才是最终一致性保证。Redis预减的意义在于,它用极小的代价挡掉了99%的无效请求,保护了数据库。
库存扣减SQL还有一个小技巧,我们的更新语句是这样的:
UPDATE show_ticket_stock SET stock = stock - 1 WHERE show_id = #{showId} AND ticket_type_id = #{ticketTypeId} AND stock > 0注意末尾的AND stock > 0,这一步实际上是一个乐观锁的兜底:即使在分布式锁出现问题导致两个线程同时进入的情况下,这条SQL也保证了不会扣成负数。数据库行锁会串行执行,第二个事务进来时stock > 0已经不满足,影响行数为0,业务层捕获到影响行数为0就重新查询库存并返回“已售罄”。这个设计是双保险,非常重要。
3.4 订单幂等与重复下单拦截
抢票场景下,用户可能会重复点击“立即购买”按钮,或者由于网络重试导致同一笔订单被发起两次。如果不做幂等控制,就会出现一个用户买了同一场比赛同一个座位区的两张票,或者重复扣款。
我们的幂等方案是:利用数据库唯一索引 + 前端生成幂等键。前端在用户点击购买按钮时,生成一个requestId(UUID),后端在创建订单时把requestId写入订单表的唯一索引字段request_id。如果同一秒内用户再次提交,数据库插入订单时会因为唯一索引冲突而报错,业务层捕获DuplicateKeyException后查询已存在的订单返回给用户。这样就不需要额外引入分布式锁去查“是否已有订单”。
这里有一个细节:唯一索引的冲突检测依赖于插入操作的原子性,所以不能先查后插,必须直接插入让数据库来判断。而用户重复点击场景下,第二次插入时大概率连锁都没获取到就已经被拦截了。
另一个细节是订单超时未支付的处理。我们采用Redis延迟队列(基于Sorted Set实现),用户下单成功后把订单号写入delay_queue,score设为当前时间+15分钟。一个后台定时任务每隔几秒扫描到期的订单,如果订单还是“待支付”状态,就关单并回补座位库存。这样设计的好处是把超时关单的扫描压力打在后台任务上,不会占用主链路的数据库资源。
4. 分布式事务与订单状态机:不靠两阶段提交,靠最终一致性
4.1 为什么分布式事务场景不能直接用Seata的AT模式
售票系统的下单流程跨越了多个服务:用户在ticket-service创建订单、在payment-service发起支付、支付成功后再通知ticket-service更新订单状态和出票。
很多第一次做分布式系统的同学上来就问:为什么不直接上Seata的AT模式或者两阶段提交?这里有一个重要的认知:2PC(两阶段提交)和Seata AT模式解决的是强一致性场景下的分布式事务问题,但它们的代价是性能和可用性。在购票这种高并发场景,如果按照2PC的方案,用户下单后要等几个服务协调者确认,那下单接口的RT(响应时间)会惨不忍睹。
更重要的是,售票业务的很多步骤本来就不需要强一致。用户创建订单是一个本地事务,支付是另一个服务的事,支付成功后的出票又是一个本地事务。如果支付服务挂了,难道要让下单和支付强绑在一起,导致用户连单都没法下吗?所以我们的策略是跨服务的业务操作最终一致性。
具体落地的方案是:本地消息表 + 消息队列 + 定期对账。
4.2 本地消息表方案的具体实现
以下单支付流程为例,说明整个流程的状态流转:
user-service用户点击支付,前端调用payment-service的支付接口。payment-service创建支付流水(状态:待支付),调用第三方支付渠道(微信/支付宝)生成支付链接,返回给前端。- 用户完成支付,第三方支付渠道异步回调
payment-service的支付结果接口。 payment-service在本地事务中更新支付流水状态为“支付成功”,同时往本地消息表插入一条“支付成功通知”消息,状态为“待发送”——这两个操作在同一个数据库事务里,保证了不会出现“支付状态更新了但消息没记录”的问题。- 一个定时任务扫描消息表中“待发送”状态的消息,把消息投递到RocketMQ的
order_status_topic。 ticket-service消费MQ消息,更新订单状态为“已支付”,生成正式电子票,并调用短信通知服务给用户发送入场二维码。- 如果MQ消费失败,消息表里的消息一直处于“待发送”状态,定时任务持续重试;如果消费成功但下游处理失败,MQ的重试机制和本地消息表的重发机制共同保证最终成功。
这里要说一个非常重要但经常被忽略的问题:消息重复消费是必然的,消费端的处理逻辑必须幂等。RocketMQ的消费语义是至少一次(At-Least-Once),所以ticket-service消费“支付成功”消息时,不能直接更新订单状态,而要先判断订单当前状态,只有是“待支付”状态才允许更新为“已支付”,否则就当成重复消息丢弃。这个判断可以用数据库的乐观锁实现:UPDATE orders SET status = 'PAID', ticket_code = #{ticketCode} WHERE order_id = #{orderId} AND status = 'WAITING_PAY',影响行数为0就说明消息重复了。
4.3 订单状态机的建模与流转校验
订单状态机是整个售票系统里最容易写乱的部分。我们的状态机定义如下:
WAITING_PAY: 待支付(下单后15分钟内必须完成支付) PAID: 已支付(支付成功,待出票) TICKET_ISSUED: 已出票(电子票已生成,可入场) REFUNDING: 退款中(用户发起退票申请) REFUNDED: 已退款 CANCELLED: 已取消(超时未支付或用户主动取消) USED: 已入场(验票核销)状态机的好处是让每一步流转都有明确的合法性校验。我们用一个OrderStateMachine组件统一管理流转,不允许在业务代码里随便setStatus。比如“已支付”不能直接跳转“已取消”,“已出票”不能跳转“待支付”,这种非法流转在状态机里直接抛出异常。
退款流程是一个相对复杂的状态流转:用户发起退款请求后,先到ticket-service校验订单是否满足退款条件(比如距离开赛时间是否超过48小时),符合条件就把订单状态改为“退款中”,然后发消息给payment-service执行退款。payment-service调用第三方支付渠道的退款接口,收到退款成功回调后更新支付流水状态,再发消息通知ticket-service把订单状态改为“已退款”。整个链路也是本地消息表+MQ的最终一致性方案。
4.4 对账任务:最后一道防线
无论本地消息表还是MQ重试机制,都只能保证“消息最终会送达到”“消费处理会幂等”,但无法保证“业务数据完全一致”。比如第三方支付渠道回调丢了、或者我们的支付流水和第三方渠道的支付结果不一致,这些都是消息机制解决不了的。因此,我们额外实现了每天凌晨的自动对账任务:
- 从
payment-service拉取前一天所有“支付成功但订单未出票”的支付流水。 - 调用第三方支付渠道的对账接口,核对每笔交易在支付渠道侧的真实状态。
- 如果渠道侧是扣款成功,但我们的订单还是“待支付”,说明消息链路出问题了——立即触发补单流程,把订单状态推进为“已支付”并出票。
- 如果渠道侧未扣款,但我们的支付流水是“支付成功”,说明回调异常——把支付流水修正为“支付失败”,并通知用户重新支付。
这个对账任务上线后,我们几乎再没出现过用户“扣了钱但没拿到票”的客诉。对账是分布式系统里绝对不能省的一环,它比你多花十倍功夫保证消息可靠更有效。
5. 单体够用为什么还要Spring Cloud:注册中心、网关、配置中心实战落地
5.1 Nacos注册中心与Gateway网关的配置要点
服务拆完之后,服务间通信的复杂性就上来了。我们使用Nacos做注册中心,使用Spring Cloud Gateway做统一入口。网关的定位是:请求进来后先过鉴权过滤器,再根据路由规则转发到具体服务。
网关层的核心配置如下(YAML片段):
spring: cloud: gateway: routes: - id: match-service uri: lb://match-service predicates: - Path=/api/matches/** filters: - StripPrefix=1 - id: ticket-service uri: lb://ticket-service predicates: - Path=/api/orders/**,/api/tickets/** filters: - StripPrefix=1这里有两个关键点要说明:
一是lb://表示负载均衡模式,网关会从Nacos注册中心拉取服务实例列表,按负载均衡策略(默认是轮询)把请求分发到各实例。
二是StripPrefix过滤器的配置容易搞错。它的作用是去掉请求路径中的指定前缀。比如上面的/api/matches/**加StripPrefix=1,那么用户请求/api/matches/1001时,网关转发给match-service的路径是/matches/1001(去掉了/api)。这个前缀的设计直接决定了后端Controller的@RequestMapping路径要不要包含/api,我们统一约定为后端接口不带/api前缀,由网关统一加。
网关层的鉴权过滤器适合用GlobalFilter实现。核心逻辑:解析JWT Token,校验签名和过期时间,然后把用户ID和角色信息放到请求头X-UserId和X-User-Role中,转发给下游服务。下游服务就不再重复解析Token了,直接从Header里取用户身份。
这里又一个容易踩坑的地方:网关的JWT校验不能写得太重。如果每个请求都去数据库查用户状态,网关的RT会飙升。我们的做法是网关只校验JWT的签名和过期时间,不查数据库。如果用户被封禁或角色变更,通过配置中心的黑名单配置动态加载到网关本地缓存中,实现实时过滤。
5.2 Nacos配置中心:动态配置让运营不需要求着研发发版
Nacos作为配置中心的使用,让很多以前需要发版才能完成的运营操作变成了分钟级的操作。最典型的一个场景就是限购规则的调整。
比如运营在比赛前临时决定调整“每个用户单场限购6张”为“限购4张”,这个规则如果写在代码里,改起来需要经历改代码、构建、发版三个步骤,在高频变化的运营场景下根本不现实。我们用Nacos配置中心,把限购规则、票档开关、支付超时时间、售罄预警阈值等参数做成配置项,配置变更后通过@RefreshScope注解实现Bean的重新加载,服务不用重启就能生效。
但是配置中心能动态刷新也会带来一个隐患:如果配置写错了,并且被服务端错误地加载,可能导致线上故障。我们的规避方案是:
- 配置修改后,先在测试环境对应的namespace中验证。
- 生产环境用
Nacos的命名空间(namespace)和Group隔离环境,测试环境、生产环境分别使用不同的namespace。 - 所有配置变更操作记录审计日志,配置发布人、发布时间、变更内容都有迹可循。
- 重要配置(比如限购开关)在代码中设一个安全默认值,即使Nacos不可用或配置为空,也能按默认值运行。
5.3 Sentinel限流与降级:抢票高峰期的自我保护
Sentinel是我们集成到网关层和ticket-service里的核心组件。网关层主要用来做接口维度的限流,ticket-service里主要用来做资源维度的熔断降级。
网关层的限流规则:根据用户IP、用户ID、接口路径分别设置QPS阈值。比如/api/orders/purchase这个下单接口,单个用户每秒最多3次请求,超过了就返回繁忙提示。这个限制能有效拦截用户疯狂点击导致的重复请求,也拦截了一部分脚本抢票行为。
ticket-service内部的Sentinel熔断降级,保护的是Feign调用的依赖服务。举个例子,ticket-service下单时需要查询match-service的场次信息,但match-service如果因为数据库慢查询导致接口RT飙升,Feign调用会不断超时并占用线程资源。如果不对这种异常做熔断,ticket-service自己的线程池也会被拖垮。我们用Sentinel的熔断规则:当对match-service的调用错误率超过50%,就熔断10秒,熔断期间直接走本地缓存的场次信息(即使可能不是最新的),保证下单主链路可用。
这里想多说一句,熔断降级的关键不是“切断”,而是“替代”。熔断后你要给用户返回什么?是直接报错,还是返回一个降级的缓存值?我们在设计时约定:查询类接口(查场次、查票档)允许返回降级数据,写操作类接口(下单、支付)不能降级,必须走完整流程,否则可能产生脏数据。
6. Vue前端架构与关键页面的踩坑实录
6.1 前端整体架构与动态路由设计
前端技术栈是Vue 3 + Vue Router + Pinia + Vite + Element Plus。由于系统的后台管理和用户前台路由差异很大,我们使用了动态路由的方案:用户登录成功后,后端根据用户的角色返回可访问的路由表,前端把路由表动态注册到Vue Router中。这样做的好处是不同角色看到的菜单和可达页面天然隔离,比如普通用户永远无法通过手动修改URL进入后台管理页面。
动态路由的实现拆成三步:
- 用户登录后,调用
user-service的获取用户信息接口,拿到userId、角色、权限标识。 - 前端根据角色匹配本地路由表(一张维护了所有可选路由的静态表),过滤出该角色可见的路由集合。
- 调用
router.addRoute()动态添加路由,再router.replace()跳转到首页。
这里最常见的坑是页面刷新后动态路由丢失。因为Vue Router的路由表是运行时注册的,刷新页面后内存中的路由表清空,如果直接访问一个动态路由的URL,会出现“匹配不到路由”的白屏。解决方案是在router.beforeEach全局前置守卫中判断:如果当前访问的路由不在静态路由表中且store中还没有动态路由,就先调用获取用户信息的接口,重新生成并追加动态路由,再放行导航。
6.2 抢票页面的倒计时、防重复提交与WebSocket排队通知
购票页面是整个前端最核心的页面,它的开发有几个特殊的地方:
准点开售的倒计时。前端从match-service获取开售时间,使用本地时钟计算剩余时间。但我们都知道,用户本地时钟和服务器时钟可能存在偏差,所以倒计时不要只依赖本地时间,而是通过接口请求时响应头里的Date字段校准本地时间和服务器时间的差值。开售时间到达前1秒,前端要提前发起一个探针请求,用于“激活”后端的抢票入口。这个探针请求会触发网关预热动作(比如预先把场次信息缓存加载到Redis),让真正的购买请求进入时有更好的命中率。
防重复提交。我们前端实现了三级防重复提交:按钮在发起请求后立即置灰并显示“处理中…”;同时用一个isSubmitting变量做函数级锁,防止连点;后端还有幂等键校验。三层保障下来,重复下单的概率基本为0。
WebSocket排队通知。对于热门场次,我们做了“虚拟排队”的机制:当瞬时请求量超过阈值时,网关返回“当前购票人数过多,已进入排队队列,预计等待xx秒”,同时为用户建立一个WebSocket连接,后台每当排到该用户时,通过WebSocket推送“请尽快完成支付”的提示。这个机制在前端只是监听WebSocket消息,难度不大,真正复杂的是后端排队的实现和WebSocket连接的稳定性——用户切后台、断网重连时,排队状态怎么恢复?我们通过把排队token保存在本地存储,用户重连时携带token重新进入排队队列,并保持原有序号。
6.3 m3u8视频直播流的接入:Vue播放器选型踩坑
电竞赛事除了售票,还有“购买后可观看比赛直播/回放”的业务需求。运营给的直播源是HLS协议(m3u8格式)的地址。这里就要说一下Vue播放器的选型。
HLS流在PC端没有原生播放支持(Safari除外),必须使用hls.js库把m3u8转成MP4分片给video元素播放。如果是移动端,微信内置浏览器对HLS支持也有差异,又需要兼容处理。
我们的播放器方案是:video.js + videojs-contrib-hls插件,或者在新项目中直接用hls.js + 原生video标签。对比下来两者的区别是:
| 方案 | 优点 | 缺点 |
|---|---|---|
| video.js | 功能全、UI好看、播放器控制栏开箱即用 | 包体积大、定制样式麻烦 |
| hls.js + 原生video | 轻量、灵活、可控性强 | 需要自己写控制栏UI、兼容性调试成本高 |
由于我们的直播流有防盗链参数(URL里带签名和时间戳),签名会过期,所以我们采用hls.js实现。播放器在播放前先向后端请求一个带签名的播放地址,当播放过程中签名即将过期时,监听播放器的报错事件,捕获到网络错误后重新请求新的签名地址,再调用hls.startLoad()继续播放。这个“无缝续播”逻辑是我们踩了坑才做出来的:如果不在播放过程中处理签名过期,用户会突然看到黑屏,而且是点播放按钮都没反应的死黑屏。
7. 基础设施与性能优化:缓存、数据库、部署的完整链路
7.1 缓存分层:Redis + Caffeine两级缓存的组合
售票系统对查询类接口的性能要求非常高,尤其是赛事列表、场次信息、座位图这些热门数据。我们的缓存设计分两层:
- 热点数据(场次信息、票档价格、赛事公告)存放在Redis中,并设置合理的过期时间(5~60分钟不等)。
- 同一服务实例内的超热点数据(比如当前场次的座位图)进一步存入Caffeine本地缓存,避免每次请求都走Redis网络IO。
Caffeine是一个高性能的Java本地缓存库,它的配置很简单,但有个点要特别注意:本地缓存天然存在数据一致性问题——同一份数据在不同实例上可能缓存不一致。我们的解决方案是:本地缓存只用于“允许一定延迟”的数据,比如座位图的只读展示。当用户点击“立即购买”时,必须走Redis获取最新锁定状态,保证不拿到旧数据。
Redis里的关键数据结构和用途:
- 场次信息:
String类型,JSON序列化存储,过期时间30分钟。 - 库存预减:
Hash类型,show_stock_{showId}的字段为ticketTypeId,值为剩余预售库存。 - 分布式锁:
String类型,参考前面的实现。 - 延迟队列:
Sorted Set,score为到期时间戳,处理超时订单关单。 - 接口限流计数器:
String+ 过期时间实现滑动窗口,用于网关层限流。
7.2 数据库设计:分库分表与读写分离的取舍
对于售票系统这种数据量级(百万级订单),我们用不到分库分表,但读写分离是必须做的。我们的数据库拓扑是:
- 主库(Master):负责所有写操作——订单插入、库存扣减、支付流水更新。
- 从库(Slave):承接所有查询操作——订单列表查询、用户历史订单查询、后台报表查询。
- 通过MySQL的主从复制机制保持数据同步。
服务层通过AbstractRoutingDataSource实现读写分离:@Transactional事务方法走主库,普通查询方法走从库。这里要注意一个经典的坑:如果写入后立即查询,主从同步延迟会导致查不到最新数据。比如用户支付成功后,马上查询订单详情页,如果走从库,可能查到状态还是“待支付”。我们的规避方案是:在payment-service和ticket-service中对“刚写完就查”的场景强制使用主库(通过事务传播属性或ThreadLocal标记强制走主库)。
订单表本身是数据量增长最快的表,虽然没有分表,但我们必须设计良好的索引来保证查询性能。订单表的核心索引有:order_id主键、user_id(用户订单列表)、show_id + ticket_type_id + status(后台查询场次销量)、request_id唯一索引(幂等控制)。这些索引覆盖了90%以上的查询场景。
7.3 定时任务:XXL-JOB的使用与任务设计
分布式架构下,定时任务的调度不能依赖单机@Scheduled注解,否则多个服务实例会重复执行同一个任务。比如超时关单任务如果在3个ticket-service实例上同时跑,就会同时扫描并关掉同一批订单,导致库存负扣。
我们选用XXL-JOB作为分布式任务调度平台。任务在平台上有唯一标识,平台通过执行器注册机制,每次只会把任务分发给一个实例执行。XXL-JOB的接入很简单:引入依赖、配置执行器地址、在代码里继承IJobHandler就可以了。
几个典型任务的设计:
- 超时关单任务:每隔30秒执行一次,从Redis延迟队列中取出到期订单,逐个检查状态并关单。如果有大量过期订单积压,任务需要分批处理(每次处理500个,处理完一批再取下一批),防止单次执行时间过长。
- 库存回补任务:每天凌晨对所有场次的库存和实际座位占用状态进行校正,清理“锁定状态但订单已取消”的脏数据。
- 每日对账任务:前面已经讲过,负责支付流水和第三方渠道的对账,凌晨2点执行。
- 统计数据汇总任务:后台管理端需要“某场比赛卖出多少张票”“售价总额是多少”等统计数据。由于实时统计会对主库造成压力,我们每小时把订单数据聚合成汇总表,后台报表从汇总表查询,查询体验和数据库压力都能保证。
7.4 部署方案:Docker + Docker Compose搭建全栈环境
服务拆成7个之后,部署是最头疼的事。我们的开发环境和测试环境统一使用Docker Compose编排,生产环境使用Kubernetes(K8s)。
开发环境的编排文件核心服务包括:
nacos-server:注册中心+配置中心mysql-master/mysql-slave:主从数据库redis-server:缓存rocketmq-namesrv/rocketmq-broker:消息队列- 各服务实例:user-service、match-service、ticket-service等
用Docker Compose的好处是研发本地一条命令就能启动整套环境,告别“在我机器上能跑”的尴尬。但要注意Docker Compose只适合开发环境,生产环境必须使用K8s或云厂商的容器服务,因为K8s的负载均衡、自动扩缩容、滚动发布等能力是容器编排层面的刚需,Compose给不了。
生产环境的K8s部署有几个关键的配置:每个服务的CPU和内存Limit要精确设置,避免某个实例占用过多资源把宿主机打满;服务的livenessProbe和readinessProbe探针必须配置,否则Pod挂了一个小时都没人知道;配置热更新通过ConfigMap挂载实现,避免发版时配置变更丢失。
8. 上线后的性能实测与核心优化复盘
8.1 压测结果与瓶颈定位
整个系统经过三轮压测,最有参考价值的是第二轮测试的数据和问题。压测场景是模拟20万用户同时抢购1万张票,使用JMeter分布式压测,每个线程组模拟不同IP。
第二轮压测的结果:
| 指标 | 数值 |
|---|---|
| 总请求量 | 260万次 |
| QPS峰值 | 12,800 |
| 平均响应时间(下单接口) | 850ms |
| P95响应时间 | 1,530ms |
| 下单成功率 | 99.2% |
| 数据库最大连接数 | 180 |
从数据看,成功率和P95都还可以,但平均响应时间偏高,主要瓶颈不在数据库,而在于下单接口中的Feign调用。当时订单创建流程里有一处从match-service获取场次详情的Feign调用,虽然走的是Nacos负载均衡,但每次调用都要经过网络往返和序列化。在高峰期,match-service的实例处理不过来,出现连接等待超时,ticket-service的线程池被慢调用拖住了。
8.2 主链路优化的三个动作
第一轮优化是砍掉主链路的Feign调用。下单接口需要的场次信息、票价信息,统一在网关层把参数完整传递,或者由ticket-service启动时从Redis缓存加载。缓存没有命中时,才走一次Feign回源。这样99%的请求不需要跨服务调用,RT直接降到了200ms以内。
第二轮优化是连接池与线程池参数调优。ticket-service作为核心服务,我们把Tomcat线程池从默认的200调到了400,同时把数据库连接池(HikariCP)的maximumPoolSize从50调到100,并设置connectionTimeout=3000——宁可快速失败也不无限等下去。
第三轮优化是数据库慢查询治理。压测过程中发现了几个慢SQL,比如订单列表查询用了ORDER BY create_time DESC但是没走索引,因为创建时间的索引建在order_id联合索引的末尾。优化方式是加idx_user_create(user_id, create_time)复合索引,查询性能提升了好几倍。
第三轮压测的结果:平均RT降到了180ms,P95降到了320ms,下单成功率提升到99.9%。0.1%的失败主要来自极端情况下的超时和限流,这在业务上是可以接受的。
8.3 线上故障处理复盘:一次缓存穿透引发的思考
系统上线后第二周,发生过一次比较典型的线上故障。某场热门比赛开票时,大量请求打到某个票档的库存查询接口。由于该票档是刚开售的新票档,Redis里没有缓存数据,所有请求都穿到数据库查询,瞬间打爆了数据库连接池,导致整个ticket-service不可用。
事后分析的原因是:当初做缓存时,只对“已存在的票档”做了缓存,新票档如果还没人访问过,就没有缓存预热。这次故障让我深刻体会到三个措施的缺一不可:
- 缓存穿透防护:查不到的数据也要缓存一个空值,并设置很短的过期时间(比如30秒),防止击穿。更进一步的做法是使用布隆过滤器在缓存前拦截掉不存在的ID。
- 缓存预热:开票前,运营在后台配置场次时,系统自动把场次信息和库存数据写入Redis,不要等到用户来访问时才加载。
- 限流兜底:网关层对每个票档查询接口设置QPS上限,超过上限直接返回“系统繁忙”,即使缓存失效也不至于打死数据库。
这三个措施上线后,这类故障就再也没有出现过。
8.4 回顾:如果重新做一次,哪些地方可以做得更好
整体项目上线后回头看,最大的遗憾有两个。
第一个遗憾是过早上微服务。项目早期,用户量预估不清楚,团队对微服务的运维经验也不足,如果当时评估更保守一点,先用单体模块化架构(一个应用,多个模块,模块间通过接口隔离),后续再按需拆分,前期的开发效率会高很多。微服务解决了很多问题,但也引入了不少成本——服务链路追踪、环境部署复杂度、联调成本,这些在工作量上都是实打实的开销。
第二个遗憾是链路的可观测性建设晚了一些。我们前期只做了基础的日志采集,没有做全链路追踪。当线上出现订单状态不一致时,排查问题是“屎山挖矿”:要从网关日志、服务日志、MQ消息记录、数据库记录逐个对比才能定位问题在哪一环。后来我们接入了Micrometer Tracing + Zipkin,每个请求生成全局唯一的TraceId,链路中所有的服务日志都带上这个TraceId,排查效率提升了一个量级。如果你也在做微服务项目,我强烈建议从第一天就把链路追踪接入,不要等到踩了坑再补。
再分享一个关于技术选型的核心体会:凡是号称“解决所有分布式问题”的框架,引入成本和研究成本都是巨大的。能用数据库的唯一索引解决的幂等,就不要上分布式锁;能用本地事务解决的一致性问题,就不要上分布式事务框架;能用缓存解决的热点查询,就不要加一层搜索中间件。微服务的魅力在于按需拆解,而不是把所有工具都堆上去。这套售票系统最终能够在一次次开票高峰中稳定扛住压力,靠的也正是每一层都用最朴素的手段解决最具体的问题——Redis锁解决并发、消息表解决最终一致、对账任务解决差错、限流解决突发流量。把这些基础工作做扎实了,架构自然会稳。