本文不空谈理论,全程基于Spring Boot + Redisson + Redis Cluster,手写一套支持百万级QPS的秒杀系统,涵盖Lua原子扣减、分布式限流、异步落库、主从切换与分片扩容,所有代码均可直接投入生产压测。
秒杀活动的核心特征:
传统关系型数据库(MySQL)单点TPS不过数千,且行锁竞争会导致连接池耗尽。因此,Redis必须作为核心缓存与计数器,承担所有实时库存操作,异步同步至DB。
┌─────────────┐ ┌─────────────────┐ ┌─────────────────────┐
│ 客户端 │ ──> │ Nginx + Keepalived│ ──> │ Gateway 限流集群 │
│ (APP/Web) │ │ (LVS+HAProxy) │ │ (令牌桶 + 漏桶) │
└─────────────┘ └─────────────────┘ └──────────┬──────────┘
│
▼
┌─────────────────────────┐
│ 微服务层(Pod/虚拟机) │
│ - 秒杀业务Service │
│ - 预检(风控/黑名单) │
│ - 本地缓存(Caffeine) │
└──────────┬──────────────┘
│
▼
┌─────────────────────────┐
│ Redis Cluster (6节点) │
│ - 主从 + 哨兵监控 │
│ - 16个分片槽位 (实际16384)│
│ - 每个主节点挂1从 │
└──────────┬──────────────┘
│
▼
┌─────────────────────────┐
│ 消息队列 (RocketMQ) │
│ 异步落库 + 订单生成 │
└──────────┬──────────────┘
│
▼
┌─────────────────────────┐
│ MySQL (分库分表) │
└─────────────────────────┘核心原则:
使用 Redis Cluster 模式(官方推荐),6台物理机(或容器):
配置参数优化(redis.conf):
port 6379
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
appendfsync everysec # 平衡性能与持久化
maxmemory 8gb
maxmemory-policy allkeys-lru # 内存淘汰策略
tcp-backlog 511
timeout 300
tcp-keepalive 60客户端连接池配置(以Jedis为例,但我们使用Redisson):
spring:
redis:
cluster:
nodes:
- 192.168.1.10:6379
- 192.168.1.11:6379
- 192.168.1.12:6379
- 192.168.1.13:6379
- 192.168.1.14:6379
- 192.168.1.15:6379
max-redirects: 3
lettuce:
pool:
max-active: 1000
max-idle: 100
min-idle: 20
max-wait: -1ms虽然Cluster自带故障转移,但为了更细粒度的监控,我们额外部署 Redis Sentinel 用于告警和客户端路由刷新。生产上也可直接依赖Cluster的cluster-node-timeout和replica-priority。
商品库存Key设计:
seckill:stock:{productId} → 剩余库存(String类型)seckill:user:{productId}:{userId} → 用户是否已抢购(Set或String,防重复)seckill:limit:{productId} → 限流令牌桶(使用Redis + Lua实现)活动预热:秒杀开始前10分钟,将库存从DB加载至Redis:
@PostConstruct
public void preloadStock() {
List<SeckillProduct> products = productMapper.selectActiveProducts();
for (SeckillProduct p : products) {
stringRedisTemplate.opsForValue()
.set("seckill:stock:" + p.getId(), String.valueOf(p.getStock()));
// 初始化限流令牌桶容量
stringRedisTemplate.opsForZSet()
.add("seckill:rate:" + p.getId(), "token", System.currentTimeMillis());
}
}为什么必须用Lua?因为扣库存+检查超卖+记录用户必须是一个原子操作,避免并发导致库存为负。Redis单线程执行Lua,天然串行化。
Lua脚本(seckill.lua):
-- KEYS[1] = stockKey
-- KEYS[2] = userKey (防重复)
-- ARGV[1] = userId
-- ARGV[2] = quantity (默认为1)
-- ARGV[3] = expireTime (用户记录过期时间)
local stock = redis.call('get', KEYS[1])
if not stock then
return -1 -- 商品不存在
end
local stockNum = tonumber(stock)
if stockNum <= 0 then
return 0 -- 库存不足
end
-- 检查用户是否已购买
local already = redis.call('sismember', KEYS[2], ARGV[1])
if already == 1 then
return -2 -- 重复抢购
end
-- 扣减库存
local newStock = stockNum - tonumber(ARGV[2])
if newStock < 0 then
return 0 -- 库存不足(极端情况)
end
redis.call('set', KEYS[1], newStock)
-- 记录用户
redis.call('sadd', KEYS[2], ARGV[1])
redis.call('expire', KEYS[2], ARGV[3]) -- 防止集合无限增长
return 1 -- 成功Java调用封装:
@Component
public class RedisSeckillExecutor {
private final StringRedisTemplate redisTemplate;
private DefaultRedisScript<Long> seckillScript;
@PostConstruct
public void init() {
seckillScript = new DefaultRedisScript<>();
seckillScript.setScriptSource(new ResourceScriptSource(
new ClassPathResource("lua/seckill.lua")));
seckillScript.setResultType(Long.class);
}
/**
* @return 1成功, 0库存不足, -1无商品, -2重复抢购
*/
public long executeSeckill(String productId, String userId, int quantity, int expireSec) {
List<String> keys = Arrays.asList(
"seckill:stock:" + productId,
"seckill:user:" + productId
);
Long result = redisTemplate.execute(
seckillScript,
keys,
userId,
String.valueOf(quantity),
String.valueOf(expireSec)
);
return result != null ? result : -1;
}
}防止瞬间流量打垮Redis,采用 令牌桶算法,在Redis中存储令牌数量,按固定速率补充。
限流Lua(rate_limit.lua):
-- KEYS[1] = 限流key
-- ARGV[1] = 当前时间戳(ms)
-- ARGV[2] = 令牌填充速率 (每秒几个)
-- ARGV[3] = 桶容量
-- ARGV[4] = 本次请求需要的令牌数(通常1)
local lastTime = redis.call('hget', KEYS[1], 'last_time')
local tokens = redis.call('hget', KEYS[1], 'tokens')
if not lastTime then
-- 初始化桶
redis.call('hset', KEYS[1], 'last_time', ARGV[1])
redis.call('hset', KEYS[1], 'tokens', ARGV[3])
tokens = tonumber(ARGV[3])
else
local now = tonumber(ARGV[1])
local elapsed = (now - tonumber(lastTime)) / 1000.0 -- 秒
local rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
tokens = math.min(capacity, tonumber(tokens) + elapsed * rate)
redis.call('hset', KEYS[1], 'last_time', now)
redis.call('hset', KEYS[1], 'tokens', tokens)
end
local need = tonumber(ARGV[4])
if tokens >= need then
redis.call('hset', KEYS[1], 'tokens', tokens - need)
return 1 -- 放行
else
return 0 -- 限流
end使用:每次秒杀请求先调用限流脚本,通过后再进入扣库存。
在Cluster模式下,若某个Master宕机,Slave会提升为Master,但客户端需要感知拓扑变化。Redisson内置了ClusterConnectionManager自动刷新拓扑,我们只需配置重试策略。
Redisson配置(RedissonConfig.java):
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useClusterServers()
.addNodeAddress("redis://192.168.1.10:6379", ...)
.setScanInterval(2000) // 每2秒扫描集群状态
.setConnectTimeout(3000)
.setTimeout(3000)
.setRetryAttempts(5) // 失败重试5次
.setRetryInterval(1500) // 重试间隔
.setMasterConnectionMinimumIdleSize(10)
.setMasterConnectionPoolSize(100);
return Redisson.create(config);
}
}业务层异常处理:捕获RedisConnectionException,降级返回“系统繁忙”,避免无限重试导致线程堆积。
秒杀成功仅代表Redis预扣成功,订单状态为“待支付”,需异步写入MySQL。
消息模型(RocketMQ):
@Data
public class SeckillOrderMessage {
private String orderId;
private String userId;
private String productId;
private Integer price;
private Long createTime;
}发送消息:
@Service
public class SeckillService {
@Autowired
private RocketMQTemplate mqTemplate;
public Result doSeckill(String pid, String uid) {
long code = redisSeckillExecutor.executeSeckill(pid, uid, 1, 3600);
if (code == 1) {
// 生成订单号
String orderId = UUID.randomUUID().toString();
SeckillOrderMessage msg = new SeckillOrderMessage();
msg.setOrderId(orderId);
msg.setUserId(uid);
msg.setProductId(pid);
// 发送事务消息或延迟消息,确保最终一致性
mqTemplate.send("seckill-order-topic",
MessageBuilder.withPayload(msg).build());
return Result.success("秒杀成功,请支付");
} else if (code == -2) {
return Result.fail("您已参与过");
} else {
return Result.fail("库存不足");
}
}
}消费者落库(幂等性):
@RocketMQMessageListener(topic = "seckill-order-topic", consumerGroup = "order-group")
public class OrderConsumer implements RocketMQListener<SeckillOrderMessage> {
@Override
public void onMessage(SeckillOrderMessage msg) {
// 使用分布式锁或唯一索引防止重复消费
String lockKey = "order:create:" + msg.getOrderId();
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
// 检查是否已存在
if (orderMapper.exists(msg.getOrderId())) return;
// 扣减DB库存(此时是最终扣减)
int rows = productMapper.decreaseStock(msg.getProductId(), 1);
if (rows == 1) {
orderMapper.insert(buildOrder(msg));
} else {
// 异常情况:DB库存不足,需回滚Redis库存(补偿)
redisSeckillExecutor.rollbackStock(msg.getProductId(), 1);
// 发送告警
}
}
} catch (Exception e) {
// 重试机制(RocketMQ自带重试)
throw new RuntimeException("落库失败,需要重试");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}使用JMeter模拟100万用户并发,每线程循环10次,共1000万请求:
参数 | 建议值 | 说明 |
|---|---|---|
cluster-node-timeout | 5000ms | 过高导致故障切换慢 |
repl-diskless-sync | yes | 加快主从同步 |
client-output-buffer-limit | 512mb 256mb 60 | 防止从节点复制缓冲区溢出 |
内核参数 net.core.somaxconn | 65535 | 提高TCP backlog |
JVM堆内存 | 4GB | Redisson大量对象需GC优化 |
redis_cluster_slots_fail :故障槽位数instantaneous_ops_per_sec :实时QPShit_rate :缓存命中率(应>95%)rejected_connections :连接拒绝数使用 redis-rdb-tool 每天全量备份RDB,同时开启AOF(everysec),确保最多丢失1秒数据。
本文实现了一套生产可用的Redis高并发秒杀方案,核心要点:
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。