从一次真实事故说起。某电商团队上线了一套限时抢购活动,运营在后台配置了1万件商品,活动开始前一秒,网关层扛住了流量,订单服务却直接被打满,数据库连接池耗尽,紧接着Redis也出现了大面积超时。最后活动结束一复盘,卖出去了9000多件,数据库里的库存却还剩2000多,订单数据和库存数据对不上,运营紧急手工处理了大半夜。问题出在哪?不是服务器不够多,是秒杀系统在设计上就没考虑清楚“流量怎么挡、库存怎么扣、订单怎么落”。这套SpringBoot+Vue+MyBatis架构的MySQL秒杀管理系统,就是用来解决这类问题的——它把库存扣减、活动配置、订单管理、前端监控全部串起来,形成一套完整的可落地方案。无论你是准备写毕业设计的在校生,还是正被高并发场景折磨的后端开发,这套代码都能帮你把“秒杀系统到底怎么搭”这件事彻底想明白。
秒杀系统和普通电商系统的本质区别在于:普通系统是“人找商品”,秒杀系统是“商品等人”。同一时间点成千上万人涌入,如果所有请求都直接打到数据库,再好的机器也扛不住。所以这套系统从架构层面就先做了一件事——把流量挡在数据库之外,让大部分请求在缓存层就结束战斗,只有真正抢到购买资格的请求才能触达数据库。这个思路贯穿了整套代码的设计,也是整个秒杀系统最核心的认知。
先看清楚这套系统的整体面貌。它不是一个只有增删改查的普通后台管理项目,而是“管理端+用户端+服务端”三个维度的完整体系。管理端用Vue来实现,主要是给运营同学配活动、配商品、定规则;用户端负责承载真实的抢购请求;服务端是SpringBoot搭起来的接口服务,串联Redis做库存预扣,用MySQL做最终落库。三层各司其职,构成了一个完整的秒杀闭环。
1. 秒杀系统到底难在哪,这套架构凭什么能扛住
1.1 批量流量涌入对系统的三个致命冲击
任何一个秒杀系统,首先要面对的都是同样的三个难点:瞬时流量高、库存竞争激烈、数据一致性要求严苛。普通电商系统一天可能也就几千访问量,秒杀活动开始的瞬间,QPS直接冲上几万甚至几十万。这些请求涌入系统,第一波被打到的就是应用服务器,紧接着数据库的CPU会瞬间飙升,连接池很快被占满,后续请求全部排队等待,再往后就是雪崩,整个业务陷入不可用状态。
很多人以为只要把服务器数量加上去就能解决,实际上这是对秒杀场景最深的误解。数据库的瓶颈并不只是硬件性能,更多的在于锁竞争和事务开销。每一笔库存扣减都要执行UPDATE操作,数据库行锁会把这些请求串行化,QPS一大,等待锁的线程堆积成山,数据库连接直接被耗尽。这就是为什么很多系统在秒杀时,数据库CPU只有30%,但请求的RT已经超时十几秒——瓶颈根本不在计算,而在等锁和排队。
还有个隐藏更深的坑:数据一致性问题。一个用户点击“立即抢购”,前端可能同时发出好几次请求,加上脚本刷单的,同一个用户同一时刻的重复请求可能有十几条。如果系统不做幂等处理,库存可能被同一个用户扣掉好几件,超卖就在这种情况下出现。
1.2 这套系统如何分三层化解流量压力
这套秒杀系统的核心设计逻辑,是把流量分层消化。用户点击抢购按钮之后,请求首先到达Nginx网关层,网关做一次最粗粒度的过滤,用IP和用户ID做限流,把明显是刷单或者重复攻击的请求直接挡在外面。这一层不需要太多业务逻辑,只需要快。
通过网关校验的请求继续往下走,进入Redis层。Redis里预先载入了商品库存,这里用Lua脚本原子性地执行扣减操作。这步很关键,因为Redis是单线程模型,Lua脚本可以保证同一时刻只有一次扣减能修改库存,不会出现超扣的问题。扣减成功,说明这个用户锁定了库存,获得了购买资格,系统给他生成一个令牌或者待支付订单标记。扣减失败,直接返回“已抢完”。
只有锁定了库存的请求才会继续去请求MySQL,执行真正的订单插入操作。这样一个秒杀活动一万件商品,真正能打到数据库的请求最多也就是一万条左右,加上少量的退款、超时释放,数据库承受的压力比原来直接放开让所有请求往里冲要小两个数量级。这就是整套系统性能能扛住的核心原因——绝大多数流量都消耗在Redis这层,数据库只处理真正有意义的写操作。
个人建议在动手改代码之前,先把Redis预扣库存、MySQL落库这条链路用逻辑图画一遍,别看它简单,很多系统出问题就是因为这个先后顺序没想清楚。预扣和落库是两笔操作,中间一旦出现时序错乱,就会出现Redis说已经卖了五千件,MySQL里实际只有四千条订单的情况。
2. 项目搭建与核心表结构设计,这一块决定系统上限
2.1 环境准备五件套
想要把这套系统跑起来,第一步是先准备环境。后端主体是SpringBoot,JDK版本建议直接用8以上的稳定版本;MySQL至少8.0,字符集建议utf8mb4,因为订单备注和商品名称都可能包含特殊符号;Redis用5.0以上即可,Lua脚本在这个版本上运行最稳定。前端Vue部分需要Node.js环境,npm或yarn工具随选。
数据库初始化脚本在项目的sql目录下,直接执行整个init.sql就能把表结构全部建好。这里有一个很多新人容易踩的坑:连接数据库的库名、用户名、密码一定要和application.yml里完全一致。我见过不下十次的问题,都是因为这台电脑MySQL的密码跟别人配置文件里的不一样,结果启动报错报了半天,还以为是代码出了问题。
2.2 五张核心表的设计思路
这套系统的表结构对秒杀业务做了专门设计,我说几个关键表。
秒杀活动表(seckill_activity)保存活动的基础信息:活动名称、开始时间、结束时间、状态。这里特别注意,状态字段至少有“未开始、进行中、已结束、已下架”四种,而不是简单的0和1。因为这个状态会被定时任务扫描,用来做活动自动上下架和库存清理,如果只有两个状态,业务扩展开的时候代码会变得非常别扭。
秒杀商品表(seckill_product)是活动与商品的绑定关系,除了商品基础ID之外,多了两个关键字段:秒杀价和秒杀库存。这两个字段都是从普通商品表里单独剥离出来的,不跟商品原表混在一起。为什么这样做?因为秒杀价是活动特有价格,活动结束就要恢复原价,混在商品表里会污染原始数据;秒杀库存是活动维度的库存,它跟商品总库存完全不是一个概念。拆开之后,业务逻辑清楚很多,对数据库的更新压力也更小。
秒杀订单表(seckill_order)是所有表里最核心的一张,除了常规订单字段,它必须包含秒杀活动ID、用户ID、商品ID这三者的联合唯一约束。这个约束同时承担了幂等效果——同一个用户同一个活动同一件商品,数据库层面只允许一条成功订单,重复的插入请求会被数据库直接拒绝。
还有两张管理相关的表:用户表(sys_user)和操作日志表(sys_log)。用户表用来支持后端的登录认证和权限控制,操作日志表记录运营每次对活动、商品、库存的变更操作。日志表看起来不起眼,但实际运营过程中特别有用——线上出了问题,第一件事就是查日志,看谁在什么时间改了什么数据。没有日志表,出了问题就全靠猜。
2.3 唯一索引和数据状态的先后顺序
这里重点说一个细节:唯一索引和状态字段的配合。秒杀订单表里,联合唯一索引建议建立为(activity_id、user_id、product_id),这三个字段组合保证同人在同活动中对同一商品的订单只有一条。但业务上有一种场景必须提前想好:用户抢到库存后没支付,订单超时关闭了,这时候系统要不要允许用户重新抢购?如果允许,原本的订单记录还在,联合唯一索引就挡住了第二次插入。
解决办法有两种。一种是物理删除超时订单,另一种是给订单表增加一个status字段,超时关闭时把状态置为取消,同时索引改成包含status的联合索引,或者直接用逻辑删除配合唯一索引更新状态。我自己的习惯是保留订单记录、增加状态标记,这样方便后续做数据分析——到底有多少人抢到了但没支付,是个很重要的转化指标。这个字段加在后端代码里不算难,但提前设计好,后续非常省事。
2.4 MyBatis在秒杀高并发下的关键用法
MyBatis在这套系统里不只是做一个简单的ORM层,它的几个特性在秒杀场景下显得特别重要。第一个是批量插入。订单数据在高峰期是一批一批产生的,如果一条一条insert,数据库的日志写入压力非常大。用MyBatis的foreach写批量插入SQL,几百条订单一次性落库,性能和单条插入比完全是两个量级。第二个是动态SQL配合乐观锁。执行库存扣减时,UPDATE语句不直接写死,而是在XML里用动态条件拼出带版本号的扣减SQL——只有version匹配时才执行更新,这样天然支持了乐观锁防超卖。
数据源层面,这套系统用了主从分离的配置思路。写操作走主库,读操作走从库。对秒杀系统来说这个设计尤其重要,因为像商品详情、活动列表这类读多写少的数据,全走主库会给主库带来大量无谓的查询压力。配置主从分离在SpringBoot里并不复杂,两个数据源配置、一个通过注解或AOP切换数据源的逻辑,但这套机制需要你在写完SQL之后想清楚:哪个是写、哪个是读,别把主从切换参数配错了,不然会出现读不到刚写入数据的情况。
3. 从零开始搭建SpringBoot后端,核心逻辑逐步落地
3.1 基础工程结构与关键配置
后端工程用标准的Maven结构搭建(com.example.seckill),分为controller层、service层、mapper层、entity层和config层。controller只负责参数接收和结果返回,不写业务;service层承担所有业务逻辑,比如库存扣减的判断、订单生成的流程;mapper层就是数据访问接口,SQL统一写在XML里。
配置文件application.yml里有几个必须注意的参数。首先是Redis的配置,连接地址、端口、密码、连接池大小,连接池参数一般配置为maxTotal=200、maxIdle=50,这个参数直接决定了Redis在高并发下能同时处理多少请求,太小会导致大量请求在等待获取连接。线程池配置也要改,SpringBoot默认的Tomcat线程池是200,秒杀场景建议直接调到500以上,因为大量请求在缓存层就已经返回,线程不会长时间阻塞。如果线程池太小,前端所有请求都会堆积在web容器队列里。
另外Java虚拟机参数也要设置。堆内存建议至少2G起步,因为秒杀期间会有大量对象创建和销毁,频繁发生GC会严重影响响应速度。这里有个经验是开启-XX:+UseG1GC参数,G1垃圾回收器对这类延迟敏感型应用更友好,停顿时间可控。
3.2 库存扣减的核心代码逻辑
库存扣减是整个系统最关键的部分,我直接说实际做法。先看Redis预扣的Lua脚本逻辑:
local stock_key = KEYS[1] local user_key = KEYS[2] local stock = redis.call('get', stock_key) if tonumber(stock) <= 0 then return -1 end if redis.call('sismember', user_key, ARGV[1]) == 1 then return -2 end redis.call('decr', stock_key) redis.call('sadd', user_key, ARGV[1]) return 1这段脚本同时做了三件事:判断库存是否还有余量、判断用户是否已经抢过(通过set集合去重)、执行库存扣减并把用户加入已抢集合。Lua脚本在Redis中原子执行,不会存在并发竞争问题。扣减成功返回1,没库存返回-1,重复抢购直接返回-2。脚本的原子性靠Redis单线程保证,这是它比Java代码里用分布式锁再做判断要快得多也简单得多的核心原因。
预扣成功的请求进入下单流程。Service层生成订单主键(这里用雪花算法或时间戳+随机数拼出来的分布式订单号),设置订单状态为待支付,执行订单插入。插MySQL之前,还需要在Java代码里做一道完整的幂等校验,用select语句查用户是否已有相同活动相同商品的订单记录。这道校验和数据库的唯一索引相互配合,双保险防重复下单。
订单写入之后还有个重要动作——发送延迟消息。这套系统用Redis的过期key实现了简化的延迟队列效果:把订单号作为key缓存,有效期设为15分钟,给订单加设置过期时间。订单过期后,通过Redis的key过期事件监听器触发回调,业务逻辑里查到对应订单如果还是待支付状态,就把库存回补Redis,同时更新订单状态为超时关闭。不用外接消息队列,一套代码就实现了订单超时释放库存的完整闭环。
3.3 管理端接口设计,把前端需要的数据一次性备齐
后端管理接口按模块清晰切分:商品模块接口负责秒杀商品的CRUD,活动模块负责活动的创建、上下架和状态更新,订单模块提供订单分页查询和超时手动处理,日志模块提供操作日志查询。这些接口遵循统一返回结构,一个code字段表示业务状态,一个msg字段返回提示信息,一个data字段返回有效数据。统一返回类的这个设计,看起来简单,但能让前后端联调的沟通成本降到最低。
活动创建的接口里,有一个逻辑建议认真看代码:创建活动的同时会预加载库存到Redis,并设置活动结束时间的自动过期。这个预热的动作非常关键,假设活动开始时间是上午10点,如果等到10点用户请求进来时再去读数据库并写入Redis,那数据库在活动开始瞬间还是会受到一次大量读请求的冲击,预热就是为了避开这个尖峰。实际上预热的时机还可以更早——活动审核通过后立即预热,而不是等到创建接口返回才执行。
管理端查询接口设计时,要注意分页和条件查询的组合。运营后台筛选订单、看活动数据,一个不带索引的模糊查询在数据量大时会拖慢整个数据库,所以每个查询都要审视一遍:where条件里的字段有没有建索引、分页参数有没有绑定、排序字段是不是稳定索引。这些细节能决定管理后台在活动结束后查数据时会不会把数据库拖垮。
4. Vue管理端实现,把复杂的秒杀管理变成可视化操作
4.1 环境配置与工程初始化
前端工程用Vue脚手架创建,核心依赖包括vue-router(路由管理)、axios(HTTP请求库)、element-ui(UI组件库)、以及echarts(图表展示)。项目创建后先做三件事:配置路由表、在main.js里完成element-ui按需引入、在utils目录封装axios请求拦截。axios模块的baseURL直接指向后端接口地址,同时在请求拦截器中自动携带token——用户登录后拿到的token,每次请求都加到请求头里。
开发环境的跨域问题也是在配置阶段就要处理的。Vue开发服务器8080端口,后端8081端口,跨域配置在Vue的config目录index.js里设置proxy代理。这样做的好处是开发环境不用后端开CORS跨域,生产环境直接让Nginx做反向代理,前后端代码都不用动。我之前见过有人图省事在后端把全局跨域打开,结果上线后直接导致认证接口被别人恶意调用,排查半天才发现是CORS配置太宽泛。
4.2 核心管理页面拆解与实现
页面设计围绕运营真实使用流程来组织。商品管理页面是最常用的,运营要能快速查看商品列表、编辑秒杀价、调整秒杀库存。列表用表格组件展示,编辑用弹窗对话框,保存后调用管理端商品更新接口。轮播图和详情图用图片上传组件,接口统一返回URL,存数据库的只是一个路径字符串。
活动管理页面承接整个系统的核心配置功能。创建活动时,页面表单包含活动名称、开始/结束时间、活动状态、参与商品选择。这里有一个交互细节:活动时间选择器最好做前后端双重校验。前端限制开始时间必须在当前时间之后,后端也要在创建活动的service里再次验证。就在去年,我遇到一个情况,某个内部系统通过接口直接把活动开始时间改成了过去的时间,导致活动未启动就已过期。所以,前端校验只是体验,后端校验才是安全底线。
订单管理页面展示秒杀订单全貌,支持按活动ID、用户ID、订单状态多条件筛选查询。这里我强烈建议加一个“超时补货”按钮,用于人工干预异常场景——比如某用户抢到商品后迟迟未支付,订单超时关闭,但系统库存释放失败,运营可以在后台手动补偿库存,避免活动结束有库存却卖不出去的情况。这种兜底功能虽然不常用,但真到了出问题那一次,它能少挨很多骂。
登录权限和操作日志是管理端两块容易被低估的模块。登录用token认证,配合路由守卫实现页面级权限控制。操作日志真正做到接入每个接口调用,在axios响应拦截器里统一记录用户ID、方法名、参数、响应码、时长这些信息,这样一个运营操作全部有迹可循,出了问题可以回溯。从长期运维角度看,这笔投入是值得的。
4.3 大屏看板,让实时数据汇成一张图
秒杀进行过程中,运营最需要的是一个能看到实时数据的页面。这套系统的数据看板页面整合了三个维度的信息:商品热力排行(哪个SKU卖得最快)、实时库存水位(剩余库存低于20%的标红预警)、订单时间趋势线(单位时间成功订单量)。这些数据分别从订单统计接口和Redis实时库存接口获取,用轮询方式每3秒刷新一次。
这里要避免一个常见问题——前端轮询请求太频繁,把后端接口打爆。我的做法是,大屏看板的实时性不需要精确到秒,3秒间隔已经足够,后端接口再做一层简单的本地缓存,同一秒内的请求直接返回缓存数据,减轻数据库压力。轮询和大屏从设计上就是偏读场景,很少要求精确到一笔订单、一毫秒不差的强一致性,识别出这一点后,技术实现就非常简单了。
5. 防超卖、限流、削峰,秒杀健壮性的三道防线
5.1 防超卖:乐观锁兜底,缓存层防穿透
秒杀系统最不能接受的问题就是超卖——页面显示有库存,用户下单成功,结果发货时发现库存不够。这套系统在防超卖上的设计是双保险。第一道保险是Redis的Lua脚本预扣,原子操作保证并发扣减不会出现超过库存总量的情况。第二道保险是MySQL层面的乐观锁,执行订单插入前的库存校验,用版本号来做条件更新:
UPDATE seckill_product SET stock = stock - 1, version = version + 1 WHERE product_id = #{productId} AND stock > 0 AND version = #{version}这条SQL的核心是WHERE条件里带了stock > 0和version = #{version}两个条件。stock > 0保证不会扣成负数,version比对保证更新是基于最新数据的操作。如果不带version,两个请求同时读到stock = 1,都执行stock = stock - 1,结果就是-1,超卖了。版本号机制让乐观锁把并发控制从“加锁排队”变成“失败重试”,更适合读多写少的秒杀场景。
5.2 限流:令牌桶拦截突发流量
限流的目的是保护后端服务不被瞬间流量冲垮。这套系统的拦截器里实现了一个简化版令牌桶算法:令牌以固定速率往桶里放,请求来了必须取到令牌才能继续执行,取不到就直接返回“系统繁忙,请稍后重试”。相比计数限流(固定窗口内限制请求数),令牌桶的优点是允许短时间内的突发流量,同时限制了持续的高流量——这正好符合秒杀活动的特征:刚开抢那几秒流量最猛,后面慢慢趋平。
关键参数上,令牌桶速率建议设置在网关或拦截器上,以后端能承受的QPS为准。如果后端应用限流了,就说明架构需要扩容或者在网关层增加限流器,单纯的队列削峰是扛不住无限流量的。
5.3 削峰:把“瞬间”拉长,让下游能喘口气
削峰的本质是把冲进来的高峰流量,通过缓冲机制摊平到更长时间段里处理。这套系统在削峰上的做法很典型:消息队列异步下单。用户请求进来之后,先做Redis扣减,然后不是直接同步插入MySQL,而是把订单消息发送到消息队列(ActiveMQ或RabbitMQ),下单操作由消费者异步执行。用户那边立即收到“抢购成功”的响应,实际上订单还在队列里排队等待落库。
这里要注意异步下单的响应顺序。用户看到的是“抢购成功”,但如果消费端堆积严重,订单可能几秒钟后才真正落库。所以Redis在扣减成功的同时会保存一份用户的抢购成功标记,当前端轮询订单状态接口时,优先检查这个标记——有标记就返回成功,不用等数据库落库完成。库存扣减和订单落库是两回事,这个设计把用户体感和数据一致性做了分离,对体验的提升非常明显。
对于库存回补,异步模式下要多一道注意:订单超时关闭的消息也要进队列,库存回补由消费者处理。如果直接在订单过期的回调里同步回补Redis,在高并发释放场景下同样会打满Redis连接,把解耦再做一层放进队列里,整个流程才够健壮。
5.4 降级与兜底:把“最坏情况”提前想好
秒杀系统的最后一道防线是降级策略。当Redis集群出现故障、多个机房网络抖动时,系统需要能从缓存优先自动降级为数据库直连模式。降级的开关可以用配置中心统一管控,也可以用一个DB表存全局配置,服务启动时加载,运行中定期刷新。降级策略的关键是“预设计,不临场拍板”——哪些功能可以关、哪些必须保,活动开始前就要在配置里写清楚。
另一个兜底思路是:秒杀期间的页面静态化。把商品详情页、活动页面等强读场景做成静态文件放到CDN,用户浏览不走后端服务,分担了一大批查询压力。后端只做核心的下单接口和订单查询,其他能静态化、能缓存的资源全部前置。
6. 上线前要做的性能预估与压测,别等线上才追悔
6.1 容量预估的“三个数”法则
上线前做容量规划,我习惯先算三个数:目标并发数、预估QPS、数据库极限TPS。目标并发数由运营根据活动推广力度来定,比如一次大型活动预估有10万用户同时在线;预估QPS用经典公式:用户数除以活动持续时间再乘一个峰值系数。10万人在线不等于10万QPS,大部分人都在浏览页面、点击查看详情,真正发起下单请求的比例可能只有10%左右。
算出大概的下单QPS之后,再对照后端各层的处理能力:Redis单实例可以轻松支撑十万级QPS,MySQL单机写TPS一般就在几千。如果目标QPS超过MySQL极限,就必须依赖缓存层拦截更多流量,或者加机器做分库分表。这套单机版的架构,跑个中等规模的秒杀活动没大问题,但如果你要面向百万级用户,就必须引入更高阶的分层设计。
6.2 JMeter压测三轮法
容量预估是理论值,压测才是真实验证。建议用JMeter对三个场景分别压:仅测试Redis扣减接口(打入1000个并发线程)、完整下单流程(缓存扣减+MySQL落库)、管理端查询接口。第一轮的压测结论决定缓存层参数是否满足要求;第二轮的结果衡量整个链路能不能扛住;第三轮测试防止管理后台把数据库读挂。
压测时重点看三个指标:响应时间(平均RT和P99)、吞吐量(TPS)、错误率。如果P99响应时间超过300毫秒,或者错误率超过1%,就需要停下来定位瓶颈。压测环境的机器配置要和线上相近,否则压测结果没有参考价值。我自己压测时的习惯是,先跑100个线程做基线,再逐步增加并发数,从200、500、1000直到接口开始出现超时,把系统的真实瓶颈摸到底。压测完的结论要记录成文档,包括各层的最大QPS、瓶颈位置、优化措施,这一份数据就是上线前最宝贵的参考资料。
6.3 灰度发布与预热验证
正式上线前,一定要做一轮灰度验证。先挑一个流量比较小的时间窗口,用一小部分真实用户先跑一轮完整流程,重点观察下单成功率、库存扣减准确性、订单落库延迟。灰度阶段的量不需要很大,但要把整个流程从头到尾走通,特别要注意Redis和MySQL两侧的数据是否一致。我一个朋友在做秒杀项目时就遇到过:灰度阶段一切正常,全量上线之后订单全是重复扣库——原因是灰度时的用户量级小,并发竞争不明显,全量之后同一用户并发请求数剧增,而他的幂等校验漏了一个用户维度。这种问题,压测阶段很难压出来,只能靠全量场景去验证。
7. 高频踩坑实录与绕过技巧
7.1 库存扣减正确,商品却显示“还有货但买不了”
有次排查线上问题,日志里大量出现“库存不足”的报错,但管理后台显示库存还有几十件。正常扣减逻辑走到数据库前,Redis已经扣完了。后来仔细排查发现,问题出在Redis预扣和MySQL落库之间的回滚逻辑缺失:Redis里已经预扣了库存,但生成订单环节抛了异常,Redis没有执行回滚,导致Redis的库存被白白扣掉了。解决办法是给下单流程加上try-catch回滚逻辑,任何时候订单生成失败,都要重新执行一次Redis库存回补。这是一个必须在代码层面钉死的逻辑闭环。
7.2 高并发下日志风暴拖垮系统
秒杀期间,日志量是平时的几十倍。刚开始启动时我们用的是同步日志输出到磁盘,结果发现大量线程阻塞在写日志上,接口响应时间飙升。后来改成了异步日志,配置了独立的日志缓冲队列,日志线程单独处理磁盘写入,业务线程不再等待记录日志完成。这个改动看起来技术含量不算高,但对高并发接口的性能影响非常大。想要排查线上问题,日志不能没有,但日志一定不能成为业务链路的阻塞点。
7.3 数据库连接池不够用,SQL全部排队
有一次压测就发现,并发一上千,后端就抛“无法获取数据库连接”的异常。看监控发现连接池被占满,而且大量连接是慢查询占着的。定位后发现是一条订单状态的关联查询SQL没走索引,在数据量几万的情况下查一次要好几秒,连接根本释放不出来。解决方案有两个:一是优化SQL,把关联字段都加上索引;二是给连接池设置合理的最大连接数,同时增加连接等待超时时间。秒杀环境下数据库连接是稀缺资源,任何一条慢SQL的影响都会被放大数十倍,上线前必须把管理端的查询SQL全部过一遍执行计划。
7.4 前端重复点击把接口打废
前端没有做防重复点击时,一个用户狂点按钮,几十个请求瞬间打到后端。虽然Redis层加上幂等校验能挡掉大部分,但这几十个请求本身已经消耗了网络带宽和服务器的请求处理能力。更好的做法是在前端先把住这第一道闸:按钮点击后立即置灰,改成“抢购中...”状态并禁用连续点击;同时后端接口在进入逻辑前,先从Redis读一下用户是否已有抢购记录,有就直接拒绝,不进入Lua脚本扣减流程。前端拦截 + 后端Redis校验 + 数据库唯一索引,一个三层防线下来,重复问题基本清零。
7.5 时间同步问题——系统时间不一致导致活动乱套
秒杀系统对时间特别敏感,活动开始时间前后一分钟内的请求,业务状态可能完全不同。如果后端服务器的系统时间不一致,就会出现A机器上活动已开始、B机器上活动还没开始的情况。解决方法是所有服务器统一用NTP时间同步,主动将时区与时间基准对齐。更稳妥的做法是,活动是否开始/结束的判定不依赖服务器本地时间,而是从Redis缓存里取活动开始时间的标准值,或者用数据库时间。这一点容易被忽略,但踩过一次坑就记住了。
8. 扩展思路:把单机版向分布式演进
这套系统是完整可运行的,但如果你打算把它用在更高规格的生产环境里,有几个演进方向值得思考。第一个是数据库层的分库分表。订单表的数据量一旦过千万,单表查询已经扛不住,需要按订单ID或者用户ID做水平拆分。分库分表的中间件方案可以选现成的,但对这套系统来说,短期可以用分区表先顶一顶,把订单表按时间做range分区,能撑到很大数据量才需要做真正的分库。
第二个是缓存层的集群化。目前Redis是单机部署,到了一定并发量,单台Redis的网络带宽就会成为瓶颈。演进方向是主从复制加哨兵,读操作走从节点,写操作走主节点,同时用一致性哈希做切片集群,把热点商品的库存分布在多个Redis节点上。热点Key问题是缓存层最常见的坑,单一热key的流量可能占整个集群的30%,需要用本地缓存加分布式缓存的二级缓存架构来分散压力。
第三个是消息队列的高可用改造。从单机队列换成集群方案(如Kafka/RabbitMQ镜像模式),保证消费者挂掉一个节点时消息不丢。异步下单链路里的消息,必须有失败重试机制,临时性的网络抖动不能导致订单丢失。这里要考虑消息可靠性,消费者逻辑必须实现幂等——同一个订单消息被重复消费时,不会重复插入订单。实现上不难,在消费逻辑里先查订单表的唯一索引,存在就跳过。
写在最后
做完这套系统,我的感受是:秒杀系统的核心不在某一个奇技淫巧,而在于把每一个环节的余地都留出来。缓存层要想好数据怎么预热、怎么防击穿;数据库要想好索引怎么建、锁怎么控制;前端要想好怎么限制重复请求;运维要想好怎么压测、怎么监控。每一个环节都宽松一点,整个系统就稳很多。我实际在项目里跑下来,这套代码作为起步框架是很趁手的,你可以在这个基础上把监控面板、分布式锁、消息队列逐步加上去,每加一层,系统能承受的量级就上一个大台阶。遇到具体问题欢迎交流,秒杀这行的经验,大多是从坑里爬出来的。