Redis缓存策略与MySQL数据一致性实战:从缓存穿透到分布式锁的完整方案

缓存策略不是加上Redis就完事

在MySQL前面加一层Redis缓存是提升查询性能的标准方案,但缓存引入的数据一致性问题往往比性能问题更棘手。缓存穿透、缓存雪崩、数据不一致是生产环境中的高频故障类型,每个都需要针对性的解决方案。

本文从缓存读写策略、一致性保障、异常防护三个维度,构建完整的Redis+MySQL缓存方案。

缓存读写策略选型

三种主流读写策略各有适用场景:

Cache Aside(旁路缓存):最常用的策略。读请求先查缓存,miss时查数据库并回写缓存;写请求先更新数据库再删除缓存。

// Cache Aside模式实现
@Service
public class ProductService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private ProductMapper productMapper;
    
    private static final long CACHE_TTL = 3600; // 1小时
    
    public Product getProduct(Long id) {
        String key = "product:" + id;
        
        // 1. 查缓存
        Product product = (Product) redisTemplate.opsForValue().get(key);
        if (product != null) {
            return product;
        }
        
        // 2. 查数据库
        product = productMapper.selectById(id);
        if (product == null) {
            // 防穿透:空值缓存,短TTL
            redisTemplate.opsForValue().set(key, "NULL", 300, TimeUnit.SECONDS);
            return null;
        }
        
        // 3. 回写缓存
        redisTemplate.opsForValue().set(key, product, CACHE_TTL, TimeUnit.SECONDS);
        return product;
    }
    
    public void updateProduct(Product product) {
        // 1. 更新数据库
        productMapper.updateById(product);
        // 2. 删除缓存
        redisTemplate.delete("product:" + product.getId());
    }
}

为什么是删除缓存而不是更新缓存?并发写场景下,更新缓存可能出现后更新的请求先写入缓存、先更新的请求后写入缓存,导致缓存数据是旧值。删除缓存让下次读请求从数据库加载最新数据,避免了并发写的顺序问题。

Read/Write Through:缓存层代理读写操作,应用层只与缓存交互。适合读多写少且数据结构简单的场景。

Write Behind(异步回写):写请求只写缓存,异步批量刷入数据库。适合写密集且允许短暂不一致的场景,如浏览量、点赞数。

缓存穿透防护:布隆过滤器 + 空值缓存

穿透指大量请求查询数据库中不存在的数据,缓存永远miss,请求全部打到数据库。两种防护手段配合使用:

空值缓存:缓存查不到的key,设置短TTL(5分钟),防止同一不存在的key反复击穿。上文代码中的NULL标记就是空值缓存。

布隆过滤器:在Redis中维护一个布隆过滤器,包含所有合法key。请求到达时先过布隆过滤器,不存在的key直接拒绝:

@Service
public class BloomFilterService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String BLOOM_KEY = "product:bloom";
    
    public boolean mightExist(Long id) {
        long[] offsets = getHashOffsets(id);
        for (long offset : offsets) {
            Boolean bit = redisTemplate.opsForValue().getBit(BLOOM_KEY, offset);
            if (bit == null || !bit) {
                return false;  // 一定不存在
            }
        }
        return true;  // 可能存在
    }
    
    public void add(Long id) {
        long[] offsets = getHashOffsets(id);
        for (long offset : offsets) {
            redisTemplate.opsForValue().setBit(BLOOM_KEY, offset, true);
        }
    }
    
    private long[] getHashOffsets(Long id) {
        // 使用5个hash函数,误判率约1%
        long[] offsets = new long[5];
        long hash1 = hash1(id);
        long hash2 = hash2(id);
        for (int i = 0; i < 5; i++) {
            offsets[i] = Math.abs(hash1 + i * hash2) % 100000000L;
        }
        return offsets;
    }
}

布隆过滤器的限制:存在误判率(可能将不存在的key判断为存在),但不会漏判(存在的key一定通过)。误判率通过hash函数数量和bitmap大小控制。1亿条数据、1%误判率约需要100MB内存。

缓存雪崩防护:随机TTL + 多级缓存

