Redis 的三种高效缓存读写策略!

目录

      • 准备工作:环境与模型
      • 策略一:Cache-Aside (旁路缓存)
        • [1. 概念与工作流程](#1. 概念与工作流程)
        • [2. 代码示例 (UserServiceImpl.java)](#2. 代码示例 (UserServiceImpl.java))
        • [3. 优缺点与适用场景](#3. 优缺点与适用场景)
        • [4. 常见陷阱与注意事项](#4. 常见陷阱与注意事项)
      • 策略二:Read/Write-Through (读穿/写穿)
        • [1. 概念与工作流程](#1. 概念与工作流程)
        • [2. 代码示例 (使用 Spring Cache 注解)](#2. 代码示例 (使用 Spring Cache 注解))
        • [3. 优缺点与适用场景](#3. 优缺点与适用场景)
      • 策略三:Write-Back (写回)
        • [1. 概念与工作流程](#1. 概念与工作流程)
        • [2. 代码示例(概念性实现)](#2. 代码示例(概念性实现))
        • [3. 优缺点与适用场景](#3. 优缺点与适用场景)
      • 总结与策略选择

在企业级应用中,缓存是应对高并发、提升系统性能的关键一环。而如何确保缓存与数据库之间数据的一致性、高效性与可用性,正是我们设计缓存策略的核心。下面,我将循序渐进地为您讲解 Cache-Aside、Read/Write-Through 和 Write-Back 这三种主流策略。


准备工作:环境与模型

为了让代码示例更贴近真实场景,我们先定义一个基础模型和环境。

技术栈:

  • Spring Boot 3.x
  • Spring Data Redis
  • MyBatis-Plus (或 JPA)
  • MySQL

数据模型 (User.java):

java 复制代码
import lombok.Data;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import java.io.Serializable;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    private String username;
    private String email;
}

数据访问层 (UserMapper.java) (MyBatis-Plus 接口):

java 复制代码
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends BaseMapper<User> {
}

策略一:Cache-Aside (旁路缓存)

这是最经典、最常用,也是最容易理解的缓存策略。它的核心思想是:应用程序代码直接负责维护缓存和数据库

1. 概念与工作流程

读操作流程:

  1. 应用程序先从缓存中读取数据。
  2. 如果缓存命中(Cache Hit),则直接返回数据。
  3. 如果缓存未命中(Cache Miss),则从数据库中读取数据。
  4. 将从数据库中读到的数据写入缓存
  5. 返回数据给调用方。

写操作流程 (关键点):

  1. 先更新数据库
  2. 再删除(失效)缓存

为什么是"删除缓存"而不是"更新缓存"?

  • 懒加载思想:只有在下次真实需要读取该数据时,才通过"读操作流程"将其加载到缓存。如果每次更新都去刷新缓存,而这个数据后续又很少被读取,就会造成不必要的缓存写操作。
  • 并发安全:考虑一个场景(写-写并发),如果线程A更新数据库后更新缓存,同时线程B也更新数据库并更新缓存。可能发生B先完成,A后完成,导致缓存中是A的旧数据,而数据库是B的新数据,造成不一致。而"删除缓存"能极大地降低这种不一致的概率。
2. 代码示例 (UserServiceImpl.java)
java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.concurrent.TimeUnit;

@Service
public class UserServiceImpl {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
  
    private final ObjectMapper objectMapper = new ObjectMapper();

    private static final String CACHE_KEY_PREFIX = "user:";

    /**
     * 读取用户 - 实现Cache-Aside读策略
     */
    public User getUserById(Long id) {
        String key = CACHE_KEY_PREFIX + id;

        // 1. 从缓存读取
        Object cachedUserObj = redisTemplate.opsForValue().get(key);
        if (cachedUserObj != null) {
            System.out.println("Cache Hit for user: " + id);
            return objectMapper.convertValue(cachedUserObj, User.class);
        }

        // 2. 缓存未命中,从数据库读取
        System.out.println("Cache Miss for user: " + id + ". Reading from DB.");
        User userFromDb = userMapper.selectById(id);

        // 3. 数据库存在数据,则写入缓存
        if (userFromDb != null) {
            redisTemplate.opsForValue().set(key, userFromDb, 60, TimeUnit.MINUTES); // 设置60分钟过期
        }
      
        return userFromDb;
    }

    /**
     * 更新用户 - 实现Cache-Aside写策略
     */
    public void updateUser(User user) {
        if (user == null || user.getId() == null) {
            throw new IllegalArgumentException("User or user ID cannot be null.");
        }
      
        // 1. 先更新数据库
        userMapper.updateById(user);
        System.out.println("Updated user in DB: " + user.getId());

        // 2. 再删除缓存
        String key = CACHE_KEY_PREFIX + user.getId();
        redisTemplate.delete(key);
        System.out.println("Invalidated cache for user: " + user.getId());
    }
}
3. 优缺点与适用场景
  • 优点:

    • 逻辑简单,易于实现和理解。
    • 强一致性(在大多数场景下),因为写操作直接操作数据库,读操作在缓存失效后会从数据库加载最新数据。
    • 灵活性高,缓存和数据库的交互完全由应用层控制。
  • 缺点:

    • 代码耦合,业务代码中混入了大量缓存操作逻辑,不够优雅。
    • 首次读取延迟,对于冷数据(首次被访问的数据),会经历一次"缓存未命中 -> 读数据库 -> 写缓存"的完整过程,延迟较高。
    • 可能存在一致性问题:在"更新DB"和"删除缓存"这两个非原子操作之间,如果发生异常或高并发读写,可能导致缓存中的数据是旧的,而数据库是新的。这被称为"缓存-数据库双写不一致",但通过"先更新DB,再删除缓存"已将风险降到最低。
  • 适用场景:

    • 绝大多数的读多写少的业务场景。
    • 对数据一致性有较高要求,但能容忍极短暂不一致的场景。
    • 这是大部分互联网应用的首选和默认策略
4. 常见陷阱与注意事项
  • 缓存穿透 :查询一个数据库和缓存中都不存在 的数据。这会导致每次请求都直接打到数据库,缓存形同虚设。
    • 解决方案 :对查询结果为null的数据也进行缓存(缓存空对象),但设置一个较短的过期时间。
  • 缓存击穿 :某个热点Key 在缓存中过期失效的瞬间,大量并发请求同时涌入,直接打到数据库上。
    • 解决方案:使用互斥锁(如分布式锁),只允许一个线程去查询数据库并回写缓存,其他线程等待。
  • 缓存雪崩 :大量的Key在同一时间 集体过期,导致所有请求瞬间全部打到数据库。
    • 解决方案:在Key的过期时间上增加一个随机值,避免集体失效。

策略二:Read/Write-Through (读穿/写穿)

这种策略将缓存作为主要的数据存储。应用程序只与缓存交互,由缓存服务自身来负责与底层数据库的同步。

1. 概念与工作流程

Read-Through (读穿):

  1. 应用程序向缓存请求数据。
  2. 如果缓存命中,直接返回。
  3. 如果缓存未命中,由缓存服务自己负责从数据库加载数据。
  4. 缓存服务将数据加载到缓存中,并返回给应用程序。
    • 这个过程对应用程序是透明的。

Write-Through (写穿):

  1. 应用程序向缓存写入数据。
  2. 缓存服务首先更新缓存
  3. 然后缓存服务同步地将数据写入数据库
  4. 操作完成后,缓存服务向应用程序返回成功。
    • 这个过程保证了缓存和数据库的强一致性

关键区别:Cache-Aside是应用层维护,Read/Write-Through是缓存服务(或一个封装层)维护。

2. 代码示例 (使用 Spring Cache 注解)

Spring Cache 的 @Cacheable, @CachePut, @CacheEvict 注解是 Read/Write-Through 和 Cache-Aside 写策略思想的完美体现。它将缓存逻辑从业务代码中解耦,使得代码更简洁。

配置 (CacheConfig.java):

java 复制代码
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(60)) // 默认缓存60分钟
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .disableCachingNullValues(); // 不缓存null值

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .build();
    }
}

重构后的 Service (UserServiceWithCacheAnnotations.java):

java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImplWithAnnotations {

    @Autowired
    private UserMapper userMapper;

    /**
     * @Cacheable 实现了 Read-Through 思想
     * - `value` 或 `cacheNames`: 指定缓存的名称(命名空间)
     * - `key`: 缓存的key,这里使用SpEL表达式取方法参数id
     * - `unless`: 结果为null时不缓存,防止缓存穿透
     */
    @Cacheable(cacheNames = "user", key = "#id", unless = "#result == null")
    public User getUserById(Long id) {
        System.out.println("Reading from DB for user: " + id);
        return userMapper.selectById(id);
    }

    /**
     * @CacheEvict 实现了 Cache-Aside 的写策略(删除缓存)
     * - `key`: 指定要删除的缓存key
     */
    @CacheEvict(cacheNames = "user", key = "#user.id")
    public void updateUser(User user) {
        System.out.println("Updating user in DB: " + user.getId());
        userMapper.updateById(user);
        System.out.println("Cache evicted for user: " + user.getId());
    }
  
    // 如果需要Write-Through(每次都更新缓存),可以使用@CachePut
    // @CachePut(cacheNames = "user", key = "#user.id")
    // public User updateUserAndCache(User user) {
    //     userMapper.updateById(user);
    //     return user; // @CachePut 要求方法必须有返回值,返回值会被放入缓存
    // }
}
3. 优缺点与适用场景
  • 优点:

    • 代码简洁,业务逻辑与缓存逻辑分离,可维护性高。
    • 强一致性(对于Write-Through),因为写操作是原子的(从应用角度看)。
    • 对应用透明,开发者无需关心底层细节。
  • 缺点:

    • 灵活性较低,缓存的读写行为由框架或缓存服务固定,不易定制。
    • 写操作延迟增加(对于Write-Through),因为需要同步写入数据库。
  • 适用场景:

    • 对代码整洁度要求高的项目。
    • 需要强一致性且能接受写操作延迟的场景。
    • 在Java生态中,使用Spring Cache进行常规业务对象缓存是此模式的最佳实践。

策略三:Write-Back (写回)

这是一种以性能为先的策略,追求极致的写性能,但牺牲了一定的数据一致性和可靠性。

1. 概念与工作流程

写操作流程:

  1. 应用程序将数据只写入缓存,并立即返回。
  2. 缓存服务将此数据标记为"脏数据"(Dirty)。
  3. 一个独立的异步任务 会批量地、或延迟地将这些"脏数据"刷回(flush)到数据库中。

读操作流程:

  • 与 Read-Through 类似。如果缓存命中(无论是干净数据还是脏数据),直接返回。如果未命中,从数据库加载。
2. 代码示例(概念性实现)

原生 Redis 和 Spring Boot 不直接提供 Write-Back 机制,需要自己实现或借助第三方框架。下面是一个简化的概念性实现,用 BlockingQueueExecutorService 模拟异步写回。

java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

@Service
public class UserWriteBackService {
  
    @Autowired
    private UserMapper userMapper;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
  
    private static final String CACHE_KEY_PREFIX = "user:";
  
    // 使用阻塞队列作为缓冲区
    private final BlockingQueue<User> dirtyQueue = new LinkedBlockingQueue<>(10000);
  
    // 使用单线程的Executor来顺序处理写回任务
    private final ExecutorService writerExecutor = Executors.newSingleThreadExecutor();

    // 初始化时启动异步写回任务
    @PostConstruct
    public void init() {
        writerExecutor.submit(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    // 每隔5秒或缓冲区达到100条时,批量写回数据库
                    List<User> userBatch = new ArrayList<>();
                    // 从队列中取出最多100个元素,最多等待5秒
                    Queues.drain(dirtyQueue, userBatch, 100, 5, TimeUnit.SECONDS);

                    if (!userBatch.isEmpty()) {
                        System.out.println("Writing back batch of size: " + userBatch.size());
                        // 在实际应用中,这里应该是批量更新操作
                        for (User user : userBatch) {
                            userMapper.updateById(user);
                        }
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt(); // 恢复中断状态
                    System.err.println("Write-back thread interrupted.");
                } catch (Exception e) {
                    // 必须处理异常,否则线程可能终止
                    System.err.println("Error during write-back: " + e.getMessage());
                }
            }
        });
    }

    // 更新操作:只写缓存,并放入脏数据队列
    public void updateUser(User user) {
        // 1. 更新缓存
        redisTemplate.opsForValue().set(CACHE_KEY_PREFIX + user.getId(), user);

        // 2. 放入异步写回队列
        // 注意:为避免重复放入,可以先从队列中移除旧的相同ID的项
        dirtyQueue.removeIf(u -> u.getId().equals(user.getId()));
        boolean offered = dirtyQueue.offer(user);    
        if(!offered){
             System.err.println("Write-back queue is full. Data for user " + user.getId() + " might be lost!");
             // 可以在此添加降级策略,例如同步写入
        }
    }
  
    public User getUserById(Long id) {
        // 读操作逻辑与Cache-Aside或Read-Through类似
        Object user = redisTemplate.opsForValue().get(CACHE_KEY_PREFIX + id);
        if (user != null) {
            return (User) user;
        }
        return userMapper.selectById(id); // 此处简化,未回写缓存
    }
  
    // 关闭服务时,确保缓冲区数据被处理
    @PreDestroy
    public void shutdown() {
        writerExecutor.shutdown();
        try {
            if (!writerExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
                writerExecutor.shutdownNow();
            }
        } catch (InterruptedException e) {
            writerExecutor.shutdownNow();
        }
        // 处理队列中剩余的数据...
    }
}
3. 优缺点与适用场景
  • 优点:

    • 极高的写性能,因为应用"写"操作的耗时仅仅是写入内存(Redis)的时间,响应极快。
    • 降低数据库压力,通过批量异步写入,大大减少了对数据库的写请求次数。
  • 缺点:

    • 数据丢失风险:如果 Redis 服务宕机,且缓冲区中的"脏数据"还未写回数据库,这部分数据将永久丢失。
    • 数据一致性差:是"最终一致性",在数据写回数据库之前,缓存和数据库的数据是不同的。
    • 实现复杂度高:需要自己实现异步队列、批量写入、失败重试、服务关闭时的数据处理等机制,非常复杂。
  • 适用场景:

    • 写密集型应用,例如:高频次的用户行为记录、点赞数、文章浏览量计数等。
    • 对数据丢失有一定容忍度的业务。比如,丢失几秒内的点赞数或浏览量通常是可以接受的。
    • 绝对不能用于金融、交易等对数据可靠性和一致性要求极高的场景。

总结与策略选择

特性 Cache-Aside (旁路缓存) Read/Write-Through (读写穿) Write-Back (写回)
实现复杂度 中等 (业务代码侵入) (框架支持,如Spring Cache) (需自行实现异步逻辑)
数据一致性 准实时一致性 强一致性 (Write-Through) 最终一致性
数据可靠性 最高 低 (有数据丢失风险)
读性能 高 (命中时) 高 (命中时) 高 (命中时)
写性能 中等 (DB + Cache) 慢 (同步写DB+Cache) 极高 (只写内存)
适用场景 通用,读多写少,互联网首选 代码简洁性要求高,通用业务 写密集型,对性能要求极致,能容忍数据丢失

进阶建议与最佳实践:

  1. 从 Cache-Aside 开始:对于绝大多数项目,Cache-Aside 是最稳妥、最灵活的起点。
  2. 拥抱 Spring Cache :在 Spring 生态中,优先使用 @Cacheable@CacheEvict 等注解来实践 Read-Through 和 Cache-Aside 的思想,能极大简化代码,提高开发效率。
  3. 谨慎使用 Write-Back:只有在写性能成为明确瓶颈,且业务能容忍其数据丢失风险时,才考虑自行实现或引入支持 Write-Back 的缓存组件。
  4. 一致性是关键挑战:深入理解"先更新DB,再删除缓存"策略,并了解其在极端并发下的风险。对于要求更强一致性的场景,可以研究基于消息队列(如Canal+RocketMQ/Kafka)的**订阅数据库变更日志(Binlog)**来异步更新缓存的方案,这是目前业界解决该问题的主流高级方案。
  5. 监控不可或缺 :无论使用哪种策略,都必须对缓存的命中率、内存使用率、响应时间等关键指标进行全面监控,这是优化和排查问题的基础。
相关推荐
Liquad Li2 小时前
Salesforce 生态中的缓存、消息队列和流处理
缓存·架构·salesforce
哲Zheᗜe༘2 小时前
了解学习Nginx反向代理与缓存功能
学习·nginx·缓存
梅孔立2 小时前
基于 Service Worker 的图书馆资源缓存技术研究
缓存
小哈里2 小时前
【后端开发】golang部分中间件介绍(任务调度/服务治理/数据库/缓存/服务通信/流量治理)
数据库·缓存·中间件·golang·后端开发
塔中妖4 小时前
Spring Boot 启动时将数据库数据预加载到 Redis 缓存
数据库·spring boot·缓存
学编程的小鬼6 小时前
MyBatis中如何实现数据封装
数据库·mybatis
余衫马11 小时前
Windows 10 环境下 Redis 编译与运行指南
redis·后端
观望过往15 小时前
Spring Boot 集成 Redis 全方位详解
spring boot·redis
RoboWizard16 小时前
移动固态硬盘无法被电脑识别怎么办?
大数据·人工智能·缓存·电脑·金士顿
Lucky_Turtle18 小时前
【Mybatis】单独测试
mybatis