突破限制:引入Redis机制
当前系统V1.3 已经具备的功能:
- 异步处理:用户的点击会立刻得到响应。
- 流量控制:保护系统不会因过多线程而崩溃
- 原子化SQL:数据库操作精准无误
- 内存标记:售罄后能快速拒绝请求。
无法回避的“物理上限”:
- 用户体验的断崖式下跌:服务器可能在第一秒就收到了数万甚至数十万的 HTTP 请求。而应用内置的 Tomcat 服务器线程池(比如200个)会瞬间被打满。后续的所有请求,都会在操作系统的 TCP 连接队列中排队,最终大量超时。
- 99% 的用户刷新页面后,看到的是一个永远在“转圈圈”的加载动画,或是冰冷的 “503 Service Unavailable” 错误。
- 数据库是最终的性能瓶颈:数据库的磁盘I/O、网络带宽、以及自身的处理能力成为了整个系统性能的天花板。
- 数据库通常是多个业务的共享资源。秒杀业务对数据库的极限压榨,会导致整个网站的其他核心功能全部瘫痪。普通用户无法登录、无法浏览其他商品、无法对购物车里的其他商品下单。为了一个秒杀活动,导致整个电商平台的交易系统停摆,这是任何公司都无法接受的巨大损失。
- 应用服务器是“单点故障”:应用运行在一个实例上,如果这个应用因为任何原因,比如JVM崩溃或服务器宕机,挂掉,那么整个秒杀服务就会彻底中断。
- 整个秒杀服务彻底消失,恢复时间未知。
- 无法水平扩展:所有基于Java内存的并发控制,在多实例部署时都会失效。
- 暴露了架构的僵化和脆弱
Redis能解决什么问题?
- 解决了数据库雪崩和用户体验差的问题
- 把高频的库存读写、用户资格判断,从毫秒级的、基于磁盘的MYSQL,转移到了微秒级的、基于内存的Redis。
- 99%的读写流量由Redis抗住,每秒可以处理数万甚至数万次请求。
- 数据库只负责收尾工作,只有极少数成功抢到资格的用户,它们的订单信息才会异步的、平稳的写入数据库中。
- 解决了单点故障和无法水平扩展的问题
- 通过将所有需要共享的状态统一放在Redis中,本身的Spring Boot应用本身变成了“无状态”的。
环境准备与集成
在 Spring Boot 项目中成功引入并连接到 Redis
- 安装并运行Redis
- 在电脑上,使用docker在后台启动一个名为seckill-redis的Redis容器,并将其6379端口映射到电脑的6379端口。
1
docker run -d --name seckill-redis -p 6379:6379 redis
- 添加Maven依赖
- 在pom.xml文件中,添加新的依赖
1
2
3
4<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency> - 保存并让IDE重新加载依赖
- 配置application.properties
- 添加 Redis 的连接信息
1
2
3# ================== Redis Configuration ==================
spring.redis.host=localhost
spring.redis.port=6379
- 验证连接
- 可以创建一个简单的测试类来验证应用启动时能否成功连接到Redis
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class RedisConnectionTester implements CommandLineRunner {
private StringRedisTemplate redisTemplate;
public void run(String... args) throws Exception {
try {
String result = redisTemplate.getConnectionFactory().getConnection().ping();
System.out.println("=========================================");
System.out.println("Successfully connected to Redis. PING response: " + result);
System.out.println("=========================================");
} catch (Exception e) {
System.err.println("=========================================");
System.err.println("Failed to connect to Redis: " + e.getMessage());
System.err.println("=========================================");
}
}
} - 启动 Spring Boot 应用。在控制台看到了 Successfully connected to Redis 的信息,表示第一阶段就成功。
数据预热与缓存“读”操作
将查询库存的流量从 MySQL 转移到 Redis。
- 创建数据预热Service
- 在秒杀开始之前,把数据从MYSQL加载到Redis。用启动时加载器来模拟。
- 创建
RedisPreheatService.java1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class RedisPreheatService implements CommandLineRunner{
public static final String STOCK_KEY = "seckill:stock:";
public static final String PRODUCT_KEY = "seckill:product:";
public static final String USER_SET_KEY = "seckill:users:";
private ProductRepository productRepository; // 假设你已注入
private RedisTemplate<String, Object> redisTemplate;
// 应用启动后自动执行
public void run(String... args) throws Exception {
// 假设我们秒杀的商品ID是 1
long productId = 1L;
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
// 1. 清理旧数据(为了可重复测试)
redisTemplate.delete(STOCK_KEY + productId);
redisTemplate.delete(USER_SET_KEY + productId);
// 2. 加载库存到 Redis String
redisTemplate.opsForValue().set(STOCK_KEY + productId, product.getStock());
System.out.println("=========================================");
System.out.println("Product " + productId + " stock preheated to Redis: " + product.getStock());
System.out.println("=========================================");
}
}
}
- 改造SeckillService的checkStock方法
- 直接从Redis读数据
1
2
3
4
5
6
7
8
9// 在 SeckillService.java 中
private RedisTemplate<String, Object> redisTemplate;
public Integer checkStock(Long productId) {
String stockKey = RedisPreheatService.STOCK_KEY + productId;
Object stockObj = redisTemplate.opsForValue().get(stockKey);
return stockObj != null ? Integer.parseInt(stockObj.toString()) : -1;
}
核心逻辑迁移(Redis + Lua脚本)
将最关键的“判断资格&扣减库存”操作,从Java层的锁+数据库,迁移到Redis的原子化Lua脚本。
- 创建Lua脚本文件
- 在 src/main/resources/ 目录下,创建一个新文件夹 scripts。
- 在 scripts 文件夹里,创建一个新文件 seckill.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23-- seckill.lua
-- KEYS[1]: 库存的 key (e.g., seckill:stock:1)
-- KEYS[2]: 已购买用户集合的 key (e.g., seckill:users:1)
-- ARGV[1]: 当前请求的用户 ID
-- 1. 判断用户是否重复购买
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
return 2 -- 2 代表重复购买
end
-- 2. 获取库存
local stock = tonumber(redis.call('get', KEYS[1]))
if stock <= 0 then
return 1 -- 1 代表库存不足
end
-- 3. 扣减库存
redis.call('decr', KEYS[1])
-- 4. 记录购买用户
redis.call('sadd', KEYS[2], ARGV[1])
return 0 -- 0 代表秒杀成功
- 配置并加载Lua脚本
- 创建一个RedisConfig.java文件。用于管理与Redis相关的Bean。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class RedisConfig {
/**
* 【新增】配置并创建 RedisTemplate Bean
* @param connectionFactory Spring Boot 自动配置好的连接工厂
* @return RedisTemplate 实例
*/
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
// 创建 RedisTemplate 对象
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 设置连接工厂
template.setConnectionFactory(connectionFactory);
// 创建 JSON 序列化工具
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
// 设置 Key 的序列化方式为 String
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// 设置 Value 的序列化方式为 JSON
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
// 使配置生效
template.afterPropertiesSet();
return template;
}
public DefaultRedisScript<Long> seckillScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/seckill.lua")));
redisScript.setResultType(Long.class);
return redisScript;
}
}
- 重构SeckillService的核心秒杀逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40// 在 SeckillService.java 中
private DefaultRedisScript<Long> seckillScript;
// 我们需要一个内存队列来存放成功秒杀的订单信息
private final BlockingQueue<SeckillOrder> orderQueue = new LinkedBlockingQueue<>(1000);
// 改造异步执行的后台任务
private void executeSeckill(Long productId, Long userId) {
List<String> keys = Arrays.asList(
RedisPreheatService.STOCK_KEY + productId,
RedisPreheatService.USER_SET_KEY + productId
);
// 执行 Lua 脚本
Long result = redisTemplate.execute(seckillScript, keys, userId.toString());
if (result == 0) {
log.info("用户 {} 秒杀成功!", userId);
// 秒杀成功,生成订单信息并放入内存队列
// 此时订单尚未写入数据库
Product product = ... // 可以从缓存或数据库获取商品信息
SeckillOrder order = new SeckillOrder();
order.setProductId(productId);
order.setUserId(userId);
order.setOrderPrice(product.getPrice());
// 将订单放入队列
try {
orderQueue.put(order);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
} else if (result == 1) {
log.warn("用户 {} 秒杀失败:库存不足", userId);
} else if (result == 2) {
log.warn("用户 {} 秒杀失败:重复下单", userId);
} else {
log.error("用户 {} 秒杀异常", userId);
}
}
异步持久化
创建订单消费者Service
- 新建一个OrderConsumeService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class OrderConsumerService {
// ... 其他注入的属性 ...
// 应用启动后,开启一个后台线程
private void startConsumer() {
new Thread(() -> {
while (true) {
try {
SeckillOrder order = seckillService.getOrderQueue().take();
// 2. 循环体内部现在只调用这个新的、带事务的方法
createOrderInDb(order);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("订单消费者线程被中断", e);
break;
} catch (Exception e) {
// 捕获所有其他可能的异常,防止线程意外终止
log.error("处理订单时发生未知异常", e);
}
}
}).start();
}
/**
* 3. 【新增】一个公开的、带事务注解的方法,专门用于数据库操作
* @param order 从队列中取出的订单信息
*/
public void createOrderInDb(SeckillOrder order) {
log.info("正在创建订单并扣减MySQL库存: {}", order);
// 将所有数据库操作都放在这个方法里
orderRepository.save(order);
int result = productRepository.deductStock(order.getProductId());
if (result == 0) {
// 这是一个补偿逻辑,理论上在Redis阶段已经保证了库存充足
// 但为了数据最终一致性,如果MySQL库存扣减失败,应抛出异常让事务回滚
throw new RuntimeException("MySQL a's stock deduction failed for order: " + order);
}
log.info("数据库订单创建成功");
}
} - 在 SeckillService 中为 orderQueue 提供一个 getter 方法。
JMeter压测结果分析与改进
结果分析
- 结果:日志显示:处理订单时发生未知异常;数据库信息显示:订单正常创建,但是库存数没有减少;存在
TransactionRequiredException报错。 - 分析:
- 订单正常创建:说明 orderRepository.save(order) 这行代码执行成功了,并且它的结果被提交到了数据库。
- 库存数没有减少:说明 productRepository.deductStock(…) 这行代码没有成功,或者它的结果被回滚了。
- 存在
TransactionRequiredException报错:deductStock() 在执行时,没有找到一个正在运行的事务。 - 即,orderRepository.save() 在一个事务里成功了(或者在没有事务的情况下自动提交了),而紧接着的 deductStock() 却发现自己不在任何事务里。但是这两个方法在同一个被@Transactional注解的方法里。所以真正的原因应该是,方法上的@Transactional注解没有生效。因为这个方法是通过this关键字进行的方法自调用,无法触发AOP代理。当startConsumer方法在 while 循环里调用 createOrderInDb(order) 时,它实际上是在调用 this.createOrderInDb(order),绕过了AOP代理,所以无人发现@Transactional注解,事务没有被开启。
改进
注入服务自身,通过代理对象来调用方法。
修改
OrderConsumerService.java1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class OrderConsumerService {
// ... 其他注入的属性 ...
private SeckillService seckillService;
// 2. 注入自己(代理对象)
// 使用 @Lazy 是为了解决循环依赖的潜在问题
private OrderConsumerService self;
// 应用启动后,开启一个后台线程
private void startConsumer() {
new Thread(() -> {
while (true) {
try {
SeckillOrder order = seckillService.getOrderQueue().take();
// 3. 【关键改动】通过 self 代理对象来调用事务方法
self.createOrderInDb(order);
} catch (InterruptedException e) {
// ...
} catch (Exception e) {
// ...
}
}
}).start();
}
/**
* 这个方法保持不变,但现在它能被正确地代理了
*/
public void createOrderInDb(SeckillOrder order) {
// ... 之前的数据库操作逻辑完全不变 ...
log.info("正在创建订单并扣减MySQL库存: {}", order);
orderRepository.save(order);
int result = productRepository.deductStock(order.getProductId());
if (result == 0) {
throw new RuntimeException("MySQL stock deduction failed for order: " + order);
}
log.info("数据库订单创建成功");
}
}@Autowired private OrderConsumerService self; 注入的 self 变量,不是 this 对象,而是 Spring 创建的、包含了事务处理逻辑的代理对象。
当调用 self.createOrderInDb(order) 时,请求就从
startConsumer发到了AOP代理那里。AOP代理会正常地开启事务,然后再让真实对象去执行数据库操作。这样,@Transactional 就重新恢复了它的作用。
结果:数据库信息显示正常,库存正确减少,订单正确建立。
学学八股
Redis
- Redis是一个开源的、基于内存的、key-value结构的高性能数据库。
- 基于内存:是Redis高性能的根本原因。所有数据都存储在内存中,读写速度极快,远超基于磁盘的数据库。
- key-value:数据存储方式非常简单,像一个巨大的HashMap,通过一个唯一的Key来存取一个Value。
- 不仅仅是缓存:除了被用于缓存外,也被广泛运用于数据库、消息队列等。
- 核心原理(为什么快)
- 纯内存操作:所有的操作都在内存中完成,完全避免了磁盘I/O这个最耗时的环节。
- 单线程模型:Redis的核心网络模型和命令处理是由一个单线程来完成的。无线程切换开销、无锁竞争、I/O多路复用。
- Redis的原子性与Lua脚本
- Redis的单个命令是原子性的,但是多个命令组合在一起,就不是原子性的。
- 但Redis允许将一段Lua脚本作为一个整体发送给服务器执行,Redis会保证这个脚本在执行期间不会被任何其他命令打断,从而实现了多个命令的原子性组合。
- Redis的持久化机制
- RDB:在指定的时间间隔内,将内存中的数据快照完整的写入到磁盘上的一个二进制文件中。恢复速度快,文件紧凑。但如果Redis在两次快照之间崩溃,会损失一部分数据。
- AOF:将每一条接受到的写命令,以追加的方式写入到一个日志文件中,恢复时,重新执行一遍文件中的所有写命令。数据的安全性更高(最多只丢失1秒的数据),单文件体积大,恢复速度相对较慢。
- Redis的缓存经典问题
- 缓存穿透:查询一个数据库中根本不存在的数据,缓存中自然也没有,导致每次请求都直接打到数据库上,失去了缓存的意义。
- 缓存空对象:如果数据库查询结果为空,依然在Redis中缓存一个特殊的空值,并设置一个较短的过期时间。
- 布隆过滤器:在Redis前再加一道屏障,用布隆过滤器快速判断请求的数据是否存在。
- 缓存击穿:一个热点Key在某个瞬间突然失效,导致海量的并发请求同时涌向这个Key,并全部穿透到数据库,导致数据库瞬时压力过大。
- 互斥锁:当缓存失效时,第一个查询请求获取一个互斥锁,然后去加载数据并回设缓存。其他线程则等待锁释放后,直接从缓存中获取数据。
- 热点数据永不过期:对极热点的数据设置逻辑过期,由后台线程异步更新。
- 缓存雪崩:大量的key在同一时间集中失效,导致瞬时大量的请求都穿透到数据库。
- 随机化过期时间:在基础过期时间上,增加一个随机值,避免集中失效。
- 高可用架构:通过Redis集群、限流降级等操作,保证即使缓存出现问题,数据库也不会被完全冲垮。
- 缓存穿透:查询一个数据库中根本不存在的数据,缓存中自然也没有,导致每次请求都直接打到数据库上,失去了缓存的意义。