Redis与MySQL双写一致性(实战解决方案)

一、问题是什么?(先理解场景)

1. 经典问题:缓存与数据库不一致

复制代码
场景:用户修改用户名
期望:缓存和数据库都更新为新名字
实际可能发生:
1. 先更新缓存 → 再更新数据库(数据库更新失败)
2. 先更新数据库 → 再更新缓存(缓存更新失败)
3. 并发读写导致数据错乱

2. 不一致的几种情况

复制代码
// 情况1:先更新缓存,后更新数据库(不推荐)
public void updateUser(User user) {
    // 1. 更新缓存
    redis.set("user:" + user.id, user);
    
    // 2. 更新数据库(可能失败!)
    try {
        userDao.update(user);
    } catch (Exception e) {
        // 数据库失败,但缓存已经是新数据
        // 缓存是脏数据!
    }
}

// 情况2:先更新数据库,后更新缓存
public void updateUser(User user) {
    // 1. 更新数据库
    userDao.update(user);
    
    // 2. 更新缓存(可能失败!)
    try {
        redis.set("user:" + user.id, user);
    } catch (Exception e) {
        // 缓存失败,缓存还是旧数据
        // 下次读会读到旧数据
    }
}

二、解决方案总览

复制代码
一致性解决方案:
├── 1. 先更新数据库,再删除缓存(Cache Aside Pattern)← 最常用
├── 2. 先删除缓存,再更新数据库
├── 3. 延迟双删
├── 4. 订阅数据库Binlog
└── 5. 最终一致性策略

三、方案1:Cache Aside Pattern(旁路缓存)- 推荐

1. 核心思想:数据库为主,缓存为辅

复制代码
读流程:
1. 先读缓存
2. 缓存有 → 直接返回
3. 缓存无 → 读数据库 → 写入缓存 → 返回

写流程:
1. 更新数据库
2. 删除缓存

2. 完整代码实现

复制代码
@Service
public class UserService {
    
    @Autowired
    private UserDao userDao;
    
    @Autowired
    private RedisTemplate<String, User> redisTemplate;
    
    private static final String USER_KEY_PREFIX = "user:";
    
    /**
     * 读操作:先读缓存,缓存没有再读数据库
     */
    public User getUser(Long userId) {
        String key = USER_KEY_PREFIX + userId;
        
        // 1. 从缓存读取
        User user = redisTemplate.opsForValue().get(key);
        if (user != null) {
            return user;
        }
        
        // 2. 缓存没有,从数据库读取
        user = userDao.findById(userId);
        if (user == null) {
            return null;  // 数据库也没有
        }
        
        // 3. 写入缓存(设置过期时间)
        redisTemplate.opsForValue().set(
            key, 
            user, 
            5,  // 5分钟过期
            TimeUnit.MINUTES
        );
        
        return user;
    }
    
    /**
     * 写操作:先更新数据库,再删除缓存
     */
    @Transactional
    public void updateUser(User user) {
        // 1. 更新数据库
        userDao.update(user);
        
        // 2. 删除缓存
        String key = USER_KEY_PREFIX + user.getId();
        redisTemplate.delete(key);
        
        // 可选:延迟再次删除(防止极端情况)
        new Thread(() -> {
            try {
                Thread.sleep(1000);  // 延迟1秒
                redisTemplate.delete(key);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }).start();
    }
    
    /**
     * 删除操作:先删数据库,再删缓存
     */
    @Transactional
    public void deleteUser(Long userId) {
        // 1. 删除数据库
        userDao.delete(userId);
        
        // 2. 删除缓存
        String key = USER_KEY_PREFIX + userId;
        redisTemplate.delete(key);
    }
}

3. 为什么这个方案好?

复制代码
优点:
1. 简单易懂,实现成本低
2. 大多数场景下一致性足够好
3. 缓存不总是有数据,减少缓存污染

缺点:
1. 首次请求会慢(缓存未命中)
2. 并发时可能短暂不一致

四、方案2:先删缓存,再更新数据库

1. 流程

复制代码
写操作:
1. 删除缓存
2. 更新数据库

读操作:
1. 读缓存(无)→ 读数据库 → 写缓存

2. 代码实现

复制代码
@Service
public class UserServiceV2 {
    
