尧图精选

微服务架构下高并发抢票系统的设计与实践

🕒 发布时间:2026/10/2 1:55:53 📁 来源:尧图网络
1. 这场演唱会抢票到底难在哪一晃两年过去这个项目我从零搭到上线又陪着它扛过三轮热门歌手开票算是把微服务架构、分布式锁、分布式事务这些概念真正“打”了一遍。今天把这套演唱会在线票务预订平台的技术方案完整梳理出来包括拆分思路、核心代码逻辑、压测数据和踩过的坑。如果你正准备做类似的高并发交易系统或者正在准备微服务相关的面试这篇文章应该能省你不少时间。先说项目定位一个面向C端的演唱会票务预订平台前端基于Vue构建用户端和管理后台后端是SpringBootSpringCloud全家桶的微服务架构。核心业务覆盖用户注册登录、演唱会信息展示、在线选座、抢票锁座、订单支付、出票凭证、退票改签、管理后台的场次/票价/库存管理。技术侧的关键词可以用八个字概括微服务、分布式、高并发、最终一致。为什么会选微服务架构而不是一个SpringBoot单体一把梭因为在真正的抢票场景里流量不是平均分配的——开票瞬间几十万人涌进同一个接口落库、扣库存、生成订单全挤在一个应用里单体就算水平扩容也只是“大一号的单体”热点资源依然被死死卡住。微服务的意义不在于“服务多”而在于把热点业务独立出来单独扩容、单独限流、单独降级。就拿这个项目来说如果抢票接口和用户查询接口混在一个服务里某明星开票时查询流量直接把服务拖死抢票也跟着全挂这是不能接受的。整套系统的服务拆分如下这也是面试里最常被问到的“微服务拆分边界”问题服务名称核心职责独立拆分原因user-service注册登录、会员等级、实名认证基础数据独立被多服务依赖show-service演唱会场次、场馆座位图、票价档位读多写少适合缓存优化ticket-service座位锁定、库存扣减、余票查询热点集中需要独立压测与限流order-service订单创建、状态流转、超时处理核心交易链路事务边界清晰payment-service对接第三方支付、回调处理、对账涉及资金安全必须物理隔离gateway统一路由、鉴权、限流流量入口横切逻辑统一收口这样拆分之后开票秒杀期间只需要对ticket-service和order-service做大规模扩容show-service这类读服务靠缓存扛完全不浪费机器资源。2. 核心技术选型与关键方案解析2.1 SpringCloud组件选型不是越新越好而是越稳越好微服务不是把服务拆开就完事服务发现、配置管理、网关路由、熔断限流这些基础设施缺一不可。这个项目用的是SpringCloud Alibaba体系核心组件是NacosSentinel网关用SpringCloud Gateway远程调用用OpenFeign。为什么选Nacos做注册中心和配置中心而不是EurekaSpringCloud Config两个原因。第一Nacos同时支持注册中心和配置中心省掉一套独立部署的Config Server运维成本低第二Nacos自带控制台服务上下线、配置变更、命名空间管理都是可视化操作排查问题方便很多。Eureka已经进入维护模式新项目没必要再选。Sentinel负责限流熔断它比Hystrix强在支持热点参数限流。抢票场景里同一个场次ID就是热点参数我可以针对场次维度做精细化限流而不是对调用来源做一刀切。流控规则用Nacos配置中心动态下发改规则不用重启服务这在开票前调整限流阈值时非常实用。Gateway在这一层做统一鉴权和灰度发布。JWT校验放在网关统一处理下游服务不再关心“这个用户是谁”只需要从请求头里取userId。当时灰度发布的需求是用网关的权重路由实现的新版本服务先挂10%的流量跑一段时间确认稳定再逐步调大权重避免全量上线出问题。2.2 库存扣减与分布式锁超卖是绝对红线票务系统的第一红线就是不能超卖。座位就那么多卖出去了就必须有票可出。这个问题不能用数据库的行锁硬扛——高并发下数据库行锁会导致严重的锁等待和连接池耗尽必须用Redis做前置拦截数据库做最终落账。先明确扣减模型。每个场次的座位库存放在Redis里key设计为show:stock:{showId}value是剩余可售数量。扣减操作用Lua脚本保证原子性这是整个抢票链路最核心的一段逻辑-- KEYS[1]: show:stock:{showId} -- ARGV[1]: 本次购买数量 -- 返回值: 1表示扣减成功-1表示库存不足 local stock tonumber(redis.call(get, KEYS[1])) if not stock then return -2 -- 缓存不存在触发回源加载 end if stock tonumber(ARGV[1]) then return -1 -- 库存不足 end redis.call(decrby, KEYS[1], ARGV[1]) return 1为什么必须用Lua脚本而不是先get再decrby因为Redis是单线程执行命令Lua脚本能保证check和decrby这两步操作之间不会被其他命令插入从机制上杜绝并发超扣。如果用Java代码先读库存再判断再扣减两步之间发生并发请求两个线程都读到库存为1的时候就会都执行扣减库存直接变成-1超卖事故就这么来的。分布式锁在这个项目里没有用在库存扣减上而是用在座位维度。演唱会选座和普通商品秒杀有个本质区别用户选的是具体座位同一个座位不能被两个人同时锁定。这里的锁key设计为seat:lock:{showId}:{seatId}用Redisson实现锁粒度精确到单个座位。Redisson比手写SETNXEXPIRE靠谱在它内置了看门狗机制。手写分布式锁最经典的坑是业务执行时间超过锁的过期时间锁自动释放了另一个线程拿到锁进来第一个线程还没执行完就删锁把别人的锁误删了。Redisson的看门狗默认每10秒自动续期一次只要业务线程活着锁就不会过期。这里有个参数要记牢lock(leaseTime, TimeUnit)如果手动传了leaseTime看门狗就不会工作只有不传leaseTime时才会启用自动续期默认30秒过期。建议生产环境用lock(10, TimeUnit.SECONDS)这种手动续期方式配合业务完成后的finally释放逻辑更可控。数据库层的最终防线是乐观锁。座位表加version字段更新时UPDATE seat SET status1, versionversion1 WHERE seat_id? AND version?影响行数为0说明版本冲突本次锁座失败直接返回“座位已被抢走”。三层防线层层递进Redis Lua拦截流量、Redisson锁保证互斥、数据库乐观锁兜底把超卖概率压到理论上的零。2.3 分布式事务不能强一致但要最终一致订单、库存、支付分布在三个服务里就必然面对分布式事务问题。这个项目的核心原则是交易链路允许短时间的中间状态但必须保证最终一致。订单创建这个动作我的方案是本地消息表MQ异步处理。用户发起抢票时order-service先在自己的数据库里创建订单状态为“待支付”和一条消息记录状态为“待发送”这两个操作在同一个本地事务里要么都成功要么都失败。本地事务提交后把消息发送到RabbitMQ由ticket-service监听消费执行真正的库存扣减和座位锁定。如果ticket-service处理失败消息进入重试队列重试达到上限就落入死信队列由定时任务扫描本地消息表做补偿。有人会问为什么不用SeataSeata的AT模式确实能让分布式事务用起来像本地事务但代价是性能损耗和全局锁冲突。抢票场景每秒几千次下单Seata的全局锁会成为新的瓶颈点。更关键的是业务上根本不需要订单创建和库存扣减强一致——用户下单后订单是“待支付”状态就算扣库存失败订单会在超时时间到达后自动关闭对用户和系统都没有伤害。“待支付→扣库存失败→自动关单”这个中间态是业务可以接受的那就不值得引入全局事务。需要严格一致的是支付成功后的出票环节。用户付了钱必须保证能拿到票这里用的是事务消息方案payment-service收到支付回调后发送半消息到RocketMQ/RabbitMQ消息携带支付结果order-service消费到消息后更新订单状态为“已支付”并同步调用ticket-service生成电子票。如果这一步失败消息会被MQ重试直到成功为止。这套方案的核心思想是用消息队列的事务特性替代分布式事务把“必须保证”的语义转化为“MQ保证消息至少被成功消费一次”。2.4 缓存策略热点数据怎么扛住读流量为了扛住开票瞬间的读流量需要多级缓存来分摊压力。第一级是Caffeine本地缓存每个实例自己维护一份缓存演出详情、座位图这类变化频率低的数据过期时间设5分钟TTL可以错开一点避免同时失效引发缓存雪崩。第二级是Redis缓存缓存场次信息、余票数量、座位状态。读请求先查本地缓存本地没有就查RedisRedis没有就查数据库并回填。这里要特别注意缓存穿透问题。攻击者或者异常用户频繁查询不存在的场次ID请求会直接打到数据库。我的处理方法是在缓存里放空值key存在但value为null过期时间设短一些比如30秒配合布隆过滤器拦截不存在的ID。布隆过滤器的误判率设为1%把场次ID全量加载进去如果布隆过滤器判断不存在就直接返回连Redis都不查。余票数量的查询是另一个典型的缓存击穿场景。某场演唱会开票瞬间余票数量的缓存刚好过期大量请求同时回源数据库直接把数据库压垮。解决方案是互斥锁重建缓存缓存过期的瞬间只有一个请求能拿到重建锁其他请求短暂阻塞后重试读取或者先用本地缓存中的旧值兜底。具体参数上重建锁的超时时间设置为2秒重试次数3次实测效果稳定。3. 核心业务链路实操从抢票到出票3.1 抢票主流程拆解整条抢票链路可以拆成八个步骤按顺序分别是发起抢票→网关限流→令牌校验→库存预扣→创建订单→发送MQ→锁定座位→返回结果。前端用户在点击“抢票”按钮的那一刻实际上只关心两个问题有没有票、抢没抢到。后端要做的就是在这两个问题背后串起一条完整、可靠的链路。第一步是网关限流。Gateway配合Sentinel针对/api/ticket/grab这个接口设置了集群QPS限流阈值根据压测结果动态调整。这里用的是Sentinel的热点参数限流能力针对showId维度的QPS做限制避免某个热门场次把整个网关打爆。第二步是库存预扣。请求进入ticket-service后先执行前面那段Lua脚本在Redis里扣减场次总库存如果返回-1直接返回“已售罄”。这一步是整个链路的第一道闸门拦截掉了绝大部分无效请求。第三步是座位锁定。如果是选座模式用户已经在前端选好了座位请求里带着seatIds如果是指定区域随机分配模式系统从空闲座位池里随机选取。这一步用Redisson按座位逐个加锁锁成功就把座位状态从“可选”改为“锁定中”。第四步是订单创建。锁座成功之后通过OpenFeign调用order-service把订单主数据、座位信息、票档价格一并传过去。这里有个设计细节订单号不能依赖数据库自增因为分布式环境下多服务同时生成订单号会重复我在order-service里用雪花算法生成19位订单号保证全局唯一。订单初始状态是“待支付”设置支付超时时间为15分钟。第五步是发送MQ消息。订单创建成功后order-service发送一条消息到RabbitMQ内容包含订单号和座位ID列表。ticket-service消费这条消息后把Redis中扣减的库存和数据库里的座位状态同步更新。这步是异步的所以理论上存在一个极短的窗口期——订单创建成功但座位数据库还没更新。这个窗口期业务上完全可以接受因为库存已经在Redis里扣掉了座位不会重复售卖。第六步是返回结果。前端收到“下单成功请在15分钟内完成支付”的响应开始倒计时。整个链路从用户点击到响应返回压测环境下的平均耗时控制在800毫秒以内不含支付用户基本无感知。这套流程走完有一个细节容易被忽略但必须强调前端抢票按钮必须做防重复提交。用户连点三次抢票后端会收到三次请求。我的处理方式是在Vue端用loading状态锁住按钮同时后端在网关层做幂等拦截同一个userIdshowId在1秒内的重复请求直接返回第一次的处理结果。后端兜底比前端拦截更重要因为绕过前端直接打接口的场景在真实环境里从来不缺。3.2 订单超时释放与状态机设计订单创建后15分钟内未支付座位必须释放回库存池否则用户占着座位不付款真正想买的人买不到平台方损失的是真金白银。这个场景的实现方案是延迟消息。RabbitMQ的延迟消息插件rabbitmq-delayed-message-exchange在订单创建时发送一条延迟15分钟的消息消息体里带订单号。15分钟后消息投递到消费者消费者检查订单状态如果还是“待支付”就执行关单操作更新订单状态为“已关闭”释放Redis库存incrby更新数据库座位状态为“可选”。这里有一个经验主义的坑延迟消息的插件版本必须和RabbitMQ版本严格对应否则exchange创建会报NOT_FOUND。我当时踩过这个坑RabbitMQ是3.8.x版本配了3.7.x的延迟插件控制台创建exchange一直不成功排查了整整一天才发现是版本兼容问题。订单状态机要提前设计清楚不能靠if-else硬写。我的状态枚举是CREATED(待支付) → PAID(已支付) → ISSUED(已出票)CREATED → CLOSED(已关闭)PAID → REFUNDED(已退款)。状态流转集中在order-service的OrderStateMachine类里每个状态转换都有前置校验非法流转直接抛异常。这样设计的好处是后期加需求比如退票改签、转赠只需要在状态机里加边不用改动核心业务代码。3.3 Vue端选座交互与路由设计前端用的是Vue3VitePiniaElement Plus选座页是交互最复杂的部分。演唱会座位图和普通电影院的固定座位不一样不同场馆的座位布局差异很大有的场馆有看台区、内场区、VIP包厢每个区域的行列数都不规则。选座组件我用Canvas绘制座位图不从后端拿图片资源而是拿到场馆的座位布局JSON数据后动态渲染。JSON里每个座位包含seatId、row、col、areaId、status五个字段status取值有可选、已售、锁定、预留四种。Canvas渲染的好处是前端可以非常灵活地处理选中态高亮边框、售空态灰色不可点、锁定态红色跳动这些视觉状态的变化。座位状态的实时同步是选座体验的关键。两个人同时在看同一个区域一个人锁定了某个座位另一个人应该很快看到这个座位变成锁定状态。我的实现方案是WebSocket推送ticket-service在座位状态变化时发送消息到MQ一个专门的状态推送服务消费消息通过WebSocket推送到对应的场次频道前端收到消息后增量更新Canvas上的座位状态。轮询方案在这个场景里不可取坐席状态变化频繁轮询间隔太短会打爆接口间隔太长用户看到的是过期状态。Vue端路由设计要重点说动态路由。普通用户和管理员看到的菜单和页面完全不同如果全部配在静态路由里一来不优雅二来有越权风险。我用的是登录后根据角色动态生成路由的方案用户登录成功后后端返回该用户的权限标识列表前端用addRoute方法动态注册路由表。管理员路由包含场次管理、票价管理、订单查询、数据报表普通用户路由包含首页、票务列表、我的订单、个人中心。这个方案的关键点是在全局前置守卫里做判断router.beforeEach中先检查store里有没有路由表没有就调接口动态生成并next({ ...to, replace: true })避免路由跳转后白屏。管理后台的数据大屏也用Vue写统计实时销售额、各场次出票率、用户转化漏斗数据由后端聚合接口提供每5秒轮询刷新。图表用ECharts一开始考虑过各种大屏框架最后发现ECharts的定制能力最灵活钻取、联动、动画都支持维护成本也低。3.4 管理后台与数据统计管理后台的核心操作是场次配置和票价策略。运营人员创建一个演唱会时需要配置基本信息艺人、时间、场馆、票档看台票、内场票、VIP票、每个票档的价格和库存然后上传座位图。前端座位编辑器支持按区域批量设置座位状态可选/预留/锁定这个功能最常用的场景是合作方提前留票——比如赞助商要预留100张内场票运营人员直接在座位图上把对应座位拖进预留池就行。数据统计这边重点做了三块实时销售看板、历史订单分析、用户画像标签。实时销售看板通过聚合接口查询Redis中的实时销售数据配合定时任务把数据落库做持久化。用户画像标签是从订单表里聚合出来的比如“VIP常客”“看台偏好型”“开票即抢型”这些标签用于后续的精准营销推送。4. 压测调优与上线运维心得4.1 压测方案与性能指标上线前压测是必须做的不做压测就上线抢票活动等于裸奔。压测工具有很多当时选了JMeter因为图形界面方便调试又能脚本化跑回归。压测的核心目标是回答三个问题单机QPS上限是多少网关限流阈值应该设多少数据库连接池要配多大。压测场景设计成三组抢票接口峰值压测模拟开票瞬间流量、订单查询混合压测模拟正常存量流量、全链路回归压测模拟真实用户行为。机器配置是云服务器8核16Gticket-service部署4个实例order-service部署4个实例。压测结果记录如下场景线程数QPS平均响应时间TP99响应时间错误率抢票接口单实例3001520148ms260ms0.02%抢票接口4实例12005860165ms305ms0.05%混合场景全链路8003120380ms720ms0.08%基于这份数据4个实例的集群QPS上限定在5000左右预留20%冗余Sentinel的集群阈值就设为5000。数据库连接池大小调整成order-service的HikariCP配置maximum-pool-size20这是个反直觉的数字——并不是连接数越多越好20个连接配合高QPS比100个连接跑得更稳因为连接太多会增加数据库的上下文切换开销。4.2 几个最值得的调优点第一个是Redis连接池参数。Lettuce连接池默认参数在高并发下并不理想我把初始连接从5调到20最大连接从20调到100最大等待时间从默认调整到200ms。调完后的效果是抢票高峰期Redis命令的响应时间从平均3ms下降到0.8ms效果极其明显。第二个是Feign的超时配置。OpenFeign默认的connectTimeout和readTimeout都是1秒但下单链路里Feign要调用order-service执行数据库写入一旦数据库突发慢查询1秒超时就会导致大量报错用户看到的就是“抢票失败”。调到3秒/5秒配合重试机制后错误率下降了一个数量级。但要注意写接口不能开重试因为重试会导致重复下单读接口可以开重试因为幂等。第三个是MQ消费者的并发度。RabbitMQ消费者默认并发数是1即一个线程处理消息。当时订单消息大量堆积我调concurrency10消费者并发数提高到10同时把prefetch设为20一次性从队列拉取20条消息批量处理。配合Spring Boot的queueRejectedMessage配置消息处理能力从每秒500条提升到4200条。4.3 上线后的运维经验上线之后重点盯三个监控指标Redis命中率、MQ消费积压数、Sentinel降级次数。Redis命中率低于95%说明缓存设计有问题需要排查是否有大量Key没有缓存或者缓存过期策略过于激进。MQ消费积压数如果不为0且持续增长说明消费者处理速度跟不上生产速度需要扩容消费者实例或优化消费逻辑。Sentinel降级次数如果太高要么是后端服务出现性能瓶颈要么是限流阈值设置过低导致正常流量也被拦了。有自己的监控也要给业务方提供“开票活动应急预案”。我的预案分了三档QPS超过阈值时自动触发Sentinel限流保护后端不死订单服务出现大面积超时时会触发Sentinel熔断熔断30秒后自动恢复如果高峰期数据库连接池打满手动切换到只读从库兜底读流量等高峰过去再切回。这三档预案在实战里演练过两次每次都能在3分钟内完成切换损失控制在可接受范围。5. 踩坑实录与高频面试难点梳理5.1 常见问题速查表问题现象根因分析解决方案库存扣成负数非原子扣减check和decrby分离Redis Lua脚本保证原子性座位被重复锁定锁key设置错误锁粒度过粗或过细锁key精确到showIdseatId支付成功但不出票事务消息消费失败未做重试引入死信队列定时任务补偿订单积压在RabbitMQ消费者并发过低调concurrency和prefetch参数开票瞬间接口超时数据库连接池被查询打满读走Redis缓存只读从库兜底Vue选座白屏动态路由未注册完成就跳转beforeEach中await路由初始化本地缓存与Redis不一致更新DB后未同步清理缓存采用Cache-Aside模式先更新DB再删缓存定时任务重复执行多实例部署未做任务分发用Redisson分布式锁控制定时任务5.2 分布式锁的经典坑分布式锁面试题是微服务面试里的重灾区这里展开三个高频考点。第一个是锁的粒度。锁粒度决定了并发度锁得越粗并发越低锁得越细管理越复杂。抢票场景的锁粒度应该精细到座位seatId维度而不是场次showId维度。如果对整个场次加分布式锁所有同时段的选座请求全部串行TPS直接从几千掉到几百。第二个是锁的续期与误删。前面提过Redisson的看门狗机制这里补充一个实际案例。有一次我把锁的过期时间手动设为10秒但业务中要调Feign同步调用支付服务最慢一次业务执行了12秒锁在第10秒就自动释放了结果另一个线程进来了两个线程同时操作同一个座位造成数据覆盖。排查过程很痛苦最后在日志里发现加锁和真正执行业务逻辑之间居然有2秒的网络开销才意识到锁过期时间设置要比业务最坏情况长。这个案例的启示是分布式锁的超时时间必须大于业务最大执行时间不能想当然按平均值设。第三个是锁的公平性。Redisson默认是非公平锁后来的线程可能比先来的线程先拿到锁。在抢票场景里这其实没问题因为用户不关心“先来后到”的顺序只关心能不能买到。但如果做的是排队系统或者预约系统就需要用tryLock(waitTime, leaseTime, TimeUnit)加公平锁模式确保先请求的线程先获得锁。5.3 缓存三大难题的应对缓存穿透、缓存击穿、缓存雪崩是Redis面试的三大必问考点在这个项目里都有真实场景发生。缓存穿透是我上线第一天就遇到的。有人写了个脚本循环查询不存在的showId请求全部穿透Redis打到数据库数据库CPU直接飙到90%。解决方案前面说过布隆过滤器空值缓存双管齐下。布隆过滤器放在网关层做粗粒度拦截空值缓存放在Redis层做细粒度兜底。缓存击穿发生在开票瞬间。某热门场次的详情缓存设了10分钟过期恰好开票后第10分钟缓存失效而这时候正有大量用户查询这个场次的余票请求瞬间穿透到数据库。除前面提到的互斥锁重建方案外还有一个更简单的开关逻辑过期。Redis里存的数据不过期而是额外存一个逻辑过期时间戳比如缓存Redis里的value是{data: {...}, expireTime: 1735000000000}。查询时发现逻辑过期了先返回旧数据同时派发一个异步请求去数据库更新缓存。这样用户的响应时间永远是快的代价是数据可能短暂过期几秒在票务场景完全可以接受。缓存雪崩是大量key在同一时间过期。当时我踩过一次每天晚上零点整所有票档价格的缓存集体失效数据库在零点过一秒被瞬时打满。解决方案是设置过期时间时加随机数比如基础过期时间10分钟随机加1~3分钟让key的过期时间错开。5.4 消息幂等与重复消费MQ消息的“至少一次”投递语义决定了消费者可能会收到重复消息如果处理逻辑不是幂等的就会重复扣库存、重复发确认消息。这个问题必须在消费者配置里直接解决。我的方案是用消息唯一键数据表唯一索引双保险。每条消息带一个全局唯一的消息IDUUID消费者在处理消息前先查一张msg_processed表如果消息ID已存在就跳过不存在才执行真正的业务逻辑并在同一个本地事务里把消息ID插入msg_processed表。数据库的唯一索引兜底防止并发场景下两个线程同时判断“消息不存在”。这套方案实现简单不需要引入额外的分布式组件可靠性已经足够。财务对账的幂等性更不能马虎。支付回调消息可能被推送多次如果每次都同步调用ticket-service生成电子票用户会莫名其妙收到两张票。处理方式是先查订单状态如果订单已经是“已支付”就返回成功但不重复出票。“幂等性靠状态判断”这个原则在涉及资金和资产的业务里必须严格执行。6. 最后说点项目之外的心得回头看这个项目我最深的体会是微服务架构的成败不在于用了多新的组件而在于对业务场景的理解深度。分布式锁、分布式事务、缓存策略这些都是“术”真正决定系统能不能扛住流量的是“道”——你知道瓶颈在哪知道哪些地方可以接受弱一致知道哪些资源应当优先保护。如果你正准备自己搭一个类似的项目我的建议是不要一上来就追求大而全的微服务架构。先把单体跑通把业务链条理清再根据真实压力点拆分服务。我当时如果直接照搬网上的微服务教程不知道会绕多少弯路。技术选型上SpringBootVueSpringCloud这套组合到今天依然是Java生态里最成熟、社区资料最全的方案遇到问题搜一搜基本都能找到答案坑也大多被人踩过了启动成本低得多。最后再分享一个小技巧所有核心接口的参数校验、幂等键、耗时日志一定要在最开始就做好不要等出了事故再补。线上排查问题的效率往往取决于打点日志的完整度。我后来在网关层给所有请求都打了日志包含userId、showId、耗时、状态码排查问题时直接按时间线拉日志比在代码里现加日志快十倍。这个习惯让我在后续的几次故障中都能在5分钟内定位到根因省下的时间远远超过写日志那点成本。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →