基于SpringCloud+SSM的演唱会抢票系统:高并发秒杀架构实践 不知道你有没有遇到过这种场景演唱会门票开售那一刻页面卡死、转圈、点进去就提示系统繁忙等好不容易刷进去票已经显示售罄。用户骂骂咧咧开发压力也大。其实站在后端视角抢票系统的本质就是一个高并发秒杀场景难点不在功能多而在“瞬时流量远超系统常态容量”时如何保证不超卖、不崩溃、还能让一部分用户真正买到票。最近我在梳理一套“基于JavaSpringCloudSSM的分布式演唱会抢票系统”正好借此机会把这类系统的技术选型、核心流程、落地细节和踩坑记录完整拆一遍。这里面的不少解决思路不只适用于抢票凡是涉及电商秒杀、限量预约、活动报名这类“短时高并发有限资源分配”的场景都可以直接复用。1. 这个项目到底在解决什么问题1.1 抢票场景的技术本质是什么先把话说直白演唱会抢票系统本质上就是“秒杀系统”的一个分支。秒杀场景有三个非常鲜明的特征和普通业务完全不一样。第一是瞬时流量巨大。开售时间一到几万甚至几十万人同时涌进来请求曲线在几秒钟之内直接拉满。第二是资源极度有限。一个场次可能只有几千张票但参与抢票的用户数远超票数这就意味着绝大多数请求注定是要失败的系统必须想办法让这些失败的请求尽快结束而不是一直占用资源。第三是数据一致性要求高。同一张座位票不能卖出两次库存扣减和订单生成必须严格匹配一旦超卖后面就是一堆退款和客诉。这三张特征叠加在一起会让传统的单体应用非常吃力。普通购票业务流程是“查库存—插入订单—更新库存”在低并发下完全没问题但一旦并发上来就会出现两个请求同时读到同一个剩余库存然后都认为自己可以下单进而导致超卖。而且数据库的连接池、锁机制、事务日志在集中更新同一行数据时会产生大量的锁等待和性能损耗最终把整个服务拖垮。1.2 单机系统为什么扛不住这个场景很多人喜欢问一个普通的Web应用加个负载均衡多部署几台机器不就能扛住高并发了吗理论上是但实际没有这么简单。以一台八核十六G的云服务器为例Tomcat的默认线程池大概在两百左右当同时到达的连接数超过这个数字后多余的请求只能排队等待用户感受就是页面一直在转圈。数据库层面更明显MySQL的InnoDB行锁在集中更新同一个场次的库存时会形成严重的锁竞争。我这边在某高校的模拟项目X上做过压测单机加单库一个抢票接口在每秒大约三四百个请求的时候就已经出现大量的超时和行锁等待。而改成分布式方案后把流量入口、库存扣减、订单处理拆分到不同服务与不同实例库存预扣提前放到Redis层处理整体吞吐量可以做到两千加。说白了一台机器的CPU、内存、线程池和数据库连接数都是有限资源单机方案在架构上就封死了系统的上限。1.3 这套项目适合谁来学习参考如果你手头正好要做毕设分布式方向或者高并发方向通常是很好的选题而“演唱会抢票系统”这种题目既贴近生活又覆盖了微服务、缓存、消息队列、分布式锁等一系列成熟技术点做出来之后整个技术栈都有实际落点不是空架子。如果你是在准备Java后端面试那更值得花时间把这类系统吃透。面试官问高并发问题时最常见的追问就是“如何防超卖”“为什么要用MQ削峰”“Redis在秒杀场景里承担什么角色”。你如果真把一个抢票系统的完整链路做大做过这些问题都能结合实际经验来答而不是背八股。如果你已经工作项目中正要从单体向微服务过渡这套系统的拆分思路也很有参考价值。服务怎么拆、流量怎么挡在数据库前面、哪些地方必须用分布式事务、哪些地方用最终一致性就够了——都可以在这类项目里找到答案。2. 技术选型拆解为什么是SpringCloudSSM这套组合2.1 选型之前的核心矛盾拿到一个项目第一步不是急着写代码而是先想清楚技术选型的逻辑。这套系统用到的关键词很明确SpringCloud、SSM还有“分布式”。第一次看到这个组合的人可能会疑惑SpringCloud是微服务解决方案SSM是经典的单体分层框架这两者放在一起不奇怪吗其实一点也不奇怪。SSM代表Spring、SpringMVC、MyBatis这三个组件它们负责单个服务内部的分层处理Controller接收请求、Service实现业务逻辑、Mapper完成数据库读写。SpringCloud则是解决服务之间的协作问题服务如何注册、如何被发现、如何路由、配置如何管理。可以被理解为SpringCloud负责把系统“切开”而SSM负责保证切开的每一块能独立运行。这个组合的好处很明显。对大多数中小团队或者个人开发者来说它是一个门槛较低的搭配——SSM本身生态成熟、资料丰富遇到问题很容易在网上找到方案同时SpringCloud把微服务治理的问题接住了不需要自己开发一套服务发现和网关。在“能不能快速落地”这件事上这套组合非常务实。2.2 SpringCloud里的关键组件到底怎么用真正落到项目里SpringCloud不是一个大而全的东西而是由一组子项目组成。在抢票系统里实际高频用到的组件其实就那么几个。第一个是注册中心。早期项目常用Eureka现在新项目用Nacos的比较多因为Nacos集成了注册中心和配置中心两个功能一个组件解决两个问题而且控制台界面清晰服务上下线一目了然。抢票服务的多个实例启动之后都会注册到Nacos上调用方只需要从注册中心拿实例列表不需要硬编码IP和端口。第二个是网关。Spring Cloud Gateway负责统一流量入口可以在这个层面做登录校验、请求限流和简单的前端防护。抢票请求先打到网关再路由到对应的服务避免每个业务服务都暴露自己的地址。第三个是远程调用组件。一般用OpenFeign它是一个声明式的HTTP客户端定义一个接口加几个注解就能调用远程服务比手动拼接RestTemplate代码干净很多。OpenFeign在底层集成了负载均衡调用某个服务的时候会自动按照负载策略选择实例。有人可能会问为什么不直接用DubboDubbo在性能上确实有优势但SpringCloud和Spring生态的贴合度更高对新手更友好配置约定也比Dubbo更统一。毕设和中小项目里优先选SpringCloud完全够用不需要为了追求“更专业”去引入额外的复杂度。2.3 SSM这种经典分层到底过时没有直接说结论不过时。很多电商和金融系统的核心业务内部依然是SpringMVC加MyBatis的分层结构。这类系统追求的不是技术上的新鲜感而是稳定性和可维护性。MyBatis对复杂SQL的控制力很强尤其是在抢票系统中要处理库存扣减这类对SQL有精确要求的场景用MyBatis直接写SQL会比JPA的自动映射更直观、更可控。在微服务架构里每个服务内部继续用Controller-Service-Mapper这种分层本身就是最稳妥的做法。当你把服务拆出来之后每个服务的结构依然是清晰的出现问题的时候顺着Controller到Service再到Mapper一层层查下去定位非常快。所以这套组合不是“老技术硬凑在一起”而是一套经过大量生产环境验证过的稳定搭配。2.4 服务与服务之间的边界怎么划分布式系统里最忌讳的一点就是“为了拆而拆”。服务拆得太细部署和运维成本会直线上升拆得太粗又体现不出分布式的优势。对演唱会抢票这个业务场景来说比较合理的拆分方式是围绕“领域”来做。大致可以拆成这么几个服务用户服务负责注册、登录、实名信息演出服务负责演唱会信息、场次、座位图库存服务负责票仓与锁座订单服务负责订单创建和状态流转支付服务负责支付回调与结果处理。再配合一个网关作为所有请求的统一入口。数据表也要跟着服务域拆开比如用户表归用户库、演出表归演出库、订单表归订单库不能所有表还堆在同一个库里否则服务拆了但数据没拆最终性能还是卡在数据库那里。不过在动手拆服务之前我个人建议先把业务逻辑想清楚再决定服务边界。一个比较实用的判断标准是如果两个功能模块需要频繁地同步调用和强一致事务那它们更适合放在同一个服务里如果它们之间只是通过数据接口或消息来协作那就适合拆成两个服务。3. 抢票核心流程与关键落地细节3.1 一次抢票请求从进入到完成经过了什么可以先把完整的请求链路在脑子里过一遍。用户在前端点击“立即抢票”之后请求首先到达Spring Cloud Gateway网关层面先做一些基础校验和限流防止恶意流量直接打到业务服务。然后请求被转发到库存服务的抢票入口。库存服务收到请求后先做用户维度的校验比如当前用户是否已购过该场次、当前时间是否在抢票时间内、用户是否在黑名单中。校验通过后对Redis中的库存进行预扣减这里必须保证原子性。扣减成功的请求才会发送一条消息到消息队列由订单服务异步消费真正在数据库中创建订单。订单创建完成之后状态是“待支付”用户在规定时间内完成支付支付回调更新订单状态同时锁定对应的座位。这套流程的核心思想叫“异步削峰”。抢票瞬间的高并发请求其实大部分是无效请求因为票就那么多不可能让所有用户都买到。如果让每一个请求都直接落到数据库数据库很快就会被拖死。正确的做法是把流量在Redis和MQ这一层先挡掉大部分数据库只需要处理真正“抢到了”的那一小部分请求。3.2 库存预扣减为什么必须用Redis加Lua脚本库存扣减是整个抢票系统最关键的技术点没有之一。它的难点在于判断库存和扣减库存这两个操作必须是一个原子操作否则在高并发下一定会出问题。举一个典型的错误场景。假设剩余库存只有1张票两个用户同时发起抢票请求都先执行“查询剩余库存”的操作结果两个请求都看到库存为1都认为可以购买然后都执行了“库存减一”的操作最终库存变成-1也就是超卖。解决办法很多但最优雅的是使用Redis配合Lua脚本。Lua脚本的核心逻辑可以写成这样local stock tonumber(redis.call(get, KEYS[1])) if stock nil then return -2 end if stock 0 then return -1 end redis.call(decrby, KEYS[1], ARGV[1]) return stock - tonumber(ARGV[1])这段脚本先读取当前库存判断库存是否存在判断库存是否足够最后再执行扣减。注意这里的关键是Redis会保证整个Lua脚本在执行过程中不会被其他命令插入也就是说“判断加扣减”是一个不可拆分的原子操作。即使一万个请求同时进来Redis也是逐个执行脚本的不可能出现两个请求同时通过库存校验的情况。在实际的Spring代码里可以通过RedisTemplate和DefaultRedisScript来调用这段脚本。一个典型的做法是给每一个演出场次设置一个独立的库存Key比如“stock:{showId}”在活动开始前通过一个预热接口把库存数量提前写入Redis抢票开始之后所有扣减操作都走这个Key。这里顺便说一个容易踩的坑有人觉得既然Redis是单线程的那用“get查询”加“decrby扣减”两条普通命令也差不多吧不对。两条命令之间会出现轮转两个请求完全可以在第一条get命令都拿到库存1然后都执行decrby依然会超卖。所以必须把逻辑写进同一个Lua脚本里不能用多条命令组合来替代。3.3 防止同一个用户重复购票除了库存超卖抢票系统另一个容易忽视的问题是“一个人抢了多张票”。如果没有防重复机制同一个用户可以同时用多个设备反复发送请求最终把多张票都占下来。这种问题在业务上叫“超购”和超卖一样严重。业务层面的手段可以叠加使用。一是数据库唯一索引兜底在订单表里给(user_id, show_id, session_id)建立一个联合唯一索引同一个用户同一个场次只能插入一条有效订单这是最底层的保障。二是在Redis里设置一个购票标记Key可以设计成“buy_flag:{userId}:{sessionId}”第一次请求进来的时候通过setnx命令设置标记设置成功的才放行其余请求直接拒绝。三是网关层做IP级别的限流比如同一个IP在一秒钟内最多允许几个抢票请求防止脚本批量刷票。这里有一个经验数据库唯一索引这种兜底手段一定不能省。因为Redis缓存毕竟存在失效、被清理或者集群切换的可能如果只依赖业务层的判断一旦缓存出问题数据库层又没有约束就会产生脏数据。唯一索引就是最后一道防线它的存在让你在极端情况下也不会产生重复订单。3.4 支付超时的座位如何释放用户抢到票但不立即支付这是抢票场景里非常常见的状态。如果这些订单一直占着座位不释放就会导致大量真正想买的用户买不到票也会让演唱会的实际出票率大打折扣。所以常规设计是订单创建后只保留一段时间比如十五分钟或三十分钟超时未支付就自动关闭同时把对应的库存释放回票仓。实现方式有两种主流方案。第一种是消息队列的延迟消息比如RabbitMQ的延迟队列或者死信队列创建订单的同时发送一条延迟消息三十分钟之后消息到达消费端消费端检查订单状态如果还是待支付就关闭订单、释放座位。第二种是定时任务扫描用一个任务调度框架定时扫描订单表查出所有“待支付且创建时间超过N分钟”的订单依次做关单和恢复正常库存的操作。实际项目里我建议两种方式配合使用。延迟消息负责“准点处理”用户三十分钟不付款三十分钟后立刻触发关单定时任务负责“兜底”防止MQ消息丢失或者消费堵塞导致座位长时间被占用。释放库存这个动作同样要小心并发不能简单地在业务代码里做“查询订单状态然后更新库存”因为释放的同时可能正好有用户在抢这张票。保险的做法是把库存加回的操作也写成Lua脚本或者使用数据库的原子更新语句。3.5 数据库层的最终防线如何设计不管Redis和MQ多可靠数据库始终是系统的最终依据。库存数据的准确性和一致性最终一定落在数据库上所以数据库层的扣减逻辑必须设计好。推荐在库存表中增加“已售数量”和“版本号”两个字段扣减库存时使用乐观锁思路UPDATE stock SET sold sold 1, version version 1 WHERE show_id ? AND sold total AND version ?;执行之后如果受影响行数为1说明扣减成功如果受影响行数为0说明要么票已卖完要么版本号不一致需要重新处理。这个SQL里“sold total”这个条件特别关键它是数据库层的最终兜底即使前面的Redis逻辑被绕过或者并发场景下多个事务同时到达数据库这个条件也能保证库存不会变成负数。但要注意一个问题在高并发下所有请求都集中更新同一行库存记录数据库行锁竞争依然存在。乐观锁在冲突率高的场景下会导致大量更新失败和重试反而不如悲观锁稳定。所以这个SQL只能作为兜底方案不能作为主路径。主路径的扣减必须放到Redis的Lua脚本里完成数据库更新只处理那些已经从Redis成功扣减的少数请求。4. 项目的交付内容与工程化落地4.1 拿到手之后项目的典型目录结构这套项目通常包含源码、LW文档、调试文档和讲解资料。源码部分一般是标准的Maven多模块结构里面按照服务划分模块比如网关模块、用户服务模块、演出服务模块、库存服务模块、订单服务模块、支付服务模块另外有一个公共模块用来放公共的工具类和配置类。数据库脚本一般放在独立的SQL目录下里面包含了建库建表的语句和初始化数据。LW文档在毕设语境下一般指论文或者设计说明书。它不会是一份简单的项目介绍而是会把背景、需求分析、系统设计、数据库设计、核心功能实现、系统测试等内容系统地写出来很多同学会把这部分当成额外的负担但其实它恰恰是梳理整个项目逻辑的好机会。4.2 本地环境依赖与启动顺序把项目跑起来之前需要先确认几个基础环境已经就绪JDK版本需要和项目保持一致常见的是JDK8或JDK11Maven用来管理依赖和打包MySQL负责业务数据持久化Redis负责缓存与库存预扣减Nacos用作注册中心和配置中心如果项目里用了RabbitMQ还需要启动RabbitMQ服务。启动顺序有讲究需要先启动基础设施再启动业务服务。正确的顺序是先初始化数据库导入SQL脚本然后启动Redis、Nacos、RabbitMQ等Nacos控制台里能看到这些中间件正常之后再启动网关服务和各个业务服务。最后打开Nacos控制台确认所有服务都已经注册成功再用Postman或者Apifox发起一个测试请求验证整个链路是否打通。我见过很多新手在“服务启动失败”这个问题上卡住最后发现原因很简单Nacos没启动就去启动业务服务导致服务注册失败或者是数据库账号密码配置不对导致连接池初始化失败。所以调试文档里把环境依赖和启动顺序写清楚真的能帮人节省大量时间。4.3 LW文档的内容组织与写法LW文档在毕设答辩中是非常重要的一环。它一般会要求包含这几个部分选题背景与意义、国内外研究现状、需求分析、系统总体设计、数据库设计、系统详细设计与实现、系统测试、总结与展望。写文档的时候有一个建议不要事无巨细地贴代码而是把重点放在“你为什么要这么设计”上。比如数据库表为什么这样拆分库存扣减为什么用Redis加Lua服务之间为什么用消息队列异步通信这些设计决策背后的思考才是答辩老师最看重的东西。论文里的图表也要规范架构图、流程图、E-R图、时序图各来一张配合文字说明整体观感会好很多。4.4 调试和压测工具怎么用联调阶段最常用的工具是Postman或者Apifox用来逐个测试接口的入参和返回值。需要特别关注的是接口在参数异常、未登录、库存不足等情况下的表现这些边界情况的处理很多同学容易忽略。压测建议使用JMeter。压测不是简单地“发起一堆请求看会不会挂”而是要分层验证。第一步先对单个服务做压测找到单机瓶颈比如库存服务的接口在多大并发下开始出现错误。第二步再对整个链路做压测看网关、Redis、MQ、数据库哪个环节先成为瓶颈。第三步针对瓶颈做优化比如调整线程池大小、增加缓存或者水平扩容。压测数据最后会变成LW文档测试章节里的有力支撑。5. 实战踩坑与排查记录5.1 高并发场景下的高频问题速查表先给出一个我在实际调试中整理的高频问题速查表方便读者对照排查。问题表现可能原因解决方案库存超卖查询与扣减不是原子操作改用Redis Lua脚本合并判断与扣减同一个用户重复下单缺少用户维度限制订单表加联合唯一索引Redis加购票标记服务注册不上Nacos未启动或配置错误检查Nacos地址、服务名和网络连通性缓存雪崩热点Key同时失效过期时间加随机值热点数据预热缓存穿透查不到数据直接查数据库空值缓存或布隆过滤器分布式锁失效锁过期时间太短或主从切换尽量不用锁用Lua原子操作替代MQ消息积压消费者处理慢或下游DB慢批量消费、扩大线程池、优化SQL数据库死锁用户重复点击并发提交唯一索引防重业务层先做幂等校验5.2 从“超卖翻车”复盘看问题本质我印象最深的一次复盘是在模拟项目X里。最开始团队用的方案是“先查询Redis库存再执行decrby扣减”结果压测一开始就出现超卖。原因前面已经提到了两条Redis命令之间存在间隔高并发下多个请求都能通过库存检查然后再一起扣减库存在一瞬间变成负数。后来改成Lua脚本后超卖问题立刻消失。这个案例很能说明问题秒杀场景的核心矛盾就是“并发读写同一份数据”解决问题的关键在于把“读和写”变成一个不可分割的原子动作而不是靠加大机器配置或用更复杂的锁方案。Redis的单线程特性加上Lua脚本的原子执行就是针对这个场景最经济实惠的方案。5.3 Redis锁在抢票场景里的坑很多人在防超卖时首先想到的是加锁比如在扣库存之前加一个分布式锁。理论上完全可行但实操中有一些坑很不显眼。比如用Redisson实现的可重入锁在主从架构下存在一个风险线程A在主节点上获取锁成功但主节点在同步给从节点之前发生宕机从节点被提升为新主节点此时锁信息丢失线程B也可以成功获取同一把锁。又比如锁的过期时间设置得太短业务还没执行完锁就自动释放了另一个线程趁机进入同样会造成并发问题。我的经验是在库存扣减这个场景里能用Redis原子操作解决的就尽量不要用分布式锁。分布式锁适合的是一些无法用原子操作表达的业务场景比如复杂的跨服务流程控制。而在“扣减一个数字”这种简单且高频的操作上Lua脚本既简单又安全还没有锁过期、锁续期这些麻烦事。5.4 缓存雪崩和热点Key带来的连锁反应抢票场景天然会产生热点Key。开售前会把场次库存和座位信息写入Redis开售瞬间所有用户都会读取同一个场次的数据这个Key会成为单个Redis节点上的热点。如果Redis节点压力过大响应变慢所有请求都积压在这里整个系统都会被拖累。另外一个常见问题是缓存过期时间设置得过于集中。比如为了抢占活动把所有场次信息的缓存过期时间都设置为开售前十分钟那这十分钟一到大量Key同时失效所有请求同时打到数据库MySQL瞬间被打爆。解决办法是给过期时间增加一个随机值让Key的失效时间分散开。对于特别热点的Key还可以做“逻辑过期”方案也就是缓存永不过期后台异步刷新避免集中穿透到数据库。5.5 消息积压与订单落库性能优化在抢票链路中Redis扣减成功后会发送MQ消息订单服务消费消息并落库。如果瞬间产生大量消息而订单服务消费速度跟不上就会出现消息积压表现在业务上就是用户明明抢到了票却迟迟看不到订单。优化方向可以从几个角度来看。第一个是批量消费每次拉取一批消息积攒几十条后统一执行批量插入比逐条插入的吞吐量高很多。第二个是优化数据库写入订单表去掉不必要的索引减少插入时的索引维护开销。第三个是调整消费者的并发度增大线程数让消费速度匹配生产速度。不过也要注意批量插入和调整并发度之后数据库的压力会增大需要同步观察MySQL的负载和连接数不能让瓶颈从MQ转移到数据库。5.6 分布式事务到底要不要引入抢票系统里库存扣减、订单创建、座位锁定这些步骤跨了多个服务很多人第一反应是引入Seata这样的分布式事务框架保证多个服务要么一起成功要么一起回滚。我的看法刚好相反抢票链路最忌讳强一致分布式事务。原因很简单秒杀场景追求的是高吞吐和最终一致不是实时强一致。用户点击抢票按钮后即使订单创建和状态更新有短暂的延迟用户也是可以接受的。但如果为了强一致引入分布式事务所有服务都要同步等待事务提交结果整个链路的响应时间会大幅上升吞吐量也会明显下降得不偿失。更合理的方案是“本地消息表”或者“MQ消息驱动削峰填谷”。Redis扣减成功就发消息订单服务消费消息创建订单如果创建失败就把消息放到重试队列里继续处理最终保证订单一定被创建。支付超时释放座位也是同理允许一个短暂的时间窗口内出现“座位已锁定但订单未支付”的中间状态通过定时任务或延迟消息慢慢消化掉。这种最终一致性的设计才是秒杀场景下正确的思路。整套系统做下来我最大的体会是分布式方案本身没什么高深莫测的技术难的是你能不能在每一个环节说清楚“为什么要这样设计”。抢票系统把并发控制、缓存设计、消息队列、服务拆分这些知识点全部串了起来是一个非常完整的练手项目无论你是用来做毕设还是作为面试项目都很值得认真做一遍。最后再分享一个个人经验压测比写代码更能暴露设计缺陷。很多时候功能代码调通了就觉得没问题了结果JMeter一压超时、超卖、缓存穿透、线程阻塞各种问题全都冒出来了。所以一定要把压测当成验收的一部分而且最好从单服务压起一步步放大并发边压边看监控数据这样才能真正摸清这套系统的底细和上限。