“基于Java+SpringCloud+SSM的分布式演唱会抢票系统”这个命题我见了太多次,很多人以为把SpringCloud拆几个微服务、Redis存个库存就能交差,结果一压测全露馅:超卖、同座票重复卖出、库存扣减对不上账、下单接口直接被刷爆。这篇文章我把这套系统的核心思路、踩坑实录和可复现的关键实现一次性讲透,适合正在做毕业设计、求职项目,或者想把单体抢票重构为分布式方案的开发者对照参考。先说明白这系统到底解决什么问题。演唱会的“抢票”和普通电商下单完全是两种节奏:一场热门演出开票瞬间可能涌入几十万请求,但真正库存可能只有几万张。系统要做的不是“能下单”,而是“在极端流量下还能保证不超卖、不重复、响应快、数据最终一致”。Java + SpringCloud + SSM这套组合,放在这里指的是:SSM(Spring + SpringMVC + MyBatis)负责业务层的数据处理和持久化,SpringCloud承担服务拆分、注册发现、网关路由和远程调用,再配合Redis、消息队列等中间件完成高并发下的流量控制和库存管理。1. 项目整体设计与技术选型1.1 为什么要用分布式,而不是继续堆单体先说绝大多数人踩的第一道坎:一开始用单体架构做抢票,改造时才发现崩塌点根本不是代码,而是数据库。单体架构里所有请求打到一个应用,再打到库里,连接数一满,整个系统全部阻塞。我以前帮人优化过一套单体抢票,压测到两千并发,MySQL连接池直接打穿,后面排队的用户连登录都进不去,SQL层面全是“too many connections”。这就是典型的“单点瓶颈”,不是调优能解决的,而是架构层面必须拆分。拆分为分布式之后,第一收益是把压力分散到多个服务实例上,网关层、业务层、库存层各管一段。比如用户登录请求走用户服务,抢票请求走订单服务,库存扣减走库存服务,各自独立部署、独立扩缩容。第二收益是隔离故障,某个服务挂了不至于牵连整站。第三收益是扩展性,开票前可以动态扩容订单服务实例数,活动结束后再缩容,成本可控。但要注意,分布式不是银弹,它带来了新的问题:服务间如何互相发现?配置怎么统一管理?请求如何路由到正确的服务?远程调用失败了怎么处理?分布式事务怎么保证?这些正是SpringCloud要回答的。所以这个项目的技术选型逻辑不是“用酷炫技术堆一堆”,而是每加一个组件,都对应一个真实痛点。1.2 技术栈组合的合理搭配这个项目里SSM和SpringCloud的职责边界要划清楚。SSM是基础开发框架,Spring负责依赖管理和事务,SpringMVC负责Web层请求映射,MyBatis负责SQL操作数据持久化。SpringCloud作为微服务治理层,提供注册中心(我常用Nacos)、服务网关(Spring Cloud Gateway)、远程调用(OpenFeign)、熔断降级(Sentinel或Resilience4j)。具体到一次购票请求,链路是这样的:前端请求先打到Spring Cloud Gateway网关,网关做限流和路由,把请求转发到订单服务;订单服务先查Redis里预扣的库存配额,再调用库存服务的接口确认扣减,最后异步通过消息队列通知第三方支付服务生成支付单。整套链路里,SSM相当于每个微服务内部的“地基”,SpringCloud则是把各个“地基”串起来的高速网。还有一个常见误区:把Redis和消息队列当成可选组件。在抢票场景里,它们是必需品,不是装饰品。Redis解决的是库存的原子扣减和热点数据的读性能问题,消息队列解决的是高峰期请求削峰和解耦问题。少了这两样,光靠微服务拆分,数据库一样会被压垮。2. 核心业务模块拆解与数据模型2.1 用户、演出、场次与票档任何票务系统都不能绕开四类基础数据:用户、演出、场次和票档。用户表不用多说,核心是用户ID、手机号、登录态凭证。演出表存的是演出的基础信息,比如艺人、演出名称、海报、说明文字。场次表非常关键,一场演唱会通常在同一天有多个场次,或者多城市巡演有不同的场次,每个场次独立对应一个演出。票档表则描述价格和区域,比如内场A区、看台B区,每个票档关联到具体场次。我见过很多项目把场次和票档混在一张表里,字段设计极度混乱,最后统计哪个票档还有多少余票时只能靠foreach循环算,性能惨不忍睹。正确的做法是:场次表与票档表做一对多关联,票档表里存场次ID、票档名称、原始总票数、已售数量、价格。库存则不要直接冗余在票档表的正数字段上“裸扣”,而是放到Redis里以票档ID为key来做原子操作,这一点后面专门讲。另外有一个细节:如果做的是真正可落地的系统,还要考虑“区域座位图”。简化方案是票档级别不具体到座位号,只控制每个票档的库存总数;进阶方案是精确到座位号,那就要维护座位表(场次ID、区域ID、行号、列号、座位状态),并且要考虑两个同时选中同一座位的并发冲突。毕业设计和工程演示通常用票档级库存就够了,但你要知道自己在这个维度上做了“简化取舍”,面试被问到时能讲清楚为什么。2.2 订单表与流水表设计订单表是系统里数据一致性要求最高的地方。核心字段包括:订单号(必须唯一)、用户ID、场次ID、票档ID、数量、单价、总金额、订单状态、创建时间、支付截止时间。订单状态建议用状态机管理:已创建、待支付、已支付、已取消、已退款、已失效。除了订单表,强烈建议再加一张库存流水表,记录每一次库存扣减和回补的操作。字段包括:流水号、票档ID、订单号、操作类型(预扣、确认扣减、回补)、操作数量、操作前库存、操作后库存、操作时间。这张表的价值在于对账和排查问题,一旦出现超卖或者重复扣减,可以通过流水表回放整个操作序列,定位是哪一步出现了并发冲突。很多人做项目时嫌流水表多余,但真正上线后没有它,排查数据问题就是大海捞针。订单号和普通主键ID要有区分。主键ID可以自增或雪花算法生成,但对用户暴露的订单号一定要短、唯一、不容易被枚举,常用方案是“日期 + 随机数 + 校验位”或直接用预生成的随机字符串。不要直接把自增ID暴露给前端,会被爬虫遍历出全站的订单数据。3. 分布式高并发抢票的核心实现3.1 库存预扣与Redis原子操作抢票系统最容易犯的错误是:用户点下单时直接 UPDATE 库存表 SET stock = stock - 1。在低并发下没问题,高并发下同一行记录被多个事务同时更新,会出现超卖——多个请求都读到了库存为1,然后都执行扣减,最后都通过校验。数据库的行锁虽然最终能保住一致性,但会让大量请求排队等待,系统吞吐量急剧下降。更合理的方案是在Redis里做库存预扣。开票前用定时任务或管理后台操作,把本场次每个票档的库存同步到Redis,比如用 String key = "ticket:stock:{ticketTypeId}",再用Redis的 DECR 命令做原子扣减。DECR本身就是原子操作,多个并发请求同时执行也不会超扣。扣减前可以先判断当前值是否大于0:用 Lua 脚本把“判断库存是否足够 + 扣减”两个操作合并成一个原子脚本,这是标准的做法。下面是一段我在项目中常用的Lua脚本示例:local stock = tonumber(redis.call('get', KEYS[1])) if not stock or stock <= 0 then return -1 end redis.call('decrby', KEYS[1], ARGV[1]) return stock调用时把票档库存的Redis key和购买数量传进去。返回-1说明库存不足,直接返回“已售罄”;否则进入下一步。这套方案的好处是单次扣减操作的耗时在毫秒级,Redis单机就能扛住很高的QPS,数据库层只在最终确认时扣减一次,压力小得多。3.2 消息队列削峰与订单异步处理抢票高并发场景下,如果把所有请求都同步打到订单服务和数据库,峰值流量会瞬间把后端打挂。这也是“削峰”这一概念的实际意义。我的做法是订单服务接收请求后,在网关层或服务入口处做初步限流,通过的请求把“创建订单”的任务投递到消息队列(比如RabbitMQ或RocketMQ),后端消费者按自己能承受的速率去处理下单逻辑。这里有个容易踩的坑:不是把原始请求直接投到队列就完了,而是要设计好队列的消费模型。我自己常用的是“一个场次一个虚拟队列”,也就是消息的routing key带上场次ID或票档ID,这样同一个票档的订单可以被串行消费或按顺序处理,降低库存扣减顺序错乱的概率。如果所有场次混在一个队列里,多个消费者并行消费同一个票档的订单时,会大大增加并发冲突的概率。异步处理还带来一个体验问题:用户点击抢票后,不能傻等一个同步结果。前端在提交抢票请求后,应该轮询或通过WebSocket接收“抢票结果通知”,页面可以展示“正在排队中”的中间状态。这里要设计一个“结果查询”接口,订单服务在处理完消息后,将状态回写数据库和Redis,用户端的查询接口通过订单号或一个临时排队标识去获取最新状态。3.3 限流、防刷与分布式锁高并发系统的第一步不是处理业务,而是把无效流量挡在外面。限流我习惯在网关层做,Spring Cloud Gateway结合Sentinel可以配置按接口、按参数、按用户维度的限流规则。比如对“下单接口”做每秒每用户最多2次的限制,对“查询接口”放宽到每秒10次。限流的目的不是拒绝所有用户,而是把流量整形到后端能承受的区间,让真实用户有机会挤进来。防刷要从两个视角考虑:同一用户短时间内重复提交,同一IP或设备短时间内大量请求。前者可以用用户ID + 接口维度做计数限流,后者可以在网关层做IP维度的滑动窗口限流。正经项目还会加验证码或滑块,抢票场景下用“静默验证”比强制验证码体验更好,比如在客户端收集一些设备指纹信息,后端对异常特征做拦截。还要注意分布式锁的使用场景别滥用。在Redis做库存预扣之后,数据库层的“订单确认”并不需要再对库存行加悲观锁,否则并发瓶颈又回到数据库。分布式锁最典型的使用场景是:针对某个用户同一场次只能下一单、某个票档的促销开关只能被一个服务实例修改这类“低频但必须互斥”的操作。另外,使用Redis分布式锁务必设置合理的过期时间,防止锁持有者崩溃后锁一直不释放,导致后续请求全部阻塞。4. 实操过程与关键环节实现4.1 服务拆分与SpringCloud基础环境搭建实操第一步是把Maven多模块工程建好。我习惯按业务域拆分模块,而不是按技术层拆分。也就是说,不要拆一个“controller模块”一个“service模块”,而是拆出:用户服务(user-service)、演出服务(show-service)、库存服务(inventory-service)、订单服务(order-service)、支付服务(payment-service)。每个服务都是独立的SpringBoot应用,内部再用Controller、Service、Mapper分层。注册中心我用Nacos,因为它同时承担服务注册发现和配置中心两个角色,省去再搭一套配置管理组件。启动Nacos后,在每个服务的配置文件中注册到Nacos并开启服务发现:server: port: 9101 spring: application: name: order-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848服务间的远程调用用OpenFeign。比如订单服务需要调用库存服务扣减库存,就在订单服务中定义一个Feign客户端接口,映射到库存服务的扣减接口:@FeignClient(name = "inventory-service", path = "/inventory") public interface InventoryClient { @PostMapping("/deduct") R<Boolean> deduct(@RequestBody DeductRequest request); }这里要注意Feign调用超时时间的设置。抢票高峰期服务可能变慢,默认的1秒超时几乎必然触发熔断。我一般将连接超时设为3秒,读取超时设为10秒,同时开启重试,但要注意:如果扣减接口不是幂等的,重试可能导致重复扣减,所以要么在扣减操作上做幂等处理(比如同一个业务流水号只允许扣一次),要么关闭重试并配合业务层的补偿机制。4.2 抢票核心链路完整演示下面把一条完整抢票链路的代码逻辑梳理出来。前端调用/order/create接口,参数包含场次ID、票档ID、购买数量、用户标识。网关层限流之后请求进入订单服务的Controller,第一步是校验基础参数和用户登录态;第二步从Redis获取票档库存并执行Lua脚本预扣;第三步扣减成功后,组装订单消息发送到MQ;第四步立即返回“排队中”状态给前端。MQ消费者监听到订单消息后,开始真正的订单创建:@RabbitListener(queues = "order.create.queue") public void onCreateOrder(OrderCreateMessage message) { // 1. 幂等校验:根据业务流水号判断订单是否已存在 // 2. 创建订单主记录,状态为待支付 // 3. 调用库存服务确认扣减(更新数据库库存) // 4. 生成支付单并推送到支付服务 }这里必须重点提醒幂等的问题。MQ消息在极端情况下可能被投递不止一次(生产者重试、消费者异常后重新入队),所以消费者一定要保证幂等。我通常在每个业务操作里带上一个全局唯一的业务流水号(bizNo),在数据库建立唯一索引,消费前先按bizNo查一次,已存在就直接返回。这是做实战项目最常被忽略,又最影响可靠性的细节。4.3 数据库层面的最终一致性保障Redis预扣之后,数据库的库存最终要怎么和Redis对齐?我的做法是数据库库存表保留“总库存”和“锁定库存”两个字段。下单预扣对应逻辑就是:检查可用库存 = 总库存 - 锁定库存 > 0,然后锁定库存增加。用户支付成功后,锁定库存不变,总库存减去购买数量;这就是最终一致性回写。用户取消订单或支付超时,锁定库存减少,把配额释放回可用库存。这个方案最关键的地方在于:数据库层面不需要对总库存字段做高频的并发更新,大部分压力被Redis扛住,数据库只承受低频的“确认”和“回补”操作。对账时,可以用库存流水表核对——Redis当前值加上已确认扣减总量,应该等于初始库存。我强烈建议在项目里写一个简单的对账Job,每小时或每场活动结束后跑一次,不一致时发告警,这是保证数据可靠性的最后一道防线。5. 常见问题与排查技巧实录5.1 超卖问题:前端显示还有票,提交时却售罄这个现象几乎每个抢票系统都遇到过。原因主要有两类:第一类是前端展示的库存来自缓存,没有实时刷新;第二类是后端扣减逻辑没有使用原子操作,比如先查库存再更新,查和更新之间的间隙被其他请求插入。排查思路是:先看Redis的库存key是否在所有扣减路径上都是原子操作,把所有“判断+扣减”逻辑封装到Lua脚本里;再看前端轮询库存的接口频率和缓存过期时间,把“精确库存”和“展示库存”分离,展示库存允许延迟几秒,下单时的库存必须以Redis实时值为准。这里补充一个细节:扣减库存后即使后续流程失败,也要走“回补”流程,不能直接丢弃,否则库存会莫名变少,用户看到的余票数量越来越离谱。5.2 同一用户重复下多单与同座位票重复分配重复下单的处理方案是用户维度加分布式锁。比如用 Redis setnx 命令,key为“order:lock:userId:{userId}”,值为订单创建的任务ID,设置过期时间为3秒。只有获取锁成功的请求可以继续,其他请求直接返回“请勿重复提交”。如果分布式锁的实现只靠setnx而忘记设置过期时间,一旦出现死锁或锁持有时间过长,后续请求会被卡住很久,这是新手最容易踩的坑。座位票重复分配的问题和库存超卖本质一样,只是粒度从票档级细化到座位级。处理逻辑同样用Lua脚本保证座位状态变更的原子性,或者把座位移到一个ZSet或Hash结构中,用SPOP之类的命令原子地弹出未售座位。设计和说明时讲清楚这两者的递进关系,往往会成为项目答辩或面试的加分项。5.3 缓存击穿 / 穿透 / 雪崩抢票系统最怕的是缓存集体失效导致数据库被打爆。击穿指某一个热点key失效的瞬间大量请求直击数据库,雪崩指大量key同时过期导致数据库整体压力激增,穿透指恶意请求查询不存在的key,Redis和数据库都查不到,请求全部打到数据库。针对击穿,我的做法是给热点key设置逻辑过期时间,而不是物理过期,后台用一个定时任务去刷新热点数据;或者使用互斥锁——当某个key过期后,只允许一个线程去数据库重建缓存,其他线程等待缓存重建完成。针对穿透,在网关服务或Web层加布隆过滤器,对不存在的票档ID直接拦截。针对雪崩,在设置过期时间时加一个随机扰动,比如基础过期时间加上0到300秒的随机值,避免大面积同时失效。6. 调试与部署经验补充6.1 本地联调环境搭建本地要跑通整套分布式系统,我的建议是按依赖关系分步启动,不要一上来把六个服务全启了。先启动Nacos和Redis,再启动基础依赖的用户服务、演出服务、库存服务,最后启动订单服务和网关服务。测量接口时用JMeter或Postman脚本模拟并发请求,不要用浏览器手动点击,并发量根本压不出来。联调期间打开Nacos控制面板的服务列表页,观察每个服务的健康状态。如果一直有服务显示不健康,八成是注册中心地址配置错误或服务心跳超时,先检查server-addr配置,再看服务所在机器的防火墙端口有没有放开。网关路由转发到下游服务失败时,可以先用curl直接打下游服务的接口确认下游本身是否正常,再排查网关路由的断言和过滤器位置问题。6.2 部署时最容易被忽略的配置项部署阶段最容易被忽略的是JVM参数和连接池参数。测试环境和生产环境的机器配置差距大,我建议上线前用压测工具分别测出每个服务的建议线程池、数据库连接池和MQ的消费线程数。JVM堆内存根据服务大小设置为2G到4G,年轻代比例保持默认即可,不要乱调。在网关服务中,要对超时时间做整体规划:网关转发下游超时时间必须大于下游服务的响应时间,否则用户端看到504错误而服务端其实还在处理。此外,将日志级别设好,生产环境至少保留INFO级别,同时开启请求ID的链路透传,这样一次请求在整个链路中的日志才能串联起来。没有链路透传,排查问题只能靠猜,效率极低。6.3 一套压测的最低配置参考我自己常用的压测配置如下:用JMeter创建100个线程组,每个线程循环20次,模拟2000次请求,观察网关服务的CPU、订单服务的响应时间和Redis的QPS。如果订单服务的平均响应时间超过2秒,先看是否有慢SQL,再看是否发生了消息队列积压。压测前给数据库执行一份大表索引检查,确保订单表的order_no、库存流水表的biz_no都建了唯一索引。索引缺失会导致压测中途数据库CPU飙升,这个坑我踩过不止一次。我还习惯压测后在订单表统计总订单数和支付成功数,与Redis里的扣减记录做一次粗略对账。对不上就回到库存流水表逐步排查,一般情况下问题都出在消息重复消费或库存回补的逻辑遗漏上。如果要把这个项目继续往深处做,接下来的重点建议放在三块:第一是订单超时自动关单的定时任务与支付回调链路的补偿机制;第二是精确到座位选择的界面交互和座位锁定实现;第三是压测报告的数据指标分析和调优节奏。这三块恰好是所有抢票系统后续真实上线时绕不开的硬骨头。以我自己的体会来看,这类项目最有价值的地方不在于用了多少组件,而在于每个组件都恰好解决了一个具体的并发痛点,当你把组件和问题一一对应清楚时,这套系统才真正算吃透了。
“基于Java+SpringCloud+SSM的分布式演唱会抢票系统”这个命题我见了太多次,很多人以为把SpringCloud拆几个微服务、Redis存个库存就能交差,结果一压测全露馅:超卖、同座票重复卖出、库存扣减对不上账、下单接口直接被刷爆。这篇文章我把这套系统的核心思路、踩坑实录和可复现的关键实现一次性讲透,适合正在做毕业设计、求职项目,或者想把单体抢票重构为分布式方案的开发者对照参考。先说明白这系统到底解决什么问题。演唱会的“抢票”和普通电商下单完全是两种节奏:一场热门演出开票瞬间可能涌入几十万请求,但真正库存可能只有几万张。系统要做的不是“能下单”,而是“在极端流量下还能保证不超卖、不重复、响应快、数据最终一致”。Java + SpringCloud + SSM这套组合,放在这里指的是:SSM(Spring + SpringMVC + MyBatis)负责业务层的数据处理和持久化,SpringCloud承担服务拆分、注册发现、网关路由和远程调用,再配合Redis、消息队列等中间件完成高并发下的流量控制和库存管理。1. 项目整体设计与技术选型1.1 为什么要用分布式,而不是继续堆单体先说绝大多数人踩的第一道坎:一开始用单体架构做抢票,改造时才发现崩塌点根本不是代码,而是数据库。单体架构里所有请求打到一个应用,再打到库里,连接数一满,整个系统全部阻塞。我以前帮人优化过一套单体抢票,压测到两千并发,MySQL连接池直接打穿,后面排队的用户连登录都进不去,SQL层面全是“too many connections”。这就是典型的“单点瓶颈”,不是调优能解决的,而是架构层面必须拆分。拆分为分布式之后,第一收益是把压力分散到多个服务实例上,网关层、业务层、库存层各管一段。比如用户登录请求走用户服务,抢票请求走订单服务,库存扣减走库存服务,各自独立部署、独立扩缩容。第二收益是隔离故障,某个服务挂了不至于牵连整站。第三收益是扩展性,开票前可以动态扩容订单服务实例数,活动结束后再缩容,成本可控。但要注意,分布式不是银弹,它带来了新的问题:服务间如何互相发现?配置怎么统一管理?请求如何路由到正确的服务?远程调用失败了怎么处理?分布式事务怎么保证?这些正是SpringCloud要回答的。所以这个项目的技术选型逻辑不是“用酷炫技术堆一堆”,而是每加一个组件,都对应一个真实痛点。1.2 技术栈组合的合理搭配这个项目里SSM和SpringCloud的职责边界要划清楚。SSM是基础开发框架,Spring负责依赖管理和事务,SpringMVC负责Web层请求映射,MyBatis负责SQL操作数据持久化。SpringCloud作为微服务治理层,提供注册中心(我常用Nacos)、服务网关(Spring Cloud Gateway)、远程调用(OpenFeign)、熔断降级(Sentinel或Resilience4j)。具体到一次购票请求,链路是这样的:前端请求先打到Spring Cloud Gateway网关,网关做限流和路由,把请求转发到订单服务;订单服务先查Redis里预扣的库存配额,再调用库存服务的接口确认扣减,最后异步通过消息队列通知第三方支付服务生成支付单。整套链路里,SSM相当于每个微服务内部的“地基”,SpringCloud则是把各个“地基”串起来的高速网。还有一个常见误区:把Redis和消息队列当成可选组件。在抢票场景里,它们是必需品,不是装饰品。Redis解决的是库存的原子扣减和热点数据的读性能问题,消息队列解决的是高峰期请求削峰和解耦问题。少了这两样,光靠微服务拆分,数据库一样会被压垮。2. 核心业务模块拆解与数据模型2.1 用户、演出、场次与票档任何票务系统都不能绕开四类基础数据:用户、演出、场次和票档。用户表不用多说,核心是用户ID、手机号、登录态凭证。演出表存的是演出的基础信息,比如艺人、演出名称、海报、说明文字。场次表非常关键,一场演唱会通常在同一天有多个场次,或者多城市巡演有不同的场次,每个场次独立对应一个演出。票档表则描述价格和区域,比如内场A区、看台B区,每个票档关联到具体场次。我见过很多项目把场次和票档混在一张表里,字段设计极度混乱,最后统计哪个票档还有多少余票时只能靠foreach循环算,性能惨不忍睹。正确的做法是:场次表与票档表做一对多关联,票档表里存场次ID、票档名称、原始总票数、已售数量、价格。库存则不要直接冗余在票档表的正数字段上“裸扣”,而是放到Redis里以票档ID为key来做原子操作,这一点后面专门讲。另外有一个细节:如果做的是真正可落地的系统,还要考虑“区域座位图”。简化方案是票档级别不具体到座位号,只控制每个票档的库存总数;进阶方案是精确到座位号,那就要维护座位表(场次ID、区域ID、行号、列号、座位状态),并且要考虑两个同时选中同一座位的并发冲突。毕业设计和工程演示通常用票档级库存就够了,但你要知道自己在这个维度上做了“简化取舍”,面试被问到时能讲清楚为什么。2.2 订单表与流水表设计订单表是系统里数据一致性要求最高的地方。核心字段包括:订单号(必须唯一)、用户ID、场次ID、票档ID、数量、单价、总金额、订单状态、创建时间、支付截止时间。订单状态建议用状态机管理:已创建、待支付、已支付、已取消、已退款、已失效。除了订单表,强烈建议再加一张库存流水表,记录每一次库存扣减和回补的操作。字段包括:流水号、票档ID、订单号、操作类型(预扣、确认扣减、回补)、操作数量、操作前库存、操作后库存、操作时间。这张表的价值在于对账和排查问题,一旦出现超卖或者重复扣减,可以通过流水表回放整个操作序列,定位是哪一步出现了并发冲突。很多人做项目时嫌流水表多余,但真正上线后没有它,排查数据问题就是大海捞针。订单号和普通主键ID要有区分。主键ID可以自增或雪花算法生成,但对用户暴露的订单号一定要短、唯一、不容易被枚举,常用方案是“日期 + 随机数 + 校验位”或直接用预生成的随机字符串。不要直接把自增ID暴露给前端,会被爬虫遍历出全站的订单数据。3. 分布式高并发抢票的核心实现3.1 库存预扣与Redis原子操作抢票系统最容易犯的错误是:用户点下单时直接 UPDATE 库存表 SET stock = stock - 1。在低并发下没问题,高并发下同一行记录被多个事务同时更新,会出现超卖——多个请求都读到了库存为1,然后都执行扣减,最后都通过校验。数据库的行锁虽然最终能保住一致性,但会让大量请求排队等待,系统吞吐量急剧下降。更合理的方案是在Redis里做库存预扣。开票前用定时任务或管理后台操作,把本场次每个票档的库存同步到Redis,比如用 String key = "ticket:stock:{ticketTypeId}",再用Redis的 DECR 命令做原子扣减。DECR本身就是原子操作,多个并发请求同时执行也不会超扣。扣减前可以先判断当前值是否大于0:用 Lua 脚本把“判断库存是否足够 + 扣减”两个操作合并成一个原子脚本,这是标准的做法。下面是一段我在项目中常用的Lua脚本示例:local stock = tonumber(redis.call('get', KEYS[1])) if not stock or stock <= 0 then return -1 end redis.call('decrby', KEYS[1], ARGV[1]) return stock调用时把票档库存的Redis key和购买数量传进去。返回-1说明库存不足,直接返回“已售罄”;否则进入下一步。这套方案的好处是单次扣减操作的耗时在毫秒级,Redis单机就能扛住很高的QPS,数据库层只在最终确认时扣减一次,压力小得多。3.2 消息队列削峰与订单异步处理抢票高并发场景下,如果把所有请求都同步打到订单服务和数据库,峰值流量会瞬间把后端打挂。这也是“削峰”这一概念的实际意义。我的做法是订单服务接收请求后,在网关层或服务入口处做初步限流,通过的请求把“创建订单”的任务投递到消息队列(比如RabbitMQ或RocketMQ),后端消费者按自己能承受的速率去处理下单逻辑。这里有个容易踩的坑:不是把原始请求直接投到队列就完了,而是要设计好队列的消费模型。我自己常用的是“一个场次一个虚拟队列”,也就是消息的routing key带上场次ID或票档ID,这样同一个票档的订单可以被串行消费或按顺序处理,降低库存扣减顺序错乱的概率。如果所有场次混在一个队列里,多个消费者并行消费同一个票档的订单时,会大大增加并发冲突的概率。异步处理还带来一个体验问题:用户点击抢票后,不能傻等一个同步结果。前端在提交抢票请求后,应该轮询或通过WebSocket接收“抢票结果通知”,页面可以展示“正在排队中”的中间状态。这里要设计一个“结果查询”接口,订单服务在处理完消息后,将状态回写数据库和Redis,用户端的查询接口通过订单号或一个临时排队标识去获取最新状态。3.3 限流、防刷与分布式锁高并发系统的第一步不是处理业务,而是把无效流量挡在外面。限流我习惯在网关层做,Spring Cloud Gateway结合Sentinel可以配置按接口、按参数、按用户维度的限流规则。比如对“下单接口”做每秒每用户最多2次的限制,对“查询接口”放宽到每秒10次。限流的目的不是拒绝所有用户,而是把流量整形到后端能承受的区间,让真实用户有机会挤进来。防刷要从两个视角考虑:同一用户短时间内重复提交,同一IP或设备短时间内大量请求。前者可以用用户ID + 接口维度做计数限流,后者可以在网关层做IP维度的滑动窗口限流。正经项目还会加验证码或滑块,抢票场景下用“静默验证”比强制验证码体验更好,比如在客户端收集一些设备指纹信息,后端对异常特征做拦截。还要注意分布式锁的使用场景别滥用。在Redis做库存预扣之后,数据库层的“订单确认”并不需要再对库存行加悲观锁,否则并发瓶颈又回到数据库。分布式锁最典型的使用场景是:针对某个用户同一场次只能下一单、某个票档的促销开关只能被一个服务实例修改这类“低频但必须互斥”的操作。另外,使用Redis分布式锁务必设置合理的过期时间,防止锁持有者崩溃后锁一直不释放,导致后续请求全部阻塞。4. 实操过程与关键环节实现4.1 服务拆分与SpringCloud基础环境搭建实操第一步是把Maven多模块工程建好。我习惯按业务域拆分模块,而不是按技术层拆分。也就是说,不要拆一个“controller模块”一个“service模块”,而是拆出:用户服务(user-service)、演出服务(show-service)、库存服务(inventory-service)、订单服务(order-service)、支付服务(payment-service)。每个服务都是独立的SpringBoot应用,内部再用Controller、Service、Mapper分层。注册中心我用Nacos,因为它同时承担服务注册发现和配置中心两个角色,省去再搭一套配置管理组件。启动Nacos后,在每个服务的配置文件中注册到Nacos并开启服务发现:server: port: 9101 spring: application: name: order-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848服务间的远程调用用OpenFeign。比如订单服务需要调用库存服务扣减库存,就在订单服务中定义一个Feign客户端接口,映射到库存服务的扣减接口:@FeignClient(name = "inventory-service", path = "/inventory") public interface InventoryClient { @PostMapping("/deduct") R<Boolean> deduct(@RequestBody DeductRequest request); }这里要注意Feign调用超时时间的设置。抢票高峰期服务可能变慢,默认的1秒超时几乎必然触发熔断。我一般将连接超时设为3秒,读取超时设为10秒,同时开启重试,但要注意:如果扣减接口不是幂等的,重试可能导致重复扣减,所以要么在扣减操作上做幂等处理(比如同一个业务流水号只允许扣一次),要么关闭重试并配合业务层的补偿机制。4.2 抢票核心链路完整演示下面把一条完整抢票链路的代码逻辑梳理出来。前端调用/order/create接口,参数包含场次ID、票档ID、购买数量、用户标识。网关层限流之后请求进入订单服务的Controller,第一步是校验基础参数和用户登录态;第二步从Redis获取票档库存并执行Lua脚本预扣;第三步扣减成功后,组装订单消息发送到MQ;第四步立即返回“排队中”状态给前端。MQ消费者监听到订单消息后,开始真正的订单创建:@RabbitListener(queues = "order.create.queue") public void onCreateOrder(OrderCreateMessage message) { // 1. 幂等校验:根据业务流水号判断订单是否已存在 // 2. 创建订单主记录,状态为待支付 // 3. 调用库存服务确认扣减(更新数据库库存) // 4. 生成支付单并推送到支付服务 }这里必须重点提醒幂等的问题。MQ消息在极端情况下可能被投递不止一次(生产者重试、消费者异常后重新入队),所以消费者一定要保证幂等。我通常在每个业务操作里带上一个全局唯一的业务流水号(bizNo),在数据库建立唯一索引,消费前先按bizNo查一次,已存在就直接返回。这是做实战项目最常被忽略,又最影响可靠性的细节。4.3 数据库层面的最终一致性保障Redis预扣之后,数据库的库存最终要怎么和Redis对齐?我的做法是数据库库存表保留“总库存”和“锁定库存”两个字段。下单预扣对应逻辑就是:检查可用库存 = 总库存 - 锁定库存 > 0,然后锁定库存增加。用户支付成功后,锁定库存不变,总库存减去购买数量;这就是最终一致性回写。用户取消订单或支付超时,锁定库存减少,把配额释放回可用库存。这个方案最关键的地方在于:数据库层面不需要对总库存字段做高频的并发更新,大部分压力被Redis扛住,数据库只承受低频的“确认”和“回补”操作。对账时,可以用库存流水表核对——Redis当前值加上已确认扣减总量,应该等于初始库存。我强烈建议在项目里写一个简单的对账Job,每小时或每场活动结束后跑一次,不一致时发告警,这是保证数据可靠性的最后一道防线。5. 常见问题与排查技巧实录5.1 超卖问题:前端显示还有票,提交时却售罄这个现象几乎每个抢票系统都遇到过。原因主要有两类:第一类是前端展示的库存来自缓存,没有实时刷新;第二类是后端扣减逻辑没有使用原子操作,比如先查库存再更新,查和更新之间的间隙被其他请求插入。排查思路是:先看Redis的库存key是否在所有扣减路径上都是原子操作,把所有“判断+扣减”逻辑封装到Lua脚本里;再看前端轮询库存的接口频率和缓存过期时间,把“精确库存”和“展示库存”分离,展示库存允许延迟几秒,下单时的库存必须以Redis实时值为准。这里补充一个细节:扣减库存后即使后续流程失败,也要走“回补”流程,不能直接丢弃,否则库存会莫名变少,用户看到的余票数量越来越离谱。5.2 同一用户重复下多单与同座位票重复分配重复下单的处理方案是用户维度加分布式锁。比如用 Redis setnx 命令,key为“order:lock:userId:{userId}”,值为订单创建的任务ID,设置过期时间为3秒。只有获取锁成功的请求可以继续,其他请求直接返回“请勿重复提交”。如果分布式锁的实现只靠setnx而忘记设置过期时间,一旦出现死锁或锁持有时间过长,后续请求会被卡住很久,这是新手最容易踩的坑。座位票重复分配的问题和库存超卖本质一样,只是粒度从票档级细化到座位级。处理逻辑同样用Lua脚本保证座位状态变更的原子性,或者把座位移到一个ZSet或Hash结构中,用SPOP之类的命令原子地弹出未售座位。设计和说明时讲清楚这两者的递进关系,往往会成为项目答辩或面试的加分项。5.3 缓存击穿 / 穿透 / 雪崩抢票系统最怕的是缓存集体失效导致数据库被打爆。击穿指某一个热点key失效的瞬间大量请求直击数据库,雪崩指大量key同时过期导致数据库整体压力激增,穿透指恶意请求查询不存在的key,Redis和数据库都查不到,请求全部打到数据库。针对击穿,我的做法是给热点key设置逻辑过期时间,而不是物理过期,后台用一个定时任务去刷新热点数据;或者使用互斥锁——当某个key过期后,只允许一个线程去数据库重建缓存,其他线程等待缓存重建完成。针对穿透,在网关服务或Web层加布隆过滤器,对不存在的票档ID直接拦截。针对雪崩,在设置过期时间时加一个随机扰动,比如基础过期时间加上0到300秒的随机值,避免大面积同时失效。6. 调试与部署经验补充6.1 本地联调环境搭建本地要跑通整套分布式系统,我的建议是按依赖关系分步启动,不要一上来把六个服务全启了。先启动Nacos和Redis,再启动基础依赖的用户服务、演出服务、库存服务,最后启动订单服务和网关服务。测量接口时用JMeter或Postman脚本模拟并发请求,不要用浏览器手动点击,并发量根本压不出来。联调期间打开Nacos控制面板的服务列表页,观察每个服务的健康状态。如果一直有服务显示不健康,八成是注册中心地址配置错误或服务心跳超时,先检查server-addr配置,再看服务所在机器的防火墙端口有没有放开。网关路由转发到下游服务失败时,可以先用curl直接打下游服务的接口确认下游本身是否正常,再排查网关路由的断言和过滤器位置问题。6.2 部署时最容易被忽略的配置项部署阶段最容易被忽略的是JVM参数和连接池参数。测试环境和生产环境的机器配置差距大,我建议上线前用压测工具分别测出每个服务的建议线程池、数据库连接池和MQ的消费线程数。JVM堆内存根据服务大小设置为2G到4G,年轻代比例保持默认即可,不要乱调。在网关服务中,要对超时时间做整体规划:网关转发下游超时时间必须大于下游服务的响应时间,否则用户端看到504错误而服务端其实还在处理。此外,将日志级别设好,生产环境至少保留INFO级别,同时开启请求ID的链路透传,这样一次请求在整个链路中的日志才能串联起来。没有链路透传,排查问题只能靠猜,效率极低。6.3 一套压测的最低配置参考我自己常用的压测配置如下:用JMeter创建100个线程组,每个线程循环20次,模拟2000次请求,观察网关服务的CPU、订单服务的响应时间和Redis的QPS。如果订单服务的平均响应时间超过2秒,先看是否有慢SQL,再看是否发生了消息队列积压。压测前给数据库执行一份大表索引检查,确保订单表的order_no、库存流水表的biz_no都建了唯一索引。索引缺失会导致压测中途数据库CPU飙升,这个坑我踩过不止一次。我还习惯压测后在订单表统计总订单数和支付成功数,与Redis里的扣减记录做一次粗略对账。对不上就回到库存流水表逐步排查,一般情况下问题都出在消息重复消费或库存回补的逻辑遗漏上。如果要把这个项目继续往深处做,接下来的重点建议放在三块:第一是订单超时自动关单的定时任务与支付回调链路的补偿机制;第二是精确到座位选择的界面交互和座位锁定实现;第三是压测报告的数据指标分析和调优节奏。这三块恰好是所有抢票系统后续真实上线时绕不开的硬骨头。以我自己的体会来看,这类项目最有价值的地方不在于用了多少组件,而在于每个组件都恰好解决了一个具体的并发痛点,当你把组件和问题一一对应清楚时,这套系统才真正算吃透了。