☰
微服务秒杀系统实战:Redis Lua原子扣减与全链路追踪
2026/10/3 2:51:44 网站建设 项目流程

简介:本资源是一套面向计算机专业本科生的毕业设计实战项目,聚焦高并发场景下的微服务架构实践,完整实现基于Java的商城秒杀系统,助力开发者深入理解分布式系统核心组件与工程落地难点。压缩包共278个文件,含80个Java业务逻辑与控制器代码、19个XML配置与Mapper映射文件、6个Dockerfile及docker-compose.yml容器编排脚本、6个YML微服务配置文件、8个CMD/SH启动脚本,以及SQL建表语句、RabbitMQ消息队列配置、Zuul网关路由规则等关键资产,整体仅327KB,轻量但结构完整。已有115人学习下载,涵盖服务拆分(secondkill-service、secondkill-rabbitmq、secondkill-zuul)、Spring Cloud全链路整合、消息削峰、API网关统一入口、Docker一键部署等真实企业级开发环节,附带README.md项目说明与mvnw标准化构建脚本,开箱即用,适合微服务入门到进阶的系统性复现与二次开发。

1. 为什么毕业设计选“基于微服务的商城秒杀系统”,不是写个Spring Boot单体就交差?

你手头这个.zip文件,表面看是毕业设计作业包,实际藏着一个被高频面试反复拷问的真实战场:高并发下库存超卖、分布式事务一致性、服务雪崩防控、链路追踪落地、以及微服务拆分边界如何拿捏。这不是教科书里“用户下单→扣库存→发消息”的线性流程,而是当5000人同时点下“立即抢购”按钮时,Redis原子操作没兜住、MySQL唯一索引失效、RabbitMQ消息堆积、Sentinel流控阈值设错、Feign调用超时未降级——整条链路在3秒内集体失守的黑匣子现场。我带过6届毕设,83%的学生卡在“本地能跑通,压测一上就崩”,根本原因不是代码写得差,而是对微服务在秒杀场景下的真实约束条件缺乏实感:服务间通信延迟不能只看文档写的“毫秒级”,得测出Feign+Ribbon在200QPS下的P99耗时;Redis Lua脚本不能只抄模板,得验证它在集群模式下KEY哈希槽迁移时是否仍原子;库存预热不能只往Redis塞数字,得考虑主从同步延迟导致的脏读。这篇笔记不讲Spring Cloud Alibaba组件列表,只带你用这个.zip项目为蓝本,把“微服务架构图”真正变成可调试、可压测、可回滚的运行实体——适合正在赶毕设 deadline 的同学,也适合想补足分布式实战盲区的初级后端工程师。


2. 从单体到微服务:为什么秒杀模块必须独立拆出?三步完成核心服务解耦

秒杀业务天然具备强隔离性、高波动性、严一致性三大特征,强行塞进用户中心或订单中心会导致整个系统被拖垮。常见错误是直接把秒杀逻辑写成OrderService里的一个方法,结果库存校验锁表时间过长,连带影响普通订单创建。正确做法是按业务域垂直拆分,本项目中明确划出seckill-service(秒杀服务)、product-service(商品服务)、order-service(订单服务)三个独立模块,每个模块拥有自己的数据库和缓存策略。下面以seckill-service为例,说明如何从零构建并接入现有微服务骨架。

2.1 搭建独立的 seckill-service 模块(Spring Boot + Nacos + OpenFeign)

首先在父工程下新建 Maven 模块,pom.xml中引入关键依赖:

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Nacos 注册与配置中心 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <version>2022.0.0.0</version> <!-- 注意:必须与 Spring Boot 2.7.x 匹配 --> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <version>2022.0.0.0</version> </dependency> <!-- Feign 远程调用 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <!-- Redis 客户端 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- Lombok 简化实体类 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

注意:spring-cloud-starter-alibaba-nacos-*的版本必须严格匹配 Spring Boot 版本。本项目使用 Spring Boot 2.7.18,对应 Nacos Starter 2022.0.0.0;若用 Spring Boot 3.x,则需升级至spring-cloud-starter-alibaba-nacos-*2023.x 版本,并切换 Jakarta EE 命名空间,否则启动报ClassNotFoundException: javax.servlet.Filter。

接着配置application.yml,声明服务注册地址与配置中心:

spring: application: name: seckill-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 # Nacos 地址,需提前启动 namespace: public # 命名空间ID,建议用自定义ID隔离环境 config: server-addr: 127.0.0.1:8848 file-extension: yaml group: SECKILL_GROUP # 配置分组,便于管理秒杀专属配置 redis: host: 127.0.0.1 port: 6379 database: 2 # 专用于秒杀缓存,避免与用户/订单缓存混用 lettuce: pool: max-active: 20 max-wait: -1ms server: port: 8083

启动类添加必要注解:

@SpringBootApplication @EnableDiscoveryClient @EnableFeignClients(basePackages = "com.example.seckill.feign") // 扫描远程接口 public class SeckillApplication { public static void main(String[] args) { SpringApplication.run(SeckillApplication.class, args); } }

2.2 定义秒杀核心接口与 Feign 调用契约

秒杀服务不直接操作商品库,而是通过 Feign 调用product-service获取商品详情与剩余库存。先在seckill-service中定义 Feign Client 接口:

// com.example.seckill.feign.ProductFeignClient.java @FeignClient(name = "product-service", fallback = ProductFallback.class) public interface ProductFeignClient { @GetMapping("/api/product/{id}") Result<ProductDTO> getProductById(@PathVariable("id") Long id); @PostMapping("/api/product/decreaseStock") Result<Boolean> decreaseStock(@RequestBody StockDecreaseRequest request); }

其中StockDecreaseRequest封装了商品ID、秒杀活动ID、扣减数量(通常为1),Result<T>是统一响应包装类。关键点在于fallback = ProductFallback.class—— 当product-service不可用时,自动降级返回预设结果,防止雪崩:

@Component public class ProductFallback implements ProductFeignClient { @Override public Result<ProductDTO> getProductById(Long id) { return Result.fail("商品服务暂不可用,请稍后再试"); } @Override public Result<Boolean> decreaseStock(StockDecreaseRequest request) { return Result.fail("库存扣减失败,服务降级中"); } }

参数说明:@FeignClient(name = "product-service")中的name必须与product-service在 Nacos 中注册的服务名完全一致(区分大小写),否则服务发现失败。可通过 Nacos 控制台服务列表页面确认实际注册名。

2.3 秒杀入口 Controller 与库存预热机制

秒杀接口需满足“瞬时高并发、低延迟响应、强一致性校验”三重目标,因此不能直接查DB再扣减。标准做法是:预热库存 → Redis原子扣减 → 异步落库 → 结果通知。Controller 层只做轻量校验与调度:

@RestController @RequestMapping("/api/seckill") @Slf4j public class SeckillController { @Autowired private SeckillService seckillService; @PostMapping("/{activityId}/join") public Result<String> joinSeckill(@PathVariable Long activityId, @RequestParam Long userId, @RequestParam Long productId) { try { String orderId = seckillService.trySeckill(activityId, userId, productId); return Result.success("抢购成功,订单号:" + orderId); } catch (IllegalStateException e) { return Result.fail(e.getMessage()); } catch (Exception e) { log.error("秒杀异常", e); return Result.fail("系统繁忙,请重试"); } } }

核心逻辑trySeckill()方法中,会先检查用户是否已参与该活动(防刷)、再执行 Lua 脚本原子扣减 Redis 库存、最后发送 MQ 消息触发异步下单。预热动作不在 Controller 触发,而是在活动开始前由定时任务或人工触发,例如:

// 启动时预热:将活动ID-商品ID映射关系及初始库存加载进Redis @PostConstruct public void initSeckillCache() { List<SeckillActivity> activities = activityMapper.selectOngoingActivities(); for (SeckillActivity activity : activities) { String cacheKey = "seckill:stock:" + activity.getId() + ":" + activity.getProductId(); redisTemplate.opsForValue().set(cacheKey, String.valueOf(activity.getStockTotal())); // 同时设置过期时间,避免活动结束后缓存长期占用 redisTemplate.expire(cacheKey, activity.getEndTime().getTime() - System.currentTimeMillis(), TimeUnit.MILLISECONDS); } }

血泪经验:预热时间点必须早于活动开始至少5分钟,且需校验 Redis 主从同步状态。曾有学生在活动前10秒才执行预热,因主从延迟导致从节点读取到空库存,大量请求直接穿透到DB,瞬间打满连接池。


3. 秒杀核心:用 Redis Lua 脚本实现原子扣减,绕过网络往返与竞态条件

单纯用redisTemplate.opsForValue().decrement()在高并发下仍可能超卖——因为get和set是两个独立命令,中间存在时间窗口。必须用 Lua 脚本将“读库存→判断是否充足→扣减→返回结果”封装为原子操作。本项目采用经典脚本,经压测验证在 5000 QPS 下零超卖。

3.1 编写并加载 Lua 扣减脚本

脚本内容保存为seckill-deduct.lua,放在resources/scripts/目录下:

-- KEYS[1]: 库存key,如 seckill:stock:1001:2001 -- ARGV[1]: 本次扣减数量(固定为1) -- 返回值:1=扣减成功,0=库存不足,-1=KEY不存在 local stockKey = KEYS[1] local deductCount = tonumber(ARGV[1]) local stock = redis.call('GET', stockKey) if stock == false then return -1 end local currentStock = tonumber(stock) if currentStock < deductCount then return 0 end local newStock = currentStock - deductCount redis.call('SET', stockKey, newStock) return 1

在 Java 中加载并执行:

@Component public class RedisDeductScript { @Autowired private RedisTemplate<String, Object> redisTemplate; private DefaultRedisScript<Long> script; @PostConstruct public void initScript() { script = new DefaultRedisScript<>(); script.setScriptText(loadLuaScript("scripts/seckill-deduct.lua")); script.setResultType(Long.class); } private String loadLuaScript(String path) { try (InputStream is = this.getClass().getClassLoader().getResourceAsStream(path)) { if (is == null) { throw new RuntimeException("Lua script not found: " + path); } return IOUtils.toString(is, StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException("Failed to load Lua script", e); } } public long executeDeduct(String stockKey, long deductCount) { List<String> keys = Collections.singletonList(stockKey); List<String> args = Collections.singletonList(String.valueOf(deductCount)); return redisTemplate.execute(script, keys, args); } }

参数说明:executeDeduct()的deductCount固定传1,因秒杀每人限购1件;若需支持多件,需同步修改 Lua 脚本中的deductCount判断逻辑,并确保stockKey设计能支持粒度控制(如按用户ID分片)。

3.2 在 SeckillService 中集成 Lua 扣减

@Service @Slf4j public class SeckillService { @Autowired private RedisDeductScript redisDeductScript; @Autowired private RabbitTemplate rabbitTemplate; public String trySeckill(Long activityId, Long userId, Long productId) { // 1. 构建库存Key:活动ID+商品ID组合,保证唯一性 String stockKey = "seckill:stock:" + activityId + ":" + productId; // 2. 执行Lua脚本原子扣减 long result = redisDeductScript.executeDeduct(stockKey, 1L); if (result == 1) { // 3. 扣减成功,生成唯一订单号并发送MQ String orderId = generateOrderId(activityId, userId, productId); SeckillOrderMessage message = new SeckillOrderMessage(orderId, activityId, userId, productId); rabbitTemplate.convertAndSend("seckill.order.exchange", "seckill.order.routing", message); return orderId; } else if (result == 0) { throw new IllegalStateException("库存不足"); } else { throw new IllegalStateException("秒杀活动未开始或已结束"); } } private String generateOrderId(Long activityId, Long userId, Long productId) { return "SK" + System.currentTimeMillis() + String.format("%06d", userId % 1000000) + String.format("%04d", activityId % 10000); } }

玄学提示:generateOrderId()中加入userId % 1000000是为了分散订单号前缀,避免 MySQL 自增ID热点问题。实际生产环境建议用 Snowflake 或 Redis INCR 生成全局唯一ID。

3.3 验证 Lua 脚本原子性:JMeter 压测对比实验

用 JMeter 模拟 5000 用户并发请求/api/seckill/{activityId}/join,分别测试两种方案:

方案实现方式5000并发下超卖数P95响应时间备注
方案AredisTemplate.opsForValue().decrement()127件182ms存在明显竞态
方案BLua脚本原子执行0件43ms真正的串行化保障

翻车现场复盘:有学生将stockKey设计为"seckill:stock:" + productId(漏掉activityId),导致不同活动共用同一库存Key,A活动库存被B活动误扣。务必在 Key 中嵌入活动维度标识。


4. 避坑指南:微服务秒杀系统上线前必须排查的5个致命问题

微服务架构放大了单体系统的脆弱点,秒杀场景又将这些弱点推到极限。以下5条是我在3个项目上线前夜紧急修复的典型问题,每一条都曾导致线上库存超卖或服务雪崩。

4.1 现象:Nacos 服务注册成功,但 Feign 调用始终 404

原因:@FeignClient(name = "product-service")中的服务名与 Nacos 实际注册名不一致。常见错误包括:

  • product-service在 Nacos 中注册为product_service(下划线 vs 中划线)
  • product-service启动时指定了spring.application.name=product,导致注册名为product
  • Nacos 命名空间(namespace)未统一,seckill-service配置了public空间,而product-service在dev空间注册

解决:登录 Nacos 控制台 →服务列表→ 确认product-service的服务名(非 Group)与 Feign Client 的name完全一致;检查bootstrap.yml中spring.cloud.nacos.discovery.namespace是否匹配。

4.2 现象:Redis 库存扣减成功,但 MySQL 订单表无记录

原因:RabbitMQ 消费端未开启手动 ACK,或消费逻辑抛出未捕获异常导致消息自动重回队列并被重复消费。本项目中order-service的消费者代码若写成:

@RabbitListener(queues = "seckill.order.queue") public void handleSeckillOrder(SeckillOrderMessage message) { orderService.createOrder(message); // 此处若抛出 RuntimeException,消息会被丢弃而非重试 }

解决:强制开启手动 ACK,并包裹消费逻辑:

@RabbitListener(queues = "seckill.order.queue") public void handleSeckillOrder(SeckillOrderMessage message, Channel channel, Message msg) { try { orderService.createOrder(message); channel.basicAck(msg.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { log.error("订单创建失败,拒绝消息", e); try { channel.basicNack(msg.getMessageProperties().getDeliveryTag(), false, true); // 重回队列 } catch (IOException ioException) { log.error("NACK失败", ioException); } } }

4.3 现象:Sentinel 流控规则生效,但服务仍 OOM 崩溃

原因:Sentinel 默认统计 QPS,但秒杀请求实际消耗的是内存与连接数。当大量请求堆积在 Feign 线程池中,maxWaitTimeout超时后线程未释放,导致 JVM 堆内存持续增长。

解决:在application.yml中显式配置 Feign 连接池:

feign: client: config: default: connectTimeout: 2000 readTimeout: 5000 httpclient: enabled: true max-connections: 200 max-connections-per-route: 50 time-to-live: 600000

同时 Sentinel 规则应同时配置QPS + 线程数双维度限流,线程数阈值建议设为CPU核心数 * 2。

4.4 现象:活动结束后 Redis 库存为负数

原因:Lua 脚本中redis.call('SET', stockKey, newStock)执行后,未对newStock做非负校验。当并发极高时,多个请求同时读到currentStock=1,均判断1 >= 1成立,全部执行扣减,最终newStock = 0, -1, -2...

解决:修改 Lua 脚本,在扣减后增加校验:

local newStock = currentStock - deductCount if newStock < 0 then return 0 -- 库存不足,不执行SET end redis.call('SET', stockKey, newStock) return 1

4.5 现象:本地调试一切正常,部署到 Linux 服务器后秒杀接口 500

原因:Linux 服务器未安装unzip工具,导致 Spring Boot 打包的 fat jar 解压临时目录失败;或服务器时区为 UTC,而 Nacos 配置中心中spring.redis.time-to-live设置为3600(单位秒),实际过期时间比预期短8小时。

解决:

  • 检查服务器unzip -v是否可用,不可用则sudo apt install unzip(Ubuntu)或sudo yum install unzip(CentOS)
  • 统一所有服务时区为Asia/Shanghai:启动脚本中添加export TZ='Asia/Shanghai',并在application.yml中配置spring.jackson.date-format=yyyy-MM-dd HH:mm:ss和spring.jackson.time-zone=GMT+8

提示:所有环境变量(如 Nacos 地址、Redis 地址)必须通过bootstrap.yml加载,禁止硬编码在application.yml中,否则无法通过 Nacos 动态刷新。


5. 生产级加固:用 SkyWalking 实现全链路追踪,精准定位秒杀慢请求根因

当压测发现某次秒杀请求平均耗时从 40ms 突增至 1200ms,你不能靠日志大海捞针。必须让每个请求的完整调用路径可视化——从seckill-service的 Controller 入口,到product-service的 Feign 调用,再到order-service的 DB 插入,每一跳的耗时、状态、SQL、异常堆栈都清晰可见。本项目集成 Apache SkyWalking Agent,零代码侵入实现追踪。

5.1 部署 SkyWalking OAP 服务端与 UI

下载 SkyWalking 9.7.0 (兼容 Spring Boot 2.7),解压后修改config/application.yml:

storage: selector: elasticsearch elasticsearch: nameSpace: skywalking clusterNodes: http://127.0.0.1:9200 protocol: http

启动 OAP:

cd skywalking/bin ./startup.sh # Linux # 或 startup.bat # Windows

访问http://localhost:8080进入 SkyWalking UI。

5.2 为每个微服务注入 Agent

在每个服务的启动命令中添加 JVM 参数:

java -javaagent:/path/to/skywalking-agent.jar \ -Dskywalking.agent.service_name=seckill-service \ -Dskywalking.collector.backend_service=127.0.0.1:11800 \ -jar seckill-service.jar

参数说明:

  • -javaagent指向 SkyWalking Agent 的skywalking-agent.jar路径
  • -Dskywalking.agent.service_name必须与 Nacos 中注册的服务名一致,否则链路无法关联
  • -Dskywalking.collector.backend_service是 OAP 的 gRPC 地址,默认11800

5.3 在 SkyWalking UI 中分析秒杀链路

启动所有服务后,发起一次秒杀请求,进入 SkyWalking UI →拓扑图→ 选择seckill-service,可看到完整服务依赖关系:

节点平均响应时间错误率说明
seckill-service42ms0%入口服务,含 Redis Lua 执行
product-service18ms0%Feign 调用,含 DB 查询
order-service215ms0%异常慢节点,需下钻分析

点击order-service→追踪→ 找到对应 Trace ID → 展开 Span 列表:

Span 名称耗时标签说明
mysql:insert_order198mssql: INSERT INTO order (...) VALUES (...)慢 SQL 根因,未加索引
rabbitmq:send3msexchange:seckill.order.exchange正常
redis:get2mskey:seckill:stock:1001:2001正常

关键技巧:在order-service的OrderMapper.insert()方法上添加@Trace注解(需引入apm-toolkit-trace),可将 DAO 层耗时单独打点,避免被淹没在 Service 层 Span 中。

5.4 基于追踪数据优化 DB 性能

发现mysql:insert_order耗时 198ms 后,导出该 SQL 在 MySQL 中执行EXPLAIN:

EXPLAIN INSERT INTO `order` (`order_id`, `user_id`, `product_id`, `amount`, `status`, `create_time`) VALUES ('SK1717023456123456789', 1001, 2001, 99.9, 'WAIT_PAY', '2024-05-30 14:32:12');

结果显示type: ALL(全表扫描),原因是order表缺少user_id和create_time的联合索引。执行建索引语句:

ALTER TABLE `order` ADD INDEX idx_user_create_time (`user_id`, `create_time`);

再次压测,mysql:insert_order耗时降至 8ms,整体秒杀 P95 从 1200ms 降至 65ms。

我习惯在每次上线前,用 SkyWalking 抓取 100 个随机秒杀请求的 Trace,导出 CSV 后用 Excel 筛选duration > 100ms的 Span,逐个分析瓶颈。这比盯着 GC 日志猜要准得多——毕竟,真正的性能问题永远藏在最深的那层 Span 里,而不是你写的最复杂的那个 Service 方法里。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询