    @Transactional
    public void updateUser(User user) {
        String key = USER_KEY_PREFIX + user.getId();
        
        // 1. 先删除缓存
        redisTemplate.delete(key);
        
        // 2. 再更新数据库
        userDao.update(user);
        
        // 注意:这里可能有"缓存击穿"问题
        // 在删除缓存后,更新数据库前,有其他线程读
    }
    
    public User getUser(Long userId) {
        String key = USER_KEY_PREFIX + userId;
        
        // 1. 读缓存
        User user = redisTemplate.opsForValue().get(key);
        if (user != null) {
            return user;
        }
        
        // 2. 缓存没有,读数据库
        user = userDao.findById(userId);
        if (user == null) {
            return null;
        }
        
        // 3. 写回缓存
        redisTemplate.opsForValue().set(key, user, 5, TimeUnit.MINUTES);
        return user;
    }
}

3. 问题:缓存击穿 + 短暂不一致

复制代码
时间线:
线程A:删除缓存
线程B:读缓存(无)→ 读数据库(旧数据)→ 写缓存(旧数据)
线程A:更新数据库(新数据)
结果:缓存是旧数据,数据库是新数据

五、方案3:延迟双删(解决方案2的问题)

1. 流程

复制代码
写操作:
1. 删除缓存
2. 更新数据库
3. 延迟一段时间(比如500ms)
4. 再次删除缓存

2. 代码实现

复制代码
@Service  
public class UserServiceV3 {
    
    @Transactional
    public void updateUser(User user) {
        String key = USER_KEY_PREFIX + user.getId();
        
        // 1. 第一次删除缓存
        redisTemplate.delete(key);
        
        // 2. 更新数据库
        userDao.update(user);
        
        // 3. 延迟后第二次删除缓存
        // 用线程池或消息队列实现延迟
        delayDeleteCache(key, 500);  // 延迟500ms
    }
    
    /**
     * 延迟删除缓存
     */
    private void delayDeleteCache(String key, long delayMillis) {
        CompletableFuture.runAsync(() -> {
            try {
                Thread.sleep(delayMillis);
                redisTemplate.delete(key);
                log.info("延迟删除缓存:{}", key);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
    
    /**
     * 更优雅的方式:使用消息队列
     */
    @Transactional
    public void updateUserWithMQ(User user) {
        String key = USER_KEY_PREFIX + user.getId();
        
        // 1. 删除缓存
        redisTemplate.delete(key);
        
        // 2. 更新数据库
        userDao.update(user);
        
        // 3. 发送延迟消息
        rabbitTemplate.convertAndSend(
            "cache.delete.delay.exchange",
            "cache.delete",
            key,
            message -> {
                message.getMessageProperties()
                    .setDelay(500);  // 延迟500ms
                return message;
            }
        );
    }
    
    /**
     * 消费延迟消息
     */
    @RabbitListener(queues = "cache.delete.delay.queue")
    public void handleDelayDelete(String key) {
        redisTemplate.delete(key);
        log.info("收到延迟消息,删除缓存:{}", key);
    }
}

六、方案4:订阅数据库Binlog(最可靠)

1. 架构图

复制代码
MySQL → Binlog → Canal/Otter → 消息队列 → 缓存更新服务 → Redis
    ↓
  主库更新           ↓
                   异步更新缓存

2. 使用Canal实现

复制代码
# canal部署配置
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=root
canal.instance.dbPassword=123456
canal.instance.filter.regex=.*\\..*  # 监控所有表

// Canal客户端消费Binlog
@Component
public class CanalClient {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public void processBinlog() {
        // 连接Canal
        CanalConnector connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress("127.0.0.1", 11111),
            "destination", "", ""
        );
        
        connector.connect();
        connector.subscribe(".*\\..*");
        
        while (running) {
            Message message = connector.getWithoutAck(100);
            List<CanalEntry.Entry> entries = message.getEntries();
            
            for (CanalEntry.Entry entry : entries) {
                if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
                    CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(
                        entry.getStoreValue()
                    );
                    
                    // 处理数据变更
                    processRowChange(rowChange, entry.getHeader().getTableName());
                }
            }
            
            connector.ack(message.getId());
        }
    }
    
    private void processRowChange(CanalEntry.RowChange rowChange, String tableName) {
        if (!"user".equals(tableName)) {
            return;
        }
        
        for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
            if (rowChange.getEventType() == CanalEntry.EventType.UPDATE ||
                rowChange.getEventType() == CanalEntry.EventType.DELETE) {
                
                // 获取主键ID
                String userId = getUserIdFromRow(rowData.getBeforeColumnsList());
                String key = USER_KEY_PREFIX + userId;
                
                // 删除缓存
                redisTemplate.delete(key);
                log.info("监听到数据变更,删除缓存:{}", key);
            }
        }
    }
}

3. 优点和缺点

复制代码
优点:
1. 完全解耦,缓存更新与业务代码无关
2. 保证最终一致性
3. 性能影响最小

缺点:
1. 架构复杂,维护成本高
2. 有一定延迟(毫秒到秒级)
3. 需要额外的中间件

七、方案5:读写串行化(强一致性)

1. 使用分布式锁保证强一致

复制代码
@Service
public class StrongConsistencyService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    /**
     * 强一致性读
     */
    public User getUserWithLock(Long userId) {
        String lockKey = "user:lock:" + userId;
        String cacheKey = USER_KEY_PREFIX + userId;
        
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 加读锁
            lock.lock();
            
            // 读缓存
            User user = (User) redisTemplate.opsForValue().get(cacheKey);
            if (user != null) {
                return user;
            }
            
            // 读数据库
            user = userDao.findById(userId);
            if (user == null) {
                return null;
            }
            
            // 写缓存
            redisTemplate.opsForValue().set(cacheKey, user, 5, TimeUnit.MINUTES);
            return user;
            
        } finally {
            lock.unlock();
        }
    }
    
