Redis

关注公众号 jb51net

关闭
首页 > 数据库 > Redis > Redis 缓存读写

浅谈Redis三种高效缓存读写策略的实现

作者:码熔burning

本文主要介绍了浅谈Redis三种高效缓存读写策略的实现,包括Cache-Aside、Read/Write-Through和Write-Back这三种策略,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧

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

准备工作:环境与模型

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

技术栈:

数据模型 (User.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 接口):

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. 再删除(失效)缓存

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

2. 代码示例 (UserServiceImpl.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. 优缺点与适用场景

4. 常见陷阱与注意事项

策略二: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):

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):

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-Back (写回)

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

1. 概念与工作流程

写操作流程:

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

读操作流程:

2. 代码示例(概念性实现)

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

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. 优缺点与适用场景

总结与策略选择

特性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. 监控不可或缺:无论使用哪种策略,都必须对缓存的命中率、内存使用率、响应时间等关键指标进行全面监控,这是优化和排查问题的基础。

到此这篇关于浅谈Redis三种高效缓存读写策略的实现的文章就介绍到这了,更多相关Redis 缓存读写内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家! 

您可能感兴趣的文章:
阅读全文