前言
作为一名普通的 Java 程序开发者,日常开发中难免会遇到一些看似简单但实际排查起来非常棘手的问题。在最近的一个项目中,我遇到了一个 Redis 缓存穿透的问题,导致系统在高并发下性能急剧下降,甚至出现服务响应超时的情况。这个问题虽然不是特别复杂,但排查过程让我对缓存机制有了更深入的理解,也积累了一些实战经验。
本文将从问题现象、排查思路、代码分析和最终的解决方案几个方面来记录这次真实的 bug 排查经历,希望对同样使用 Spring Boot 和 Redis 的开发者有所帮助。
问题现象
我们的系统是一个基于 Spring Boot 的微服务架构,其中有一个订单查询接口,为了提升性能,我们引入了 Redis 缓存来存储用户的历史订单数据。正常情况下,这个接口运行良好,但某天早上突然收到监控系统的告警,提示该接口的响应时间异常增加,且错误率上升。
初步观察发现,当请求某些不存在的订单 ID 时,接口返回了错误信息,并且这些请求直接绕过了 Redis,直接访问了数据库。这种现象明显不符合预期,因为按照设计,即使缓存中没有数据,也应该通过空值缓存(如设置 TTL 为 1 分钟)来防止频繁访问数据库。
问题分析
首先,我想到的是缓存穿透的可能性。缓存穿透是指查询一个不存在的数据,由于缓存中没有,而数据库也没有,导致每次请求都去访问数据库,从而造成数据库压力过大。
进一步查看日志发现,很多请求的 key 是无效的订单 ID,比如 order:123456789
,而这些订单 ID 并不存在于数据库中。这说明确实存在缓存穿透的问题。
接下来,我检查了 Redis 的配置和代码逻辑,确认了以下几点:
- Redis 缓存未设置过期时间:在某些场景下,如果缓存中没有数据,我们没有设置任何 TTL,导致缓存永远不会失效,但也不会被填充。
- 未对非法请求进行过滤:对于一些恶意请求或非法参数,系统没有做拦截,导致大量无意义的请求直接打到数据库。
- 缓存策略不合理:在查询不到数据时,没有使用“空值缓存”来防止重复查询。
排查步骤
步骤一:验证缓存行为
我首先在本地启动了 Spring Boot 应用,并模拟了多个请求,测试 Redis 是否真的能正确缓存数据。使用 Jedis 客户端连接 Redis,执行 GET order:123456789
,发现返回结果是 nil
,即缓存中没有该 key。
public Order getOrderById(String orderId) {
String cacheKey = "order:" + orderId;
Order order = redisTemplate.opsForValue().get(cacheKey);
if (order != null) {
return order;
}
// 如果缓存中没有,则查询数据库
order = orderRepository.findById(orderId);
// 如果数据库中也没有,则不缓存
if (order != null) {
redisTemplate.opsForValue().set(cacheKey, order, 10, TimeUnit.MINUTES);
}
return order;
}
从这段代码可以看出,只有当数据库中有数据时,才会将数据写入 Redis,否则不会有任何缓存操作。这就导致了缓存穿透问题。
步骤二:分析 Redis 数据结构
我使用 Redis 的命令行工具查看了相关的 key,发现大量的 order:*
类型的 key 都是空的,也就是说,这些请求根本没有命中缓存。
步骤三:添加空值缓存逻辑
为了防止缓存穿透,我在代码中加入了空值缓存逻辑。即当数据库中没有数据时,也向 Redis 缓存一个空值,设置较短的 TTL,避免频繁访问数据库。
public Order getOrderById(String orderId) {
String cacheKey = "order:" + orderId;
Order order = redisTemplate.opsForValue().get(cacheKey);
if (order != null) {
return order;
}
// 查询数据库
order = orderRepository.findById(orderId);
// 如果数据库中也没有,则缓存一个空值
if (order == null) {
redisTemplate.opsForValue().set(cacheKey, "", 1, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set(cacheKey, order, 10, TimeUnit.MINUTES);
}
return order;
}
这样,即使请求的是不存在的订单 ID,Redis 中也会缓存一个空值,后续相同的请求就会直接从缓存中获取,避免了对数据库的频繁访问。
步骤四:增加请求过滤机制
为了进一步减少无效请求,我还增加了请求参数校验逻辑,确保传入的订单 ID 符合业务规则。
public ResponseEntity<Order> getOrder(@PathVariable String orderId) {
if (!isValidOrderId(orderId)) {
return ResponseEntity.badRequest().body(null);
}
Order order = orderService.getOrderById(orderId);
return ResponseEntity.ok(order);
}
private boolean isValidOrderId(String orderId) {
return orderId != null && orderId.matches("\d{8}");
}
这样可以有效过滤掉一些非法请求,减少不必要的数据库查询。
总结
这次缓存穿透问题的排查过程让我深刻认识到缓存设计的重要性。在实际开发中,不能只关注缓存的命中率,还要考虑如何处理缓存未命中的情况,尤其是针对非法请求的处理。
通过添加空值缓存和请求过滤机制,我们成功解决了缓存穿透问题,提升了系统的稳定性和性能。此外,我也意识到,在高并发环境下,合理的缓存策略和防御机制是保障系统健壮性的关键。
总的来说,这次 bug 排查不仅帮助我修复了一个实际问题,也让我对 Redis 和 Spring Boot 的缓存机制有了更深的理解。