    /**
     * 强一致性写
     */
    @Transactional
    public void updateUserWithLock(User user) {
        String lockKey = "user:lock:" + user.getId();
        String cacheKey = USER_KEY_PREFIX + user.getId();
        
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 加写锁
            lock.lock();
            
            // 更新数据库
            userDao.update(user);
            
            // 删除缓存(或更新缓存)
            redisTemplate.delete(cacheKey);
            
        } finally {
            lock.unlock();
        }
    }
}

八、不同场景的选型建议

1. 根据业务需求选择

复制代码
// 场景1:用户基本信息(允许短暂不一致)
// 方案:Cache Aside Pattern
@Service
public class UserBasicService {
    // 先更新数据库,再删缓存
    // 简单可靠,适合大多数场景
}

// 场景2:商品库存(需要强一致性)
// 方案:分布式锁 + Cache Aside
@Service  
public class InventoryService {
    // 读写都加锁,保证强一致
    // 性能较低,但数据准确
}

// 场景3:订单状态(最终一致性即可)
// 方案:订阅Binlog
@Service
public class OrderService {
    // 数据库更新,Binlog同步到缓存
    // 架构复杂,但性能最好
}

// 场景4:秒杀库存(高并发)
// 方案:Redis原子操作 + 异步同步
@Service
public class SeckillService {
    // Redis预减库存
    // 异步同步到数据库
    // 保证高性能
}

2. 实际项目中的组合方案

复制代码
@Component
public class CacheConsistencyManager {
    
    // 1. 常规操作:Cache Aside
    @Cacheable(value = "user", key = "#userId", unless = "#result == null")
    public User getUser(Long userId) {
        return userDao.findById(userId);
    }
    
    @CacheEvict(value = "user", key = "#user.id")
    @Transactional
    public void updateUser(User user) {
        userDao.update(user);
    }
    
    // 2. 重要数据:加分布式锁
    @Cacheable(value = "balance", key = "#accountId")
    public BigDecimal getBalanceWithLock(Long accountId) {
        String lockKey = "balance:lock:" + accountId;
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            lock.lock();
            return accountDao.getBalance(accountId);
        } finally {
            lock.unlock();
        }
    }
    
    // 3. 监听Binlog更新缓存
    @EventListener
    public void onDatabaseChange(DatabaseChangeEvent event) {
        if (event.getTable().equals("user")) {
            String key = "user:" + event.getPrimaryKey();
            redisTemplate.delete(key);
            
            // 可选:发延迟消息双删
            delayDelete(key, 1000);
        }
    }
}

九、处理特殊问题

1. 缓存穿透(查不存在的数据)

