尧图精选

SpringCloud微服务架构下的高并发抢票系统:防超卖与一致性实战

🕒 发布时间:2026/10/2 14:22:08 📁 来源:尧图网络
做这个系统最刺激的永远是开票那十几秒。王者荣耀级别的电竞赛事总决赛动辄上万座位开票瞬间几十万用户挤同一个接口服务器能扛住不算本事扛住且没超卖才算合格。这个项目我前前后后做了大半年技术栈就是标题里那套组合——SpringBoot Vue SpringCloud 的微服务架构业务域覆盖用户、赛事、场馆、订单、库存、支付、通知七块。写这篇文章不是来炫架构的而是把我从服务拆分、高并发抢票、分布式事务一致性到前端选座交互这些环节里踩过的坑和验证过的方案记录下来给同样在做票务类系统的团队一个真实参考。先说结论微服务这套组合在这类项目里的确有用但它解决的从来不是“并发高不高”这一个点而是“多业务域独立演进 故障隔离 弹性扩容”的整体工程问题。如果只有几千人的小场馆单体应用加个Redis缓存完全够用硬上微服务只会给自己添乱。可一旦业务进入“多个端同时访问 热门场次秒级售罄 运营活动频繁发版”的阶段微服务的价值就会非常明显。1. 项目背景与整体设计思路1.1 电竞票务的典型业务痛点电竞比赛售票和传统演唱会售票表面相似实际差距不小。演唱会卖的是区域票价用户选大区就行电竞观众对座位非常敏感粉丝为了靠近选手席或者某个战队的应援区会精确到第几排第几座。这就导致票务模型天然要做“选座”能力不能只做“选档”。三个最让人头疼的问题第一瞬时流量极端集中。KPL总决赛或者世界冠军杯这种量级开票前的预约人数可能是放票量的几十倍开票瞬间QPS直接冲到峰值压测阶段我们模拟过单接口每秒近万次请求的情况。普通业务系统扛日常流量没问题但开票这种脉冲式流量必须专门设计流控和削峰方案。第二黄牛和脚本抢票。电竞票的二级市场溢价很高脚本可以在毫秒级完成选座提交真人手速根本拼不过。开票后几分钟内大量热门座位被脚本占住但不支付形成“占座不付”的局面。这对系统设计的直接影响就是座位锁定必须有超时释放机制且释放要及时否则真实用户买不到票运营压力极大。第三多端多渠道协同。用户通过H5选座购票运营人员在管理后台配置场次和票价场馆入口的核销人员需要通过扫码验证电子票后续还要接小程序。服务端要一次设计、多处复用这也是我坚持用微服务而不是继续堆单体的直接原因。1.2 为什么选微服务单体架构先撑不住了最开始版本其实是个单体SpringBoot应用用户、订单、库存全在一个工程里开发阶段挺顺手但临近第一个线上活动时问题全暴露了。单体应用在抢票场景下的困境非常具体一个服务进程里同时处理登录、下单、支付回调、短信发送哪个环节慢一点整个进程的线程池都被拖住其他模块跟着遭殃数据库单库单表订单表和库存表挤在一起行锁竞争严重一个慢SQL就能拖垮全部写入业务改版必须全量发版测试要全量回归一个支付配置的改动往往要等好几个功能一起上线扩容只能整体扩容明明只有订单模块是热点却要把整个应用复制好几份资源利用率很低所以我把系统按照业务域拆成了七个微服务用户、赛事、场馆、订单、库存票务、支付、通知。每个服务独立数据库、独立部署、独立伸缩。订单和库存服务作为热点服务可以单独扩到多实例其他非热点服务保持低配置运行成本上更划算。微服务带来的额外收益是故障隔离。比如支付服务依赖第三方渠道偶尔会抖动但用户浏览赛事信息、查看座次图完全不受影响。这在单体架构里做不到一个第三方接口超时可能导致整个应用线程池耗尽。1.3 整体架构蓝图系统的分层结构用一句话概括请求从Nginx进入Spring Cloud Gateway网关网关做鉴权、限流和路由分发七个业务服务注册到Nacos并互相通过OpenFeign或RocketMQ通信数据层每个服务独立MySQL库Redis承担缓存和分布式锁RocketMQ负责异步解耦。当前端需要聚合数据时比如订单页同时展示场次信息、座位号、票价我们不采用跨服务实时查询而是在订单表里冗余一份“赛事快照”字段下单时把场次名称、比赛时间、座位区域这些信息直接写入订单。冗余数据虽然占用了一点存储但好处是查询链路极短不依赖其他服务也避免了分布式环境下多次远程调用的性能损耗。核心服务与职责分工可以参考这个表格服务名称核心职责关键数据user-service注册登录、实名认证、会员等级用户表、实名信息表match-service赛事信息、场次、战队、赛程赛事表、场次表venue-service场馆、区域、座位、票档场馆表、座位表、票档表order-service订单创建、订单状态流转订单表、订单快照表ticket-service座位锁定、库存扣减、出票核销库存表、座位状态表、票据表payment-service支付渠道对接、退款、对账支付流水表notify-service短信、站内信、App推送通知记录表2. 服务拆分边界怎么画2.1 从业务域出发的服务划分服务拆分的第一步不是画架构图而是梳理业务语言的边界。我一个一个盘业务流程发现选座购票的主链路天然跨越多个领域用户选座锁定座位然后订单服务创建待支付订单支付服务完成资金流转支付成功后库存票务服务正式出票最后通知服务给用户发取票提醒。每个环节都有自己的状态机和数据归属这就是DDD里说的限界上下文。我按照这个思路最终确定了七个服务每个服务只对自己的数据负责。比如seat的“锁定中”状态归ticket-service管订单的“待支付”状态归order-service管两者之间通过明确的接口协议联动而不是互相操作数据库。这里容易犯的错是服务拆得太碎。刚开始我们还想单独拆一个“优惠券服务”后来发现优惠券只在下单时被消费一次属于订单域的附属能力拆出去只会让一次下单变成四次远程调用。最终优惠券表放在order-service内部只是作为一张普通业务表来管理。2.2 订单、库存、座位三者的状态机设计整个系统最核心的部分就是这三个服务之间的状态联动我把完整流程定义为用户在前端选座并点击“锁定”ticket-service先把目标座位从“可售”改成“锁定中”同时创建一条库存占用记录。接着order-service创建订单状态为“待支付”并把座位信息、场次信息以快照形式写入订单表。用户支付成功后payment-service回调通知order-service订单状态更新为“已支付”随后通过RocketMQ发送“支付成功”消息。ticket-service消费到消息后将座位状态从“锁定中”改成“已售”生成电子票据。如果用户一直没有支付一个定时任务会扫描超过15分钟的“待支付”订单执行“超时关单释放座位”操作。座位状态我设计了四档可售、锁定中、已售、异常。其中“锁定中”是临时状态带15分钟有效期“已售”是终态不可逆。这个状态机同时存在于Redis和MySQL两层Redis保存短期状态用于快速查询MySQL保存最终状态用于对账兜底。一个关键细节座位释放不能直接删掉Redis里的key就完事必须同时更新数据库座位状态。因为我们遇到过Redis数据被清掉但数据库状态还是“锁定中”的情况结果那些座位永远无法被再次购买。所以现在定时释放逻辑以数据库为准Redis只是查询加速层。2.3 服务间通信同步还是异步服务通信的选型原则我踩过几次后才总结明白需要实时返回结果且链路短的调用用同步RPC比如“创建订单”必须等order-service返回订单号这时候用OpenFeign同步调用最直接。不需要实时返回、或者失败可以重试的操作用异步消息比如支付成功后的出票和通知。单纯查数据能避免跨服务就避免通过冗余快照字段满足。很多团队在这里会犯一个错误把所有跨服务调用都搞成异步觉得消息队列是万能解药。但异步会让业务链路变得难以追踪“用户下单后订单到底创建成功没”这个问题会变得很难回答。我的原则是同步调用控制在一次用户请求内不超过3次超过的话重新审视流程看能不能通过数据冗余或者中间状态化解。还有一种情况需要特别小心RocketMQ的消费是异步且可能乱序的。“支付成功”事件可能比“订单创建成功”事件先到达因为两个事件经过不同的Topic。解决方案是在消费端做状态机校验只有当前状态允许流转时才处理消息否则把消息延期重新投递。3. 关键技术选型与实现解读3.1 注册中心、配置中心、网关的选型SpringCloud体系里的组件更新很快Eureka已经停止大版本更新我最后确定用Spring Boot 2.7.18 Spring Cloud 2021.0.9 Spring Cloud Alibaba 2021.0.5.0这套组合。这个组合稳了非常久社区资料也齐全不建议一上来就追Spring Boot 3.x和JDK17因为Spring Cloud Alibaba对3.x的适配在不同版本间差异较大团队如果不够熟排查问题会很痛苦。注册中心和配置中心都用的Nacos。它的优势是把服务注册发现、配置管理集成在一个控制台上支持动态刷新配置改个限流阈值不用发版直接在控制台修改配置服务端能自动感知。网关选的是Spring Cloud Gateway而非Zuul。Gateway基于WebFlux性能比Zuul 1.x好很多而且原生支持Path、Method、Header等多种断言做路由规则非常灵活。我在网关上做了统一鉴权所有请求先过JWT校验再按路径分发到对应服务。网关还承担了第一层限流用的是令牌桶算法每个用户每秒最多放行一定数量的请求超出直接返回排队提示。Nacos的接入很简单核心配置就是这些spring: application: name: order-service cloud: nacos: discovery: server-addr: nacos-server:8848 config: server-addr: nacos-server:8848 file-extension: yaml3.2 分布式锁锁定座位不能有超卖售票系统的命根子是“一个座位不能卖两个人”。在单机架构里用synchronized或者Lock就能解决一个进程内的并发问题。但服务部署了多个实例后请求会负载均衡到不同JVM每个实例各持一把锁互相之间完全感知不到对方这时候超卖就发生了。我们第一版就栽在这个问题上库存扣减用了AtomicLong做本地计数压测时发现两个实例各卖了同一批座位的一部分数据库里出现重复订单。后来改成Redis分布式锁才把这个漏洞堵上。Redis分布式锁的最简实现是set key value ex time nx这一条命令通过SET key value EX 10 NX保证加锁和设置过期时间的原子性。但自己造轮子容易踩坑锁过期时间设置多长才合适业务执行超过过期时间怎么办锁重入怎么处理所以我直接用了Redisson它内部的看门狗机制会自动续期默认30秒业务没执行完锁就不会提前释放避免了“锁过期导致多个人同时操作同一个座位”的经典问题。核心加锁逻辑类似这样String lockKey seat:lock: seatId; RLock lock redissonClient.getLock(lockKey); boolean locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请重试); } try { // 业务操作创建订单、更新座位状态 doLockSeat(seatId, userId); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }有一点必须单独强调分布式锁释放的时机要在数据库事务提交之后不能在事务还在执行时就把锁释放了。否则另一个请求拿到锁后读到的事务数据可能还未提交还是会造成超卖。稳妥的做法是利用Spring的事务同步器在afterCommit回调里释放锁。即使有了分布式锁我依然在数据库层保留了乐观锁兜底。座位表里有一个status字段更新语句固定加条件UPDATE seat SET status 2, user_id #{userId} WHERE id #{seatId} AND status 1这里status 1表示“可售”更新影响行数为0说明座位已经被别人锁定。数据库的原子更新是最后一道防线分布式锁可能会因为各种极端情况失效但这条SQL只要执行成功就绝对不可能把同一个座位改两次。3.3 分布式事务从强一致到最终一致下单链路横跨ticket-service、order-service、payment-service数据一致性怎么做我前后试了两套方案。第一版用Seata的AT模式它通过全局事务ID把多个服务的本地事务协调成一个全局事务代码侵入小体验很好。但压测发现在抢票这种高并发场景下Seata的全局锁会持有数据库行锁较长时间吞吐量下降明显。而且AT模式对SQL有一些限制比如不能依赖数据库自增主键返回需要额外的undo_log表排查问题时链路比较长。第二版换成了RocketMQ事务消息 本地事务的组合这是目前订单系统的核心方案。流程如下order-service先向RocketMQ发送一条半消息此时消息对消费者不可见半消息发送成功后order-service执行本地事务创建订单记录本地事务执行成功提交半消息消费者可以开始消费本地事务失败则回滚半消息消息永远不会被投递万一本地事务执行过程中服务宕机RocketMQ会定时回查生产者确认本地事务状态后再决定投递还是丢弃这套方案的好处是两个服务之间没有远程同步事务不长时间占用数据库行锁吞吐量比Seata高。代价是数据一致性变为最终一致需要搭配定期对账机制来兜底。对售票系统来说用户支付订单到接收电子票之间有几秒延迟完全能接受运营可以接受“最终一致”但绝对不能接受“用户付了钱却没票”或者“一个座位两张票”。所以对账任务必须天天跑。3.4 缓存设计热点数据怎么扛选座页面的余票查询是系统里读压力最大的接口热门场次开票后用户会高频刷新座位图。数据库是撑不住这种读并发量的我在这一层做了三级缓存方案场次基本信息放Rediskey为match:info:{matchId}缓存5分钟过期时间加随机扰动防止雪崩。座位状态用Redis的Hash结构key为seat:status:{matchId}field是seatIdvalue是状态值这样前端刷新座位图时后端可以从Redis直接批量取出上千个座位状态不用查数据库。对于最热的座位区域比如选手正面方向的VIP区再加一层本地缓存虽然多机部署会有短暂不一致但在可接受范围内。缓存穿透问题在抢票场景特别明显脚本会构造大量根本不存在的seatId来刷接口。我的处理是参数校验 对不存在的座位缓存空值。缓存击穿则是某个热点key失效瞬间大量请求直接打到数据库解决方案是热点key永不过期由后台定时任务主动更新。3.5 限流与防黄牛前半程是技术问题后半程其实是业务问题。技术能做的有两层网关层的全局限流以及服务层的热点参数限流。网关层我用的Sentinel配合Spring Cloud Gateway对选座锁定、创建订单这种关键接口单独配置规则比如每个用户每秒最多2次请求超过就排队返回“前方拥挤”。热点参数限流更精细可以对同一个IP频繁切换多个场次ID的行为做限制这通常是脚本刷票的特征。黄牛防护不能只靠限流我额外做了设备指纹和风控标记前端采集设备信息生成指纹传给后端后端对同设备指纹的抢票频率做限制同一IP地址在短时间内注册大量账号的行为做风控拦截下单前要求完成滑块验证。这套组合无法彻底杜绝黄牛但能把纯脚本抢票的成功率降一个数量级。还有个大招是队列削峰。用户点击“立即购买”后不直接进入选座页而是先进入排队页前端通过WebSocket轮询排队状态。后端把购买请求先写入RocketMQ再由订单服务消费并处理。这种方式把瞬时洪峰变成了可控的排队数据库压力大幅降低用户体验也还过得去。4. 前端Vue架构与核心业务实现4.1 前端工程与动态路由前端不是单纯的“页面展示”用户端H5和管理后台是两套独立的Vue工程都基于Vue3 Vite TypeScript Element Plus Pinia这套组合。Vite在开发环境下的热更新速度比Webpack快很多这点对频繁调整选座交互的团队非常重要。Pinia做状态管理比Vuex更简洁TypeScript在维护大型票务前端时能减少大量低级错误。路由设计上用户端和运营端共用基础路由模型然后按角色差异化加载。普通用户只能访问赛事列表、选座、订单、票夹等功能运营管理员可以访问场次编排、票价配置、核销管理等页面。Vue Router的addRoute方法支持动态追加路由控制起来很方便const routes [ { path: /login, component: Login }, { path: /match, component: MatchList, meta: { auth: true } }, { path: /seat/:matchId, component: SeatSelect, meta: { auth: true } }, { path: /orders, component: OrderList, meta: { auth: true } } ] router.beforeEach((to, from, next) { const token localStorage.getItem(auth_token) if (to.meta.auth !token) { next(/login) } else { next() } })axios封装的思路也值得记录请求拦截器统一从Pinia读取token并加到Authorization头响应拦截器统一捕获401状态并跳转登录页。这样业务代码里不需要到处写token处理逻辑。4.2 实时选座交互的实现选座页是用户端最核心也最复杂的页面。我们用Canvas来绘制座位图因为需要支持滚动、缩放、拖拽DOM渲染几千个座位节点会非常卡Canvas在渲染性能和交互流畅度上明显更优。后端返回给前端的座位数据是一个结构化数组每个元素包含区域ID、排号、座位号、票档类型、当前状态。前端渲染时先按区域分组再逐排绘制状态字段控制座位颜色灰色代表已售橙色代表锁定中蓝色代表可售高亮边框代表当前选中。座位锁定成功后前端会立即把对应座位标记为锁定中防止其他用户在同一浏览器继续点击。同时前端会启动一个轮询任务每5秒向后端请求一次最新座位状态用增量数据刷新座位图。轮询比WebSocket简单而且选座场景下对延迟并不敏感5秒的更新间隔完全够用。一开始我们尝试过用WebSocket推送座位变化但后来发现问题很多瞬时大量请求放大推送压力、断线重连复杂度高、团队维护成本高。改回轮询后系统稳定性明显提升这个经验告诉我不需要为了技术炫技去引入不必要的复杂度。4.3 购票流程与支付对接购票流程的完整交互是选场次 - 查看座位图 - 选座锁定 - 创建订单 - 支付 - 出票。每一步都有对应的状态反馈。前端在“创建订单”这一步比较关键必须处理幂等问题。用户点击支付按钮时如果请求响应慢用户容易重复点击导致同一座位重复下单。解决方案是每次发起下单请求时生成一个全局唯一的幂等号后端Redis里记录幂等号和订单号的映射关系相同幂等号重复请求直接返回已有订单号而不是重新创建订单。支付环节用的是标准三方支付流程前端拿到订单号和支付参数调用支付SDK拉起收银台用户完成支付后跳转到支付结果页。但支付回调可能延迟所以支付结果页还会向后端轮询订单状态接口每3秒一次连续5次仍是待支付就提示用户主动确认。这个设计解决了“用户已经付款但页面没跳转”的恐慌感大幅减少客服咨询量。4.4 票据二维码与核销端设计出票后生成的电子票是一串带签名的字符串内容包含订单号、场次ID、座位号、随机盐服务端用HMAC-SHA256对这个字符串签名再Base64编码后生成二维码。核销时扫码端拿到内容先验签再查订单和票据状态三者都通过才算核销成功。为什么不能直接把订单号放二维码因为订单号是连续数字用户可以猜测并篡改没有签名保护的二维码可以被伪造成任意座位号。加盐签名后即使拿到了别人票据的二维码也无法修改内容重新生成有效票据因为签名密钥在服务端。核销接口需要防重复核销同一张票在Redis里setnx一个核销标记第一次核销成功后标记置为已核销同一张票再次扫码会直接拒绝返回“票据已使用”。这个逻辑同时能防截屏转发——票是谁的不重要只要先核销了后面的人用同一张二维码就无效了。5. 分布式环境下的常见问题与排查实录5.1 超卖事故从本地锁到分布式锁的修复实录线上第一次事故就出在超卖上。开票后2分钟运营后台显示某个票档卖出数量比放票量多了3张。当时查日志发现库存扣减用的是AtomicLong本地计数两个服务实例各维护一份计数每个实例卖了200张加起来就是400张而实际放票是397张。修复分三步走。第一步座位锁定操作全部加Redisson分布式锁第二步数据库座位表更新加上status条件做乐观锁兜底第三步压测300并发同时抢同一座位跑三次只产生一个有效订单数据完全正确。这个事故让我意识到一个道理本地锁在分布式环境下不是“可能失效”而是“必然失效”因为多个JVM之间没有任何共享内存。应对此类问题的经验是技术方案一定要有多级防御。分布式锁防止并发冲突数据库乐观锁防止最终数据异常两者叠加才能保证极端情况下数据仍正确。单靠任何一环都有理论上的漏网之鱼。5.2 支付回调重复通知引发的重复出票第二个典型的线上问题是支付回调。三方支付渠道为了保证通知成功会反复重试回调加上我们自己的网络超时重发同一笔订单在很短时间内被回调多次。第一版代码没有做幂等校验每收到一次回调就执行一次出票逻辑结果用户收到三张相同座位的电子票。修复方案是三层防护组合。数据库层面给订单表和支付流水表建立唯一索引由order_id和channel_trade_no组成重复插入直接报错。应用层面先查后改处理回调时先查询处理状态只有“待处理”状态才继续。最终出票操作改为消费RocketMQ消息消费端还会再查一次订单状态已经出票的直接跳过。5.3 定时任务重复执行两个实例互相拆台“释放超时未支付座位”的定时任务部署到多实例后也出过问题。两个实例在同一秒扫描订单表选出超时订单并释放座位结果把刚支付成功的订单也给释放了因为扫描时订单状态还没从“待支付”更新为“已支付”。这个问题的根源是定时任务没有做分布式互斥。最简单可靠的解法是引入xxl-job这类分布式调度框架。如果不打算引入新组件也可以在任务执行前尝试获取一个Redis锁只有拿到锁的实例才执行任务。方法很土但稳定有效我们线上就是先这么扛过来的。5.4 网关超时重试导致的重复下单这个坑更隐蔽。用户抢票时请求正好卡在网关超时网关层自动重试了一次用户那边看响应太慢也手动点了一次提交按钮结果同一个用户在同一个座位上生成了两个订单。定位过程很折磨人因为两个订单的userId和seatId完全一样只是订单号不同。后来排查到是Feign和网关的重试策略都开了。修复方案是三管齐下下单接口强制要求幂等号重复请求返回同一个订单号非幂等接口关闭自动重试前端按钮提交后进入loading状态禁止二次点击。现在这个经验也被我带到了其他项目里所有写接口默认都要考虑幂等。5.5 对账任务最终兜底永远不能省即使做了上面所有防护我依然留了一手每天凌晨跑一次对账任务扫描三个核心指标。第一订单状态为“已支付”但没有生成票据的记录第二支付流水表交易成功但订单表状态没有更新的记录第三座位状态为“锁定中”但超过30分钟仍然存在的异常数据。对账任务发现异常后自动生成补偿消息发送到MQ修复数据不一致问题同时输出报表供运营查看。对账任务本身比较耗资源我放在低峰期执行而且只扫描最近24小时的数据避免一次扫全表把数据库拖垮。对账机制的意义是给所有分布式一致性方案一个最终兜底它不一定能保证每次都在毫秒级恢复一致但能保证在用户可感知的范围内数据终会准确。做这套系统最大的体会是微服务架构本身不是一道数学题而是一道工程题。它解决了很多单体架构扛不住的问题但同时也把并发问题、一致性问题、排查问题变得更复杂。最值钱的经验不是背了多少框架源码而是每次线上事故后复盘出的这些坑——分布式锁失效、事务消息乱序、幂等遗漏每一条都能单独写篇事故报告。如果你也在做类似的票务系统建议一开始就把“防超卖、保幂等、定期对账”这三件事当成一等公民来设计等上线后再补成本要高好几倍。最后分享一个小技巧无论选座、抢购还是签到把“状态流转”抽成一个相对独立的核心模块所有业务方都集中调用同一个锁座、出票、释放能力出问题时排查范围会小很多这个设计让我在后续几次线上问题处理中都省了大量时间。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →