news 2026/10/9 3:09:34

高并发秒杀系统架构实践:限流、Redis原子扣减与MQ异步落库

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
高并发秒杀系统架构实践:限流、Redis原子扣减与MQ异步落库

简介:这是一套面向Java初、中级开发者的秒杀系统入门实现项目,基于Spring Boot 2.x编写,适合想理解高并发抢购场景核心应对思路的读者。项目针对限流、缓存预加载、分布式负载、消息队列异步处理、验证码防刷等关键机制组织代码,内容覆盖从商品详情、秒杀入口、库存处理到订单落库的常见闭环,能直观看到业务层、数据层的技术取舍。压缩包共66个文件,约2.34MB,主要包含Java源码、HTML/CSS/JS前端页面、SQL建表与数据脚本、配置文件和说明文档,整体结构清晰,便于按模块查阅。目前已有164人学习下载。除可运行工程外,还附有界面展示截图,可结合README与SQL脚本快速搭建环境并验证下单、库存扣减等流程;对希望以较小体量建立高并发系统全局认识的同学而言,是一份省时省力的参考项目。

1. 秒杀系统到底在防什么:别一上来就写库存扣减 SQL

做过 Java 后端的人大概都见过这种场景:秒杀一开始,服务器日志里瞬间被 “Connection reset”“Lock wait timeout exceeded” 刷爆,数据库连接池被打满,前端页面白屏,用户一遍遍重试,后端一遍遍报错。我早期也干过“简单实现一把梭”的事,直接在 MySQL 执行一条UPDATE seckill_product SET stock = stock - 1 WHERE product_id = ?扣库存,压测还没跑到 500 并发,数据库先跪了。高并发秒杀系统的本质,不是“把 SQL 写得快一点”,而是把短时洪峰切碎成可控的请求流,让每层只承担自己该承担的流量。Java 技术栈里比较成熟的落地路线是三层协作:网关和接口层限流、Redis 做原子扣减与用户去重、MQ 异步把订单写回 MySQL。这篇就按这条链路,给出一个能跑通的最小实现,新手可以照着搭,有经验的人可以重点看参数边界和故障场景。

2. 把秒杀链路分层拆开:限流、扣减、落库各管一段

2.1 秒杀的瓶颈不是并发数量,而是请求里 90% 都是无效请求

秒杀场景下的高并发,其实和普通业务高 qps 不一样。普通接口的请求基本都有业务价值,数据库在承受压力时至少是在处理合法数据;而秒杀接口,尤其是“点击抢购”这个动作,每一秒进入的请求里可能有九成以上是重复点击、脚本轮询、机器人刷单。这些请求最后都会落到数据库,给 MySQL 带来大量毫无意义的连接创建、事务开启和行锁竞争。

我见过不少团队在秒杀前扩容数据库、加连接池大小、升级磁盘 IO,结果一压测,发现瓶颈出在一条SELECT ... FOR UPDATE上。原因很简单:秒杀的核心数据只有一行或几行库存记录,所有事务都在抢同一把行锁。并发越高,锁等待越长,事务堆积越多,最后连接池耗尽,整个服务直接雪崩。所以说,秒杀系统的设计目标不是“把所有请求都处理完”,而是“只让极少数真正有资格的请求到达资源层”。

这也决定了架构选型的方向:请求进不来比数据写不进去更好处理。验证码、答题、按钮置灰、接口限流这些手段,本质上都是在业务资源层之前设置一道道“筛子”,把流量逐级削薄。等流量真正到 Redis 和数据库时,已经是一小部分有效请求了。

2.2 主链路设计:接口限流 → Redis 原子扣减 → MQ 异步落库

常见的高并发秒杀落地结构可以归纳成下面这条链路,每一层的职责都尽量保持单一:

用户请求 → Nginx:静态资源分离 + limit_req 限流 → 后端接口:校验 token、验证码、用户登录态 → Redis:Lua 脚本原子扣减库存 + 记录已参与用户 → MQ:写入秒杀订单消息 → 消费者:创建订单表记录 + 扣减数据库库存 → 定时补偿:对账“已扣减未下单”的数据

前端先做一道:按钮置灰、验证码点选、跳转等待页。这些交互不是面子工程,而是把笨重的人工重复请求挡在门外。比如按钮置灰后,一个用户在一次活动里最多点击一两次,而不是几十次。

Nginx 层做第二道:按 IP 或者用户维度限流。秒杀场景里我会用limit_req_zone,配置示例大致这样:

limit_req_zone $binary_remote_addr zone=seckill_limit:10m rate=10r/s; limit_req zone=seckill_limit burst=20 nodelay;

这里rate=10r/s表示每个 IP 每秒放行 10 个请求,burst=20允许瞬间峰值 20 个,nodelay表示这 20 个直接放行不排队。Nginx 层限流的参数要根据活动规模调,一般秒杀场景我会压到单 IP 每秒 1 到 5 个请求,留少量重试余地,否则用户刷新一下就被拒,体验上会显得很“卡”。

第三道在后端接口里做:token 幂等校验和 Redis 令牌限流。用户进入活动页面时,前端向后端申请一个一次性 token,抢购时带上这个 token,后端用SETNX校验,能有效防止同一用户同一时刻重复提交。

真正的库存扣减放在 Redis 里,用 Lua 脚本保证原子性。Redis 是单线程模型,一条 Lua 脚本执行期间不会穿插其他命令,所以“检查库存、扣减库存、记录用户”三个动作可以一次完成。最后,订单数据异步投递到 MQ,消费者拿到消息后再写订单表。这样一来,数据库在最坏情况下面对的写入量也只是“真实秒杀成功人数”,而不是“请求总数”。

2.3 为什么库存必须放 Redis,订单必须落 MySQL

这个问题的答案其实是从“数据重要性”出发的。库存数据是瞬时热点,前面说了,所有请求都在抢同一行记录。如果第二步就把这条更新放到数据库,即使只更新一行,也会因为行锁、事务日志、binlog 同步、磁盘 fsync 等原因把延迟拉高,导致接口被拖死。Redis 的DECR是纯内存操作,单线程执行,没有锁竞争,单实例每秒几十万次完全没问题。

订单数据则不一样。订单关系到用户的资产、支付、售后,必须持久化,而且需要支持事务、回溯、对账。你可以容忍“Redis 里库存显示没抢到,个别用户却多下了一单”,但绝不能容忍“用户已付款,订单却丢了”。所以订单落到 MySQL,库存预热到 Redis,这是一条基本原则。

既然库存放 Redis,就需要关注一个现实问题:Redis 数据是易失的,进程重启、主从切换、内存淘汰都可能造成库存不准。常见的做法是活动开始前把数据库中的库存值同步到 Redis,活动结束后再依据订单表和 MySQL 库存做一次校正。也就是说,Redis 里的库存要当作“活动期间的临时凭证”,而不是最终账本;最终账本永远是以 MySQL 订单记录为准的。这一点想明白,很多对账问题就不会慌。

3. 用 Spring Boot 跑通最小秒杀链路:Redis 原子扣减 + MQ 异步下单

3.1 表结构设计与初始数据准备

先把数据库表建好。秒杀系统和普通扣库存最大区别是不需要把“库存字段”和其他业务字段混在同一张表里,我一般会拆成两张:一张商品表,一张订单表。商品表里存总库存和剩余库存,订单表只记录事实。

CREATE TABLE `seckill_product` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID', `product_name` varchar(128) NOT NULL COMMENT '商品名称', `total_stock` int(11) NOT NULL COMMENT '总库存', `remain_stock` int(11) NOT NULL COMMENT '剩余库存', `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:0下线 1上线', `version` int(11) NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `start_time` datetime NOT NULL COMMENT '活动开始时间', `end_time` datetime NOT NULL COMMENT '活动结束时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀商品表';

订单表加了一个uk_user_product唯一索引,这算是“后悔药”:就算业务代码里有重复消费、重复补偿,数据库唯一键也能兜住最后一层。

CREATE TABLE `seckill_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '订单号', `user_id` bigint(20) NOT NULL COMMENT '用户ID', `product_id` bigint(20) NOT NULL COMMENT '商品ID', `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态:0待支付 1已支付 2取消', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_user_product` (`user_id`, `product_id`), KEY `idx_product_status` (`product_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀订单表';

订单号要全局唯一,不能只用数据库自增 ID。高并发下可以用“当前时间戳 + 用户ID + 随机数”拼,或者直接用雪花算法。关键点是把uk_user_product唯一键加在(user_id, product_id)上,保证一个用户对一个活动商品只能有一条订单记录,这也是后面处理 MQ 重复消息的最后一道防线。

3.2 库存预热:活动开始前把库存加载到 Redis

抢购开始前,需要把剩余库存从 MySQL 同步一份到 Redis。这一步通常放在活动时间的start_time前几分钟,由定时任务触发,或者系统启动时执行。简单做法是这样:

@PostConstruct public void warmUp() { List<SeckillProduct> products = seckillProductMapper.selectRunningProducts(); for (SeckillProduct product : products) { String key = "seckill:stock:" + product.getId(); Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(key, String.valueOf(product.getRemainStock()), 48, TimeUnit.HOURS); if (Boolean.TRUE.equals(success)) { log.info("库存预热完成,productId={},remainStock={}", product.getId(), product.getRemainStock()); } } }

setIfAbsent而不是直接set,是为了防止服务重启或定时任务重复执行时覆盖掉已经被扣减的库存值。过期时间一般设置为整个活动周期的时长,活动结束后任务会自动清理。注意:预热的数据来自 MySQL 的remain_stock,所以活动数据库里的初始库存必须是准确值,否则后面 Redis 扣减就失去了意义。

3.3 Redis 扣减用 Lua 脚本:检查、扣减、去重一次完成

核心逻辑在 Lua 脚本里。为什么一定要用 Lua?因为“判断库存是否充足”和“扣减库存”两步必须原子执行。如果用 Java 代码先查再减,两个线程同时查到一个库存,就会发生超卖。Redis 的 Lua 脚本是原子执行的,可以一次把多个命令打包提交。

-- KEYS[1]:库存 key,例如 seckill:stock:1001 -- KEYS[2]:已参与的 set,例如 seckill:users:1001 -- ARGV[1]:用户ID -- ARGV[2]:set 的过期时间(秒) if redis.call('exists', KEYS[1]) == 0 then return -2 end local stock = tonumber(redis.call('get', KEYS[1])) if stock <= 0 then return -1 end if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then return 0 end redis.call('decr', KEYS[1]) redis.call('sadd', KEYS[2], ARGV[1]) redis.call('expire', KEYS[2], ARGV[2]) return 1

Java 侧调用这段脚本,返回值就是业务结果:1表示扣减成功,0表示已经参与过,-1表示库存不足,-2表示活动不存在或已结束。

public long tryAcquire(Long userId, Long productId) { String stockKey = "seckill:stock:" + productId; String userKey = "seckill:users:" + productId; DefaultRedisScript<Long> script = new DefaultRedisScript<>(SEckill_LUA, Long.class); Long result = stringRedisTemplate.execute( script, Arrays.asList(stockKey, userKey), String.valueOf(userId), String.valueOf(24 * 60 * 60) ); return result == null ? -2L : result; }

stringRedisTemplate.execute默认是同步调用,耗时一般不到一毫秒。这里要注意用户集合 key 过期时间不要太短,否则活动还没结束,集合里的记录被 Redis 清理掉,同一个用户就可以再次抢购。一般是和活动周期一致,或者比活动多预留几小时,方便后续对账任务使用。

3.4 MQ 消费者:异步创建订单,数据库条件扣减

抢购接口扣减 Redis 库存之后,马上要向 MQ 投递一条秒杀消息。消息体可以是一个包含userId和productId的简单对象。消费者监听队列,真正去写订单表。

@RabbitListener(queues = "seckill.order.queue", concurrency = "8") public void onMessage(SeckillOrderMessage message) { Long userId = message.getUserId(); Long productId = message.getProductId(); // 1. Redis 集合去重,MQ 至少消费一次,这里过滤重复投递 Boolean member = stringRedisTemplate.opsForSet() .isMember("seckill:users:" + productId, userId.toString()); if (Boolean.TRUE.equals(member)) { // 已经给这个用户生成过订单,直接跳过 return; } // 2. 事务内:先插订单,再扣库存 seckillOrderService.createOrder(userId, productId); }

createOrder方法必须包在事务里,并且两步操作都要有结果校验:

@Transactional(rollbackFor = Exception.class) public void createOrder(Long userId, Long productId) { String orderNo = orderNoGenerator.generate(); SeckillOrder order = new SeckillOrder(); order.setOrderNo(orderNo); order.setUserId(userId); order.setProductId(productId); order.setStatus(0); // 唯一索引 uk_user_product 兜底,重复插入会抛 DuplicateKeyException seckillOrderMapper.insert(order); // 数据库库存扣减必须放在条件语句里 int rows = seckillProductMapper.decreaseStock(productId); if (rows == 0) { throw new BusinessException("库存不足"); } // 3. 订单创建成功后,把用户加入 Redis 成功集合 stringRedisTemplate.opsForSet().add("seckill:users:" + productId, userId.toString()); }

这里的扣减 SQL 不是普通的SET stock = stock - 1,必须带上条件:

<update id="decreaseStock"> UPDATE seckill_product SET remain_stock = remain_stock - 1, version = version + 1 WHERE id = #{productId} AND remain_stock > 0 </update>

MySQL 的行数返回1或0,这是数据库层防超卖的最终保障。之前 Redis 已经扣过一次库存,数据库这边理论上不会出现rows == 0的情况,但只要消息重复、数据不一致或定时补偿任务越权执行,这个条件就是保险丝。事务回滚会把订单和库存的更新一起撤销,最终数据还是不会错。

3.5 串起抢购接口:Controller、Service、MQ 一次连上

最后是入口接口,把上面所有环节串起来:

@PostMapping("/api/seckill/execute") public Result<Void> execute(@RequestParam Long userId, @RequestParam Long productId, @Header("X-Request-Token") String token) { // 1. 验证一次性 token,防止重复提交 Boolean first = stringRedisTemplate.opsForValue() .setIfAbsent("seckill:token:" + token, userId.toString(), 30, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(first)) { return Result.fail("请勿重复提交"); } // 2. 执行 Lua 扣减 long code = seckillService.tryAcquire(userId, productId); if (code == 1) { // 3. 异步投递订单消息 mqSender.send(new SeckillOrderMessage(userId, productId)); return Result.ok("正在排队,请稍后查看订单"); } if (code == 0) { return Result.fail("您已参与过该活动"); } if (code == -1) { return Result.fail("商品已抢完"); } return Result.fail("活动未开始或已结束"); }

这个接口做的事非常少:校验 token、执行一次毫秒级 Lua 扣减、投递一条消息,然后立刻返回。数据库只有在消费者模块被触发时才被写入,而且写入量远小于请求量。到这里,一个能跑通的“最小秒杀链路”就算闭环了。

4. 高并发细节:幂等、限流、热点穿透与最终一致

4.1 幂等设计要覆盖两层:请求幂等与消费幂等

秒杀系统的幂等问题,我吃过不少亏。一次抢购动作经过接口层、Redis、MQ、数据库四个环节,任何一个环节重试,都可能产生重复数据。接口层的幂等用一次性 token,“进入页面发 token、抢购时回收 token”是比较干净的方式。Redis 里用SETNX做 token 比对,同时设置 30 到 60 秒的过期时间,防止有人拿同一个 token 在多台机器上并发提交。

消费层的幂等则更隐晦。RabbitMQ 的投递语义是“至少一次”,消费者在业务处理中途宕机,消息会被重新投递。如果不做处理,订单表就会多出多条记录。我的做法是三层检查:

第一层,Redis 成功集合去重。SISMEMBER seckill:users:{productId} {userId},存在就直接跳过,这能拦截大部分重复消息。

第二层,数据库唯一索引。uk_user_product一旦触发DuplicateKeyException,捕获后按“业务已成立”处理,不再继续扣减库存。

第三层,扣库存条件AND remain_stock > 0。就算前面两层都被绕过,这里也能让更新行数为 0,事务回滚,不产生负库存。

这三层缺一不可。只靠 Redis 去重,Redis 一旦丢失或过期就失效;只靠唯一索引,大量重复消息打到插入操作上,白白增加数据库开销;只靠库存条件,用户可能没有订单记录,钱白扣了。

4.2 限流不能只靠 Nginx:业务侧也要加 Redis 计数限流

Nginx 的limit_req只能按 IP 维度控制入口流量,但秒杀场景里一个用户可能通过多个设备、多个 IP 反复尝试,而且多个后端实例都需要共享限流状态。所以业务侧也要加一道基于 Redis 的限流。

最简单的实现是滑动窗口计数器。比如限制每个用户每秒最多 2 次抢购请求:

public boolean rateLimit(Long userId) { String key = "seckill:rate:" + userId + ":" + (System.currentTimeMillis() / 1000); Long count = stringRedisTemplate.opsForValue().increment(key); if (count != null && count == 1) { stringRedisTemplate.expire(key, 10, TimeUnit.SECONDS); } return count != null && count <= 2; }

每秒钟一个 key,第一秒的 key 过期时间设置为 10 秒,不是 1 秒,是为了防止某秒的计数 key 在下个周期开始时还存在。INCR在 Redis 中是原子操作,多实例部署时不会有数值错误。

限流的参数需要压测时慢慢调。比如一个用户正常抢购流程最多发出 2 次请求(进入页面 1 次、点击抢购 1 次),限流阈值设为每秒 5 次是合理的,太低可能把正常用户误伤,太高又挡不住脚本。更严格的场景可以再加一层按商品维度的全局限流,比如整个活动接口的总 QPS 上限,用相同的计数器方式实现,key 换成seckill:rate:global:{productId}。

4.3 热点 Key 与缓存击穿:别让“查库存”也打穿数据库

走到 Redis 这层后,很多团队就会忽略一个问题:库存扣减前,业务逻辑可能需要先查询商品活动状态、商品名称、起止时间。这些数据如果不做缓存,每个请求都会去 MySQL 查一遍,在百万级请求下同样会打爆数据库。

比较合理的做法是:活动元数据用本地缓存兜一层,比如 Caffeine,把商品信息和活动时间缓存 30 秒;Redis 中库存 key 已经被DECR扣成0之后,也要防止大量请求反复穿透到 MySQL 去查询活动状态。

Cache<String, Object> localCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(30)) .build(); public Object queryProductDetail(Long productId) { String cacheKey = "product:detail:" + productId; Object value = localCache.getIfPresent(cacheKey); if (value != null) { return value; } // 互斥锁:同一时刻只允许一个请求加载数据库 String lockKey = "seckill:lock:" + cacheKey; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { // 拿不到锁的请求短暂等待后重读本地缓存 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return localCache.getIfPresent(cacheKey); } try { Object detail = seckillProductMapper.selectDetail(productId); localCache.put(cacheKey, detail); return detail; } finally { stringRedisTemplate.delete(lockKey); } }

这里有个容易被忽略的细节:Redis 中的库存 key 在扣减为 0 之后,不能删除,否则下一个请求会重新创建 key 并认为有库存。我一般会用seckill:stock:{productId}一直保留为0,配合 Lua 脚本里的exists判断,避免“活动还有货”的假象。

4.4 最终一致性:对账任务处理“扣了库存却没有订单”的中间状态

就算链路再完善,也一定会有消息丢失、消费者异常、Redis 和 MySQL 数据不一致的时候。秒杀系统不能保证强一致,但可以靠定时对账实现最终一致。

对账任务的核心逻辑是:扫描 Redis 秒杀成功集合中的每个用户,检查订单表里是否存在对应订单,如果不存在,就尝试补单。注意,createOrder里的decreaseStock条件是remain_stock > 0,所以如果数据库已经没有库存,补单会抛异常,这个用户就需要人工介入或自动退款。

@Scheduled(fixedDelay = 60_000L) public void reconcile() { Set<String> keys = stringRedisTemplate.keys("seckill:users:*"); if (keys == null) { return; } for (String key : keys) { String productId = key.substring(key.lastIndexOf(':') + 1); Set<String> userIds = stringRedisTemplate.opsForSet().members(key); if (userIds == null) { continue; } for (String userId : userIds) { SeckillOrder order = seckillOrderMapper.selectByUserAndProduct( Long.valueOf(userId), Long.valueOf(productId)); if (order == null) { // 幂等标志,防止多个定时任务实例并发补偿 Boolean first = stringRedisTemplate.opsForValue().setIfAbsent( "seckill:compensate:" + userId + ":" + productId, "1", 10, TimeUnit.MINUTES); if (Boolean.TRUE.equals(first)) { try { seckillOrderService.createOrder(Long.valueOf(userId), Long.valueOf(productId)); } catch (BusinessException e) { log.warn("补偿订单失败,userId={}, productId={}", userId, productId, e); } } } } } }

fixedDelay设置为 60 到 120 秒比较合适,太频繁会给数据库带来额外查询压力。补偿任务只要有幂等标志保护,即使这个类被多实例部署多次执行,也不会重复补单。Redis 成功集合的过期时间要留足,建议是活动结束后至少再保留 24 小时,给对账任务留出足够的工作时间。

5. 秒杀避坑清单:压测和上线后总结的 5 条经验

5.1 现象:剩余库存变成负数,订单却还在生成

压测时发现seckill_product表的remain_stock出现-1、-2,同时订单表里还有多条记录。原因通常是代码里先SELECT再UPDATE,或者更新语句没有带remain_stock > 0条件。解决方法是把扣减语句写成条件更新,返回受影响行数,为 0 就抛异常回滚事务;同时 Redis 扣减和数据库扣减各执行一次,两次都带原子性保障,不能省略任何一层。

5.2 现象:Redis 显示还有库存,但 MySQL 已经没有库存了

原因一般是预热时 MySQL 库存和 Redis 库存不一致,或者对账任务重复补单。解决思路是把 Redis 库存只当一个“活动期间的快速凭证”,最终以数据库为准。活动开始前的预热任务建议在秒杀开始前 5 分钟执行,执行时先更新 MySQL 状态,再覆盖 Redis 值。活动结束后,用订单表统计已售数量,再和数据库剩余库存做比对,差分数据人工核对。

5.3 现象:Redis 库存 key 被淘汰,大量请求直接打到 MySQL

Redis 设置了内存淘汰策略,如果把seckill:stock:*key 的过期时间设得太短,或者实例内存不足,key 会被淘汰。请求发现 key 不存在,Lua 脚本返回-2,此时用户看到的是“活动不存在”,还会反复重试,流量打穿到数据库。解决办法是库存 key 的过期时间至少要覆盖整个活动周期,并且 Redis 内存淘汰策略选noeviction或在业务上保留这些 key 不被淘汰。更稳妥的做法是活动结束后由定时任务统一删除,而不是依赖过期时间。

5.4 现象:MQ 消费者消费不过来,消息越积越多

秒杀接口返回“正在排队”,但订单迟迟不生成,RabbitMQ 队列堆积几万条消息。原因通常是消费者里做了同步耗时的操作,比如发送短信、推送通知、调用外部风控系统。解决方法是把消费者里的“必要业务”控制在最简:只做插订单和扣库存,短信、推送全部拿出来异步处理。同时注意@RabbitListener的concurrency参数,别随意设太大,否则消费者线程数一多,数据库连接池和 Redis 连接池都会被吃满,一般从4到8开始压测,逐步调大。

5.5 现象:同一用户在多台设备上操作,生成了多条订单

秒杀集合去重用的是 Redis 的SISMEMBER,但如果用户在多台设备上同时请求,请求进入不同后端实例,Lua 脚本执行顺序不同,理论上不会出现两个请求都成功的情况,因为 Lua 里的SADD和判断是原子的。真正容易出问题的是 MQ 消费者:如果 Redis 成功集合过期了,或者消费者先于接口阶段执行了createOrder,就可能重复。解决方法是数据库订单表必须加(user_id, product_id)唯一索引,遇到DuplicateKeyException按“订单已存在”处理,而不是让事务继续向下执行。

6. 验证方法:先把压测曲线跑出来,再拍板能不能上线

秒杀系统写完之后,我一般不会直接看代码,而是先用压测把系统的真实水位测出来。工具用 JMeter 或 wrk 都可以,重点看三个指标:接口 QPS、数据库连接池占用率、Redis 的平均耗时。

压测参数基础值说明
并发线程数500模拟瞬间并发,逐步加到 2000
压测时长5 分钟看系统长时间运行下的曲线,而不只盯着前两秒
商品库存100 件足够看出超卖和负库存问题
目标接口/api/seckill/execute只压这一个热点接口

如果压测过程中,接口 P99 延迟超过 500ms,或者数据库连接池使用率超过 70%,就需要回头检查限流阈值是不是放得太宽,消费者消费速度是否被外部调用拖慢。理想的曲线是:请求量翻了四倍,接口 QPS 基本不降,HTTP 错误率维持在 0.5% 以下,Redis 平均耗时保持在 2ms 以内。这说明流量被限流和异步链路有效削平了。

秒杀系统想再往上走,方向就比较清晰了:本地缓存从 Caffeine 换成一个多级缓存中间件,把商品元数据从 MySQL 完全剥离;MQ 从 RabbitMQ 换成吞吐量更高的 RocketMQ,并配合事务消息保证“扣减成功但订单失败”的消息一定能被补偿;Redis 集群加上主从和哨兵,即使单点故障也不会导致整个活动不可用。但这些都属于“锦上添花”,最基础的链路验证不过关,换再大的组件都会在别处翻车。

我自己的习惯是,每次压测完会把「并发数、QPS、Redis 耗时、DB 连接数、库存是否准确」这五个数据记在一个文件里,下次改参数时对比变化。秒杀这种系统,最重要的不是单个组件有多快,而是所有环节组合在一起,能不能在流量洪峰下保持稳定。这套方案的每一层都有替代品,但“限流、原子扣减、异步落库、定时对账”这四个动作缺一不可。希望我的这些踩坑记录和参数习惯,能让你少熬夜排查几回线上事故,也帮你把“简单实现”的秒杀系统真正做成一个能扛住压力的项目。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 3:08:49

Flutter for OpenHarmony实战:Visibility组件解析与最佳实践

去年我把一个原本跑在 Android 上的 Flutter 应用迁移到 OpenHarmony 开发板上&#xff0c;最让我意外的不是插件兼容清单有多长&#xff0c;而是一个被大多数人当成“if/else 语法糖”的组件——Visibility。当时前端同事看我代码时问了一句&#xff1a;“你这块为啥包个 Visi…

作者头像 李华
网站建设 2026/10/9 3:08:02

Flask部署到Kubernetes:从配置到自动化管理实战

前两篇文章我们把 Flask 应用的开发环境和容器化都捋顺了&#xff0c;镜像能跑、端口能通、依赖也没问题&#xff0c;但这只是万里长征走完了前半程。真正让项目从“能在本地跑”进化成“能稳定对外提供服务”&#xff0c;还差最关键的一步&#xff1a;把它扔进 Kubernetes 集群…

作者头像 李华
网站建设 2026/10/9 3:07:32

Linux 7 安装 Oracle 11g 全流程:环境适配、静默安装与踩坑记录

1. 在Linux 7上装Oracle 11g&#xff0c;先别急着下介质最近生产环境扩容&#xff0c;新申请的服务器清一色RHEL 7.9&#xff0c;业务侧却还绑死在Oracle 11g上。这种"新系统装老数据库"的组合&#xff0c;看起来简单&#xff0c;实际上一堆兼容性陷阱等着你踩。我在…

作者头像 李华
网站建设 2026/10/9 3:07:21

基于SVD与SGNS的汉语子词向量构建与评测实战

简介&#xff1a;本资源面向自然语言处理课程学习者与词向量入门研究者&#xff0c;围绕汉语子词向量构建与相似度评测展开&#xff0c;提供基于SVD分解与基于SGNS两种方法的完整Python实现。语料采用训练集与测试集并集&#xff0c;SVD方法取K5获取高维分布表示后降维得到vec_…

作者头像 李华
网站建设 2026/10/9 3:07:21

从A6练手项目看计算器界面程序的核心架构与边界处理

拿到“A6&#xff1a;编写计算器界面程序”这个题目的时候&#xff0c;正文是空的&#xff0c;关键词也是空的&#xff0c;很多人可能觉得无从下手。但恰恰相反&#xff0c;这可能是最容易让我这类老开发兴奋的一个题目——计算器界面程序&#xff0c;几乎是所有编程教程里雷打…

作者头像 李华
网站建设 2026/10/9 3:05:21

Java金融信贷系统设计:从表结构到还款计划对账的核心要点

简介&#xff1a;基于Java技术的金融信贷系统设计源码&#xff0c;面向金融科技开发者与信贷系统设计人员&#xff0c;聚焦客户管理、信贷产品管理、审批流程、合同与风险控制等核心业务环节&#xff0c;适合作为信贷管理系统的学习、教学与二次开发基础。压缩包共85个文件&…

作者头像 李华