复制代码
// 解决方案:布隆过滤器或缓存空值
public User getUserSafe(Long userId) {
    String key = USER_KEY_PREFIX + userId;
    
    // 1. 先查缓存
    User user = (User) redisTemplate.opsForValue().get(key);
    if (user != null) {
        // 如果是空对象
        if (user.getId() == null) {
            return null;  // 缓存了空值
        }
        return user;
    }
    
    // 2. 布隆过滤器判断
    if (!bloomFilter.mightContain(userId)) {
        return null;  // 肯定不存在
    }
    
    // 3. 查数据库
    user = userDao.findById(userId);
    if (user == null) {
        // 缓存空值,防止缓存穿透
        redisTemplate.opsForValue().set(
            key, 
            new User(),  // 空对象
            2,  // 短时间过期
            TimeUnit.MINUTES
        );
        return null;
    }
    
    // 4. 写缓存
    redisTemplate.opsForValue().set(key, user, 5, TimeUnit.MINUTES);
    return user;
}

2. 缓存雪崩(大量缓存同时失效)

复制代码
// 解决方案:随机过期时间
public void setCacheWithRandomExpire(String key, Object value) {
    // 基础过期时间 + 随机时间
    int baseExpire = 5 * 60;  // 5分钟
    int randomExpire = new Random().nextInt(60);  // 0-59秒随机
    int totalExpire = baseExpire + randomExpire;
    
    redisTemplate.opsForValue().set(
        key, 
        value, 
        totalExpire, 
        TimeUnit.SECONDS
    );
}

3. 热点key重建(缓存击穿)

复制代码
// 解决方案:互斥锁重建
public User getHotUser(Long userId) {
    String key = USER_KEY_PREFIX + userId;
    
    // 1. 查缓存
    User user = (User) redisTemplate.opsForValue().get(key);
    if (user != null) {
        return user;
    }
    
    // 2. 获取分布式锁
    String lockKey = "rebuild:lock:" + key;
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        // 尝试获取锁,最多等待100ms
        if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
            try {
                // 双重检查
                user = (User) redisTemplate.opsForValue().get(key);
                if (user != null) {
                    return user;
                }
                
                // 查询数据库
                user = userDao.findById(userId);
                if (user != null) {
                    // 写入缓存
                    redisTemplate.opsForValue().set(
                        key, user, 5, TimeUnit.MINUTES
                    );
                }
                return user;
            } finally {
                lock.unlock();
            }
        } else {
            // 没获取到锁,等待一下再查缓存
            Thread.sleep(50);
            return (User) redisTemplate.opsForValue().get(key);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return null;
    }
}

十、Spring Cache整合方案

1. 使用Spring Cache注解

复制代码
@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5))  // 默认5分钟
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        
        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }
}

@Service
public class UserService {
    
    @Cacheable(value = "user", key = "#userId")
    public User getUser(Long userId) {
        return userDao.findById(userId);
    }
    
    @CachePut(value = "user", key = "#user.id")
    @Transactional
    public User updateUser(User user) {
        userDao.update(user);
        return user;
    }
    
    @CacheEvict(value = "user", key = "#userId")
    @Transactional
    public void deleteUser(Long userId) {
        userDao.delete(userId);
    }
}

2. 自定义Cache Aside模式

复制代码
@Component
public class CacheAsideTemplate {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 通用的Cache Aside读模板
     */
    public <T> T readThrough(String key, Class<T> type, 
                            Supplier<T> loader, long expire, TimeUnit unit) {
        // 1. 读缓存
        T value = (T) redisTemplate.opsForValue().get(key);
        if (value != null) {
            return value;
        }
        
        // 2. 读数据库
        value = loader.get();
        if (value == null) {
            // 缓存空值,防止缓存穿透
            redisTemplate.opsForValue().set(key, new NullValue(), 2, TimeUnit.MINUTES);
            return null;
        }
        
        // 3. 写缓存
        redisTemplate.opsForValue().set(key, value, expire, unit);
        return value;
    }
    
    /**
     * 通用的Cache Aside写模板
     */
    public void writeThrough(String key, Runnable writer) {
        // 1. 更新数据库
        writer.run();
        
        // 2. 删除缓存
        redisTemplate.delete(key);
    }
}

// 使用模板
@Service
public class UserService {
    
    @Autowired
    private CacheAsideTemplate cacheTemplate;
    
    public User getUser(Long userId) {
        String key = "user:" + userId;
        return cacheTemplate.readThrough(
            key, 
            User.class,
            () -> userDao.findById(userId),
            5, 
            TimeUnit.MINUTES
        );
    }
    
    public void updateUser(User user) {
        String key = "user:" + user.getId();
        cacheTemplate.writeThrough(key, () -> {
            userDao.update(user);
        });
    }
}

十一、监控和告警

1. 监控缓存命中率

复制代码
@Component
public class CacheMonitor {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private AtomicLong hitCount = new AtomicLong(0);
    private AtomicLong missCount = new AtomicLong(0);
    
    public <T> T getWithMonitor(String key, Supplier<T> loader) {
        T value = (T) redisTemplate.opsForValue().get(key);
        
        if (value != null) {
            hitCount.incrementAndGet();
            return value;
        } else {
            missCount.incrementAndGet();
            value = loader.get();
            if (value != null) {
                redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
            }
            return value;
        }
    }
    
    public double getHitRate() {
        long total = hitCount.get() + missCount.get();
        if (total == 0) return 0.0;
        return (double) hitCount.get() / total;
    }
    
    @Scheduled(fixedRate = 60000)  // 每分钟上报
    public void reportMetrics() {
        double hitRate = getHitRate();
        // 上报到监控系统
        metricsService.gauge("cache.hit.rate", hitRate);
        
        // 命中率过低告警
        if (hitRate < 0.8) {
            alertService.sendAlert("缓存命中率过低:" + hitRate);
        }
    }
}

十二、最佳实践总结

1. 选择策略的黄金法则

复制代码
读多写少 → Cache Aside Pattern
写多读少 → Write Through/Write Behind
强一致性 → 分布式锁 + Cache Aside
最终一致 → Binlog同步

2. 代码规范

复制代码
// ✅ 推荐写法
@Service
public class GoodPracticeService {
    
    // 1. 使用@Transactional保证数据库原子性
    @Transactional
    @CacheEvict(value = "user", key = "#user.id")
    public void updateUser(User user) {
        userDao.update(user);
    }
    
    // 2. 设置合理的过期时间
    @Cacheable(value = "user", key = "#userId", 
               unless = "#result == null",
               cacheResolver = "dynamicTtlCacheResolver")
    public User getUser(Long userId) {
        return userDao.findById(userId);
    }
    
    // 3. 重要操作记录日志
    @CacheEvict(value = "user", key = "#userId")
    @Transactional
    public void deleteUser(Long userId) {
        log.info("删除用户,userId={}", userId);
        userDao.delete(userId);
    }
    
    // 4. 考虑失败重试
    @Retryable(value = RedisConnectionFailureException.class, 
               maxAttempts = 3,
               backoff = @Backoff(delay = 1000))
    public void updateCache(String key, Object value) {
        redisTemplate.opsForValue().set(key, value);
    }
}

3. 一句话记住

Cache Aside Pattern是万能钥匙,Binlog同步是终极方案,分布式锁是急救包。

日常开发就用Cache Aside(先更新数据库,再删缓存),加上适当的监控和重试机制,能解决95%的场景。

相关推荐
一碗面42114 分钟前
Spring AI 多模态能力全景
java·spring·spring ai
李景琰16 分钟前
Spring AI + Milvus向量数据库:企业级RAG架构实战
人工智能·spring·milvus
Andya_net17 分钟前
Spring | 深度剖析Spring Bean的生命周期:从加载到销毁的完整流程
java·spring·rpc
Maiko Star21 分钟前
Spring AI 多轮对话记忆(ChatMemory)保姆级教程:从内存版到 Redis 持久化
java·redis·spring
Irene19913 小时前
Oracle:为什么 ORDER BY 能让 SUM() 变成累计?
oracle
早日退休!!!10 小时前
《数据结构选型指南》笔记
数据结构·数据库·oracle
直奔標竿10 小时前
Java开发者AI转型第二十七课!Spring AI 个人知识库实战(六)——全栈闭环收官,解锁前端流式渲染终极技巧
java·开发语言·前端·人工智能·后端·spring
阿坤带你走近大数据13 小时前
怎么查看当前oracle库下的表空间temp大小或者默认大小
数据库·oracle
空中海16 小时前
Spring Cloud 专家级面试题库
spring·spring cloud·面试
hljqfl17 小时前
Oracle存储结构
数据库·oracle