基于Redis实现LRU缓存

4 阅读4分钟

背景

在现代分布式系统中,缓存是提升系统性能的重要手段。传统的本地缓存(如Java的LinkedHashMap)虽然性能优秀,但在分布式环境下存在数据一致性和共享性问题。而Redis作为高性能的内存数据库,天然支持分布式部署,成为了实现分布式缓存的理想选择。

然而,内存资源是有限的,当数据量超过物理内存容量时,就必须通过某种机制来释放空间,这就是内存淘汰策略存在的根本原因。没有智能的内存淘汰机制,Redis可能会因为内存不足而频繁崩溃或性能急剧下降,进而影响整个系统的稳定性和响应速度。 目前Redis主要支持三种内存淘汰策略:LRU(最近最少使用)、LFU(最不经常使用)和TTL(过期时间优先)。

业务开发中用的最多的场景就是TTL。Redis可以设置键值的过期时间,对于长时间没用的键,在有效期时间之后会自动删除,但是对于ZSet,Hash等结构,只能对整个键设置过期时间,里面的元素是没法设置过期时间的,而随着时间的增长,ZSet和Hash里面的元素越来越多,很多业务场景下只需要访问最近的元素数据(比如记录用户最近行为记录,最近历史会话记录,用户最近发送的消息,商品推荐和最近商品浏览历史等),如果存储所有数据会造成内存空间的浪费,因此可以利用LRU算法将redis ZSet/hash里面的非最近元素给淘汰掉。

技术设计

核心设计思路

这里我们以Redis的**有序集合(Sorted Set)**作为底层数据结构来实现LRU算法:

  • 成员(Member):存储序列化后的缓存对象JSON字符串
  • 分数(Score):使用时间戳作为排序依据,分数越大表示越新

架构设计

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Application   │───▶│  RedisLruCache   │───▶│      Redis      │
│                 │    │                  │    │   Sorted Set    │
└─────────────────┘    └──────────────────┘    └─────────────────┘
                              │
                              ▼
                       ┌──────────────┐
                       │ ObjectMapper │
                       │ (JSON序列化)  │
                       └──────────────┘

主要特性

  1. 泛型支持:支持任意类型的对象缓存
  2. 自动淘汰:当缓存达到最大容量时,自动移除最旧的数据
  3. 事务保证:使用Redis事务确保添加和淘汰操作的原子性

关键代码

1. 核心类结构

@Slf4j
public class RedisLruCache<T> {
    private final RedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
    
    public RedisLruCache(RedisTemplate redisTemplate, ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }
}

2. 添加元素到LRU缓存

public void addToLruCache(String cacheKey, T item, int maxSize, Function<T, Double> scoreExtractor) {
    try {
        Double score = scoreExtractor.apply(item);
        String itemJson = objectMapper.writeValueAsString(item);

        // 先获取当前大小(在事务外)
        Long currentSize = redisTemplate.opsForZSet().zCard(cacheKey);
        
        // 使用Redis事务确保原子性
        redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                operations.multi();

                // 添加新元素
                operations.opsForZSet().add(cacheKey, itemJson, score);

                // 如果当前大小+1超过最大大小,移除最旧的元素
                if (currentSize != null && currentSize >= maxSize) {
                    long removeCount = currentSize + 1 - maxSize;
                    operations.opsForZSet().removeRange(cacheKey, 0, removeCount - 1);
                }

                // 设置过期时间(可选)
                operations.expire(cacheKey, Duration.ofDays(7));

                return operations.exec();
            }
        });
    } catch (Exception ex) {
        log.error("添加数据到redis LRU cache异常,cacheKey: {},异常信息: {}", cacheKey, ex.getMessage(), ex);
        throw new RuntimeException("添加数据到redis LRU cache失败", ex);
    }
}

其中scoreExtractor用于计算item元素的Score的方法。

3. 获取最近的N条记录

public List<T> getRecentItems(String cacheKey, int count, Class<T> clazz) {
    try {
        // 获取最新的count条记录(按分数倒序)
        Set<Object> objItems = redisTemplate.opsForZSet().reverseRange(cacheKey, 0, count - 1);
        if (CollectionUtils.isEmpty(objItems)) {
            return Collections.emptyList();
        }
        
        Set<String> items = objItems.stream()
            .map(ConvertUtils::obj2String)
            .collect(Collectors.toSet());

        return items.stream()
                .map(itemJson -> {
                    try {
                        return objectMapper.readValue(itemJson, clazz);
                    } catch (Exception e) {
                        log.error("Error deserializing item from cache: {}", itemJson, e);
                        return null;
                    }
                })
                .filter(Objects::nonNull)
                .collect(Collectors.toList());

    } catch (Exception e) {
        log.error("Error getting recent items from cache: {}", cacheKey, e);
        return Collections.emptyList();
    }
}

使用示例

1. 配置和初始化

@Configuration
public class CacheConfig {
    
    @Bean
    public RedisLruCache<UserActivity> userActivityCache(
            RedisTemplate<String, Object> redisTemplate,
            ObjectMapper objectMapper) {
        return new RedisLruCache<>(redisTemplate, objectMapper);
    }
}

2. 用户活动缓存示例

@Service
public class UserActivityService {
    
    @Autowired
    private RedisLruCache<UserActivity> userActivityCache;
    
    // 记录用户活动
    public void recordUserActivity(Long userId, UserActivity activity) {
        String cacheKey = "user:activity:" + userId;
        
        // 使用活动时间戳作为分数
        userActivityCache.addToLruCache(
            cacheKey, 
            activity, 
            100,  // 最多保存100条活动记录
            UserActivity::getTimestamp  // 分数提取器
        );
    }
    
    // 获取用户最近的活动
    public List<UserActivity> getRecentActivities(Long userId, int count) {
        String cacheKey = "user:activity:" + userId;
        return userActivityCache.getRecentItems(cacheKey, count, UserActivity.class);
    }
}

3. 商品浏览历史示例

@Service
public class ProductViewService {
    
    @Autowired
    private RedisLruCache<ProductView> productViewCache;
    
    // 记录商品浏览
    public void recordProductView(Long userId, Long productId) {
        String cacheKey = "user:product:view:" + userId;
        
        ProductView view = new ProductView(productId, System.currentTimeMillis());
        
        productViewCache.addToLruCache(
            cacheKey,
            view,
            50,  // 最多保存50个浏览记录
            ProductView::getViewTime
        );
    }
    
    // 获取用户浏览历史
    public List<ProductView> getUserViewHistory(Long userId) {
        String cacheKey = "user:product:view:" + userId;
        return productViewCache.getRecentItems(cacheKey, 20, ProductView.class);
    }
}

4. 实体类定义

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserActivity {
    private Long userId;
    private String activityType;
    private String description;
    private Long timestamp;
    
    // timestamp作为LRU排序的分数
    public Double getTimestamp() {
        return timestamp.doubleValue();
    }
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductView {
    private Long productId;
    private Long viewTime;
    
    public Double getViewTime() {
        return viewTime.doubleValue();
    }
}

优化建议

  1. 这里的LRU使用的是固定元素数量淘汰的方法,还可以使用最近多长时间淘汰的方法;
  2. 业务开发中还会面临LFU算法的使用场景。