雪崩指大量key同时过期,请求瞬间涌入数据库。两个解决思路:

1. 给TTL加随机偏移:TTL = base_ttl + random(0, 300s),避免同一批key同时过期
2. 多级缓存:本地缓存(Caffeine)+ Redis分布式缓存,Redis大面积失效时本地缓存兜底

@Service
public class MultiLevelCacheService {
    
    private final Cache<String, Object> localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .recordStats()
        .build();
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public Object get(String key) {
        // L1: 本地缓存
        Object value = localCache.getIfPresent(key);
        if (value != null) {
            return value;
        }
        
        // L2: Redis
        value = redisTemplate.opsForValue().get(key);
        if (value != null) {
            localCache.put(key, value);
            return value;
        }
        
        // L3: 数据库
        value = loadFromDB(key);
        if (value != null) {
            long randomTtl = 3600 + ThreadLocalRandom.current().nextLong(300);
            redisTemplate.opsForValue().set(key, value, randomTtl, TimeUnit.SECONDS);
            localCache.put(key, value);
        }
        return value;
    }
}

本地缓存的TTL应短于Redis缓存,这样Redis失效时本地缓存还能兜底一小段时间,给数据库恢复争取缓冲期。

数据一致性保障:延迟双删 + 消息队列

先更新数据库再删除缓存的模式存在一个时间窗口:数据库更新完成后、缓存删除之前的这段时间,读请求可能从缓存中读到旧数据。对于一致性要求高的场景,需要延迟双删:

public void updateWithDoubleDelete(Product product) {
    // 1. 删除缓存(第一次)
    redisTemplate.delete("product:" + product.getId());
    
    // 2. 更新数据库
    productMapper.updateById(product);
    
    // 3. 延迟500ms后再删一次(第二次)
    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    scheduler.schedule(() -> {
        redisTemplate.delete("product:" + product.getId());
    }, 500, TimeUnit.MILLISECONDS);
}

500ms的延迟需要大于一次读请求的耗时,确保在数据库更新和第二次删除之间的读请求完成回写后,缓存被再次清理。

更高一致性要求下,使用消息队列确保缓存删除的可靠性:更新数据库后发送缓存删除消息到队列,消费者处理删除操作。消息队列保证至少被消费一次,如果消费失败会自动重试。

分布式锁:Redisson实现与注意事项

缓存重建场景中,只允许一个请求查数据库并回写缓存,其他请求等待。Redisson提供的分布式锁比setnx更可靠:

@Service
public class CacheRebuildService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    public Product getProductWithLock(Long id) {
        String key = "product:" + id;
        Product product = (Product) redisTemplate.opsForValue().get(key);
        if (product != null) {
            return product;
        }
        
        String lockKey = "lock:product:" + id;
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
                // Double check
                product = (Product) redisTemplate.opsForValue().get(key);
                if (product != null) {
                    return product;
                }
                
                product = productMapper.selectById(id);
                if (product != null) {
                    redisTemplate.opsForValue().set(key, product, 3600, TimeUnit.SECONDS);
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
        return product;
    }
}

Redisson的看门狗(Watchdog)机制会自动续期锁,避免业务未执行完锁就过期。但lock.isHeldByCurrentThread()检查不能省——如果锁已超时被其他线程持有,unlock会释放别人的锁。

缓存方案配置清单

1. 写操作采用先更新数据库再删除缓存,不使用更新缓存
2. 空值缓存TTL设5分钟,防止穿透
3. 高价值数据用布隆过滤器前置拦截,误判率控制在1%以内
4. TTL加随机偏移(0-300s),防止雪崩
5. 本地缓存TTL短于Redis缓存,形成多级兜底
6. 一致性要求高的场景使用延迟双删,延迟时间大于读请求耗时
7. 关键业务用消息队列保障缓存删除的可靠性
8. 分布式锁使用Redisson,检查isHeldByCurrentThread再释放
9. 锁的自动释放时间必须大于业务执行时间,避免误释放

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-ce-lyue-yu-mysql-shu-ju-yi-zhi-xing-shi-zhan/

(0)
小编小编
上一篇 15小时前
下一篇 15小时前

相关推荐