
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-OpenAPISwagger的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 订单幂等与重复下单拦截抢票场景下用户可能会重复点击“立即购买”按钮或者由于网络重试导致同一笔订单被发起两次。如果不做幂等控制就会出现一个用户买了同一场比赛同一个座位区的两张票或者重复扣款。我们的幂等方案是利用数据库唯一索引 前端生成幂等键。前端在用户点击购买按钮时生成一个requestIdUUID后端在创建订单时把requestId写入订单表的唯一索引字段request_id。如果同一秒内用户再次提交数据库插入订单时会因为唯一索引冲突而报错业务层捕获DuplicateKeyException后查询已存在的订单返回给用户。这样就不需要额外引入分布式锁去查“是否已有订单”。这里有一个细节唯一索引的冲突检测依赖于插入操作的原子性所以不能先查后插必须直接插入让数据库来判断。而用户重复点击场景下第二次插入时大概率连锁都没获取到就已经被拦截了。另一个细节是订单超时未支付的处理。我们采用Redis延迟队列基于Sorted Set实现用户下单成功后把订单号写入delay_queuescore设为当前时间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: - StripPrefix1 - id: ticket-service uri: lb://ticket-service predicates: - Path/api/orders/**,/api/tickets/** filters: - StripPrefix1这里有两个关键点要说明一是lb://表示负载均衡模式网关会从Nacos注册中心拉取服务实例列表按负载均衡策略默认是轮询把请求分发到各实例。二是StripPrefix过滤器的配置容易搞错。它的作用是去掉请求路径中的指定前缀。比如上面的/api/matches/**加StripPrefix1那么用户请求/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 Setscore为到期时间戳处理超时订单关单。接口限流计数器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编排生产环境使用KubernetesK8s。开发环境的编排文件核心服务包括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平均响应时间下单接口850msP95响应时间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并设置connectionTimeout3000——宁可快速失败也不无限等下去。第三轮优化是数据库慢查询治理。压测过程中发现了几个慢SQL比如订单列表查询用了ORDER BY create_time DESC但是没走索引因为创建时间的索引建在order_id联合索引的末尾。优化方式是加idx_user_createuser_id, create_time复合索引查询性能提升了好几倍。第三轮压测的结果平均RT降到了180msP95降到了320ms下单成功率提升到99.9%。0.1%的失败主要来自极端情况下的超时和限流这在业务上是可以接受的。8.3 线上故障处理复盘一次缓存穿透引发的思考系统上线后第二周发生过一次比较典型的线上故障。某场热门比赛开票时大量请求打到某个票档的库存查询接口。由于该票档是刚开售的新票档Redis里没有缓存数据所有请求都穿到数据库查询瞬间打爆了数据库连接池导致整个ticket-service不可用。事后分析的原因是当初做缓存时只对“已存在的票档”做了缓存新票档如果还没人访问过就没有缓存预热。这次故障让我深刻体会到三个措施的缺一不可缓存穿透防护查不到的数据也要缓存一个空值并设置很短的过期时间比如30秒防止击穿。更进一步的做法是使用布隆过滤器在缓存前拦截掉不存在的ID。缓存预热开票前运营在后台配置场次时系统自动把场次信息和库存数据写入Redis不要等到用户来访问时才加载。限流兜底网关层对每个票档查询接口设置QPS上限超过上限直接返回“系统繁忙”即使缓存失效也不至于打死数据库。这三个措施上线后这类故障就再也没有出现过。8.4 回顾如果重新做一次哪些地方可以做得更好整体项目上线后回头看最大的遗憾有两个。第一个遗憾是过早上微服务。项目早期用户量预估不清楚团队对微服务的运维经验也不足如果当时评估更保守一点先用单体模块化架构一个应用多个模块模块间通过接口隔离后续再按需拆分前期的开发效率会高很多。微服务解决了很多问题但也引入了不少成本——服务链路追踪、环境部署复杂度、联调成本这些在工作量上都是实打实的开销。第二个遗憾是链路的可观测性建设晚了一些。我们前期只做了基础的日志采集没有做全链路追踪。当线上出现订单状态不一致时排查问题是“屎山挖矿”要从网关日志、服务日志、MQ消息记录、数据库记录逐个对比才能定位问题在哪一环。后来我们接入了Micrometer Tracing Zipkin每个请求生成全局唯一的TraceId链路中所有的服务日志都带上这个TraceId排查效率提升了一个量级。如果你也在做微服务项目我强烈建议从第一天就把链路追踪接入不要等到踩了坑再补。再分享一个关于技术选型的核心体会凡是号称“解决所有分布式问题”的框架引入成本和研究成本都是巨大的。能用数据库的唯一索引解决的幂等就不要上分布式锁能用本地事务解决的一致性问题就不要上分布式事务框架能用缓存解决的热点查询就不要加一层搜索中间件。微服务的魅力在于按需拆解而不是把所有工具都堆上去。这套售票系统最终能够在一次次开票高峰中稳定扛住压力靠的也正是每一层都用最朴素的手段解决最具体的问题——Redis锁解决并发、消息表解决最终一致、对账任务解决差错、限流解决突发流量。把这些基础工作做扎实了架构自然会稳。