1. Redis 缓存失效与分布式锁实战经验分享
作为一名后端开发者,Redis 是我们日常工作中不可或缺的利器。但在实际应用中,我遇到过不少令人头疼的问题。今天我想分享几个典型的 Redis 使用场景中的坑点,以及我是如何解决这些问题的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存雪崩问题与解决方案
2.1 问题现象与原因分析
记得有一次凌晨三点,监控系统突然报警,服务响应时间从正常的50ms飙升到5000ms,数据库CPU直接打满。紧急排查后发现,原来是我们给所有缓存都设置了相同的2小时过期时间,导致凌晨2点整大批缓存同时失效,所有请求直接打到数据库。
java复制// 问题代码示例
public User getUserById(Long id) {
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
redis.setex(cacheKey, 7200, JSON.toJSONString(user));
return user;
}
这段代码的问题在于:
- 所有缓存设置了相同的过期时间(7200秒)
- 没有考虑缓存失效后的降级方案
- 系统启动时没有进行缓存预热
2.2 解决方案与优化实践
2.2.1 随机过期时间+逻辑过期
java复制public User getUserById(Long id) {
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
User user = JSON.parseObject(cached, User.class);
if (isStale(user)) {
threadPool.execute(() -> refreshCache(id));
}
return user;
}
User user = userMapper.selectById(id);
int expireSeconds = 7200 + new Random().nextInt(3600);
redis.setex(cacheKey, expireSeconds, JSON.toJSONString(user));
return user;
}
2.2.2 缓存预热机制
java复制@Configuration
public class CachePreheatConfig implements CommandLineRunner {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserMapper userMapper;
@Override
public void run(String... args) {
List<User> hotUsers = userMapper.selectHotUsers();
for (User user : hotUsers) {
String cacheKey = "user:" + user.getId();
redisTemplate.opsForValue().set(cacheKey, user, 24, TimeUnit.HOURS);
}
}
}
2.2.3 服务降级策略
java复制public User getUserByIdWithLock(Long id) {
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
String lockKey = "lock:user:" + id;
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(acquired)) {
try {
cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
redis.setex(cacheKey, 7200, JSON.toJSONString(user));
return user;
} finally {
redis.delete(lockKey);
}
} else {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getUserByIdWithLock(id);
}
}
2.3 实践经验总结
- 缓存过期时间一定要随机化,避免同时失效
- 热点数据要在系统启动时进行预热
- 必须考虑缓存失效后的降级方案
- 对于特别重要的数据,可以考虑使用多级缓存策略
3. 缓存穿透问题与防御措施
3.1 问题场景描述
有一次运营反馈说有用户不断访问一些不存在的ID(如-1、0、99999999等),起初我没太在意,结果数据库很快就被打崩了。查看日志发现,这些不存在的用户每次都会查询数据库,返回null,但null值没有缓存,导致每次请求都会直接打到数据库。
java复制// 问题代码示例
public User getUserById(Long id) {
if (id <= 0) {
return null;
}
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
return "null".equals(cached) ? null : JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
return user;
}
3.2 解决方案实现
3.2.1 缓存空值策略
java复制public User getUserById(Long id) {
if (id <= 0) {
return null;
}
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
if ("null".equals(cached)) {
return null;
}
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
if (user == null) {
redis.setex(cacheKey, 300, "null");
} else {
redis.setex(cacheKey, 3600, JSON.toJSONString(user));
}
return user;
}
3.2.2 布隆过滤器方案
java复制@Configuration
public class BloomFilterConfig {
@Bean
public RBloomFilter<String> bloomFilter() {
RBloomFilter<String> bloomFilter = RedisBloomFilter.create(
StrUitls.of("user:bloom"),
1_000_000,
0.01
);
return bloomFilter;
}
}
@Service
public class UserService {
@Autowired
private RBloomFilter<String> bloomFilter;
public User getUserById(Long id) {
String cacheKey = "user:" + id;
if (!bloomFilter.mightContain(cacheKey)) {
return null;
}
String cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
if (user != null) {
redis.setex(cacheKey, 3600, JSON.toJSONString(user));
}
return user;
}
}
3.3 防御措施建议
- 缓存空值时过期时间要设置得短一些(如5分钟)
- 布隆过滤器有误判率,不能作为唯一方案
- 要做好参数校验,过滤非法输入
- 可以考虑在API网关层做限流防护
4. 缓存击穿问题与分布式锁应用
4.1 问题背景分析
双11大促期间,某个大V用户的信息特别热门,缓存刚好在高峰期过期。结果瞬间数千请求同时打到数据库,导致数据库再次崩溃。这就是典型的缓存击穿问题——热点key过期瞬间,大量并发请求同时访问数据库。
4.2 分布式锁解决方案
4.2.1 基本实现方案
java复制public User getUserByIdWithLock(Long id) {
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
String lockKey = "lock:user:" + id;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(acquired)) {
try {
cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
if (user != null) {
redis.set(cacheKey, JSON.toJSONString(user));
}
return user;
} finally {
redis.delete(lockKey);
}
} else {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getUserByIdWithLock(id);
}
}
4.2.2 Redisson实现方案
java复制@Service
public class UserService {
@Autowired
private RedissonClient redissonClient;
public User getUserById(Long id) {
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
RLock lock = redissonClient.getLock("lock:user:" + id);
lock.lock(30, TimeUnit.SECONDS);
try {
cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
if (user != null) {
redis.setex(cacheKey, 3600, JSON.toJSONString(user));
}
return user;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
4.3 实现要点总结
- 分布式锁要设置合理的过期时间
- 必须进行双重检查,避免重复查询数据库
- 推荐使用Redisson,它自动处理了锁续期问题
- 业务执行时间要预估好,设置合理的锁过期时间
- 锁的粒度要尽可能小,避免影响系统吞吐量
5. 分布式锁常见问题与正确实现
5.1 常见错误实现方式
java复制// 问题代码示例
public Boolean lock(String key) {
return redisTemplate.opsForValue().setIfAbsent(key, "1");
}
public void unlock(String key) {
redisTemplate.delete(key);
}
这种实现方式存在多个问题:
- 没有设置锁的过期时间,可能导致死锁
- 加锁和设置过期时间不是原子操作
- 没有校验锁的持有者,可能释放别人的锁
- 没有锁续期机制,业务执行时间长可能导致锁提前失效
5.2 正确实现方案
5.2.1 Redisson实现方案
java复制@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("password")
.setConnectionPoolSize(64)
.setConnectionMinimumIdleSize(10);
return Redisson.create(config);
}
}
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
public void createOrder(Order order) {
String lockKey = "lock:order:" + order.getUserId();
RLock lock = redissonClient.getLock(lockKey);
try {
boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (!acquired) {
throw new RuntimeException("获取锁失败,请稍后重试");
}
orderMapper.insert(order);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁被中断");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
5.2.2 Lua脚本实现方案
java复制@Service
public class LuaLockService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LOCK_SCRIPT =
"if redis.call('set', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then " +
" return 1 " +
"else " +
" return 0 " +
"end";
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
public Boolean lock(String key, String value, int seconds) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(LOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(key), value, String.valueOf(seconds));
return result != null && result == 1;
}
public Boolean unlock(String key, String value) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(key), value);
return result != null && result == 1;
}
}
5.3 分布式锁最佳实践
- 使用成熟的框架如Redisson,避免重复造轮子
- 必须设置合理的锁过期时间
- 确保锁的释放操作一定会执行(放在finally块中)
- 考虑锁的可重入性需求
- 对于特别关键的业务,可以考虑使用多级锁策略
6. Redis数据结构选择与优化
6.1 常见错误使用案例
java复制// 问题代码:购物车用String存储
public void addToCart(Long userId, Long productId, int quantity) {
String key = "cart:" + userId;
String cartJson = redis.get(key);
Map<Long, Integer> cart = cartJson != null
? JSON.parseObject(cartJson, new TypeReference<Map<Long, Integer>>(){})
: new HashMap<>();
cart.put(productId, cart.getOrDefault(productId, 0) + quantity);
redis.set(key, JSON.toJSONString(cart));
}
这种实现方式的问题:
- 每次操作都需要序列化和反序列化整个购物车
- 不是原子操作,可能丢失更新
- 当购物车商品数量多时,性能会明显下降
6.2 正确使用Hash数据结构
java复制@Service
public class CartService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void addToCart(Long userId, Long productId, int quantity) {
String key = "cart:" + userId;
Long newQuantity = redisTemplate.opsForHash().increment(key, productId.toString(), quantity);
if (newQuantity <= 0) {
redisTemplate.opsForHash().delete(key, productId.toString());
}
}
public Map<Object, Object> getCart(Long userId) {
String key = "cart:" + userId;
return redisTemplate.opsForHash().entries(key);
}
}
6.3 Redis数据结构选型指南
| 使用场景 | 推荐数据结构 | 优势说明 |
|---|---|---|
| 单个对象缓存 | String | 简单直接,适合小对象 |
| 对象属性频繁更新 | Hash | 可单独更新字段,减少网络传输 |
| 购物车、计数器 | Hash | 支持原子操作,避免并发问题 |
| 排行榜 | Sorted Set | 自动排序,支持范围查询 |
| 好友关系、标签 | Set | 天然去重,支持集合运算 |
| 消息队列 | List | 支持阻塞操作,实现简单队列 |
| 分布式锁 | String + Lua | 保证原子性,实现简单 |
6.4 数据结构使用建议
- 根据业务场景选择最合适的数据结构
- 优先使用Redis提供的原子操作
- 对于复杂操作,考虑使用Lua脚本保证原子性
- 注意大Key问题,避免单个Key存储过多数据
- 合理设置过期时间,避免内存浪费
7. Redis使用中的其他注意事项
7.1 连接池配置优化
java复制@Configuration
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
LettuceConnectionFactory factory = new LettuceConnectionFactory();
factory.setHostName("redis-host");
factory.setPort(6379);
factory.setPassword("password");
// 连接池配置
LettucePoolingClientConfiguration config = LettucePoolingClientConfiguration.builder()
.poolConfig(new GenericObjectPoolConfig<>())
.build();
factory.setClientConfiguration(config);
return factory;
}
}
配置建议:
- 根据业务量设置合适的连接池大小
- 监控连接池使用情况,及时调整参数
- 考虑使用连接池预热
7.2 监控与告警设置
- 监控Redis的内存使用情况
- 设置慢查询告警
- 监控客户端连接数
- 关注持久化相关指标
7.3 性能优化建议
- 使用Pipeline减少网络往返
- 避免大Key问题
- 合理使用批量操作
- 考虑使用本地缓存作为二级缓存
- 对于热点数据,可以考虑分片存储
8. 实践经验总结与建议
在实际项目中使用Redis时,我总结了以下几点经验:
- 缓存设计要有失效策略和降级方案
- 分布式锁实现要考虑全面,避免常见陷阱
- 根据业务特点选择合适的数据结构
- 监控和告警必不可少
- 性能优化要结合实际场景
Redis虽然强大,但使用不当也会带来各种问题。希望这些经验能帮助开发者避免一些常见的坑。在实际开发中,建议多参考官方文档和最佳实践,根据自身业务特点进行合理设计和优化。
