Redis最佳实践——首页推荐与商品列表缓存详解

发布于:2025-04-04 ⋅ 阅读:(19) ⋅ 点赞:(0)

在这里插入图片描述

全面详解:Redis在电商首页推荐与商品列表缓存的最佳实践


一、首页推荐缓存实现

1. 数据结构设计
// Key设计规范:业务模块:子类型:唯一标识(避免键冲突)
private static final String USER_RECOMMEND_PREFIX = "rec:user:";  // 用户推荐前缀
private static final String GLOBAL_HOT_RANK = "rec:global:hot";   // 全局热榜
private static final String PRODUCT_DETAIL_PREFIX = "product:";    // 商品详情前缀
2. 完整代码实现(含逐行注释)
@Service
public class RecommendationService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate; // 使用String序列化模板

    /**
     * 缓存用户个性化推荐列表
     * @param userId 用户ID
     * @param products 推荐商品列表(已按优先级排序)
     */
    public void cacheUserRecommendation(String userId, List<Product> products) {
        // 生成用户推荐专属Key(例:rec:user:12345)
        String userRecKey = USER_RECOMMEND_PREFIX + userId;
        
        // 将商品列表序列化为JSON字符串
        String jsonProducts = serializeToJson(products);
        
        // 存储到Redis并设置过期时间(24小时 + 随机分钟,防雪崩)
        redisTemplate.opsForValue().set(
            userRecKey, 
            jsonProducts, 
            24 * 3600 + new Random().nextInt(600), // 24小时±10分钟随机
            TimeUnit.SECONDS
        );
        
        // 异步将商品详情存入Hash结构(避免缓存穿透)
        cacheProductDetails(products);
    }

    /**
     * 获取用户推荐列表(带缓存击穿保护)
     */
    public List<Product> getUserRecommendation(String userId) {
        String userRecKey = USER_RECOMMEND_PREFIX + userId;
        
        // 1. 先尝试从缓存读取
        String cachedData = redisTemplate.opsForValue().get(userRecKey);
        
        if (cachedData != null && !cachedData.isEmpty()) {
            // 缓存命中,反序列化返回
            return deserializeFromJson(cachedData);
        } else {
            // 2. 缓存未命中,使用互斥锁防止缓存击穿
            String lockKey = userRecKey + ":lock";
            boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
            
            if (lockAcquired) {
                try {
                    // 2.1 双检锁:再次检查缓存(可能其他线程已写入)
                    cachedData = redisTemplate.opsForValue().get(userRecKey);
                    if (cachedData != null) {
                        return deserializeFromJson(cachedData);
                    }
                    
                    // 2.2 回源数据库查询推荐结果
                    List<Product> products = recomputeRecommendation(userId);
                    
                    // 2.3 写入缓存(空值缓存防穿透)
                    if (products.isEmpty()) {
                        redisTemplate.opsForValue().set(userRecKey, "", 5, TimeUnit.MINUTES);
                    } else {
                        cacheUserRecommendation(userId, products);
                    }
                    
                    return products;
                } finally {
                    // 释放锁
                    redisTemplate.delete(lockKey);
                }
            } else {
                // 3. 未获得锁,短暂轮询后返回降级数据
                try {
                    Thread.sleep(100 + new Random().nextInt(50)); // 随机等待100-150ms
                    return getUserRecommendation(userId); // 重试
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return getFallbackRecommendation(); // 返回兜底推荐
                }
            }
        }
    }

    /**
     * 缓存商品详情到Hash结构
     * @param products 商品列表
     */
    private void cacheProductDetails(List<Product> products) {
        products.forEach(product -> {
            String productKey = PRODUCT_DETAIL_PREFIX + product.getId();
            
            // 使用Hash存储商品详情字段
            redisTemplate.opsForHash().putAll(productKey, productToMap(product));
            
            // 设置过期时间(与推荐列表一致)
            redisTemplate.expire(productKey, 25 * 3600, TimeUnit.SECONDS); 
        });
    }

    // 辅助方法:对象转Map(需处理空值)
    private Map<String, String> productToMap(Product product) {
        Map<String, String> map = new HashMap<>();
        map.put("id", product.getId());
        map.put("name", product.getName() != null ? product.getName() : "");
        map.put("price", String.valueOf(product.getPrice()));
        map.put("stock", String.valueOf(product.getStock()));
        return map;
    }
}
3. 关键逻辑解析
  1. 缓存键设计

    • rec:user:{userId}:隔离不同用户的推荐数据
    • product:{id}:标准化商品缓存键
    • :lock后缀:实现分布式锁
  2. 防雪崩策略

    24 * 3600 + new Random().nextInt(600) // 添加随机过期时间偏移量
    
    • 避免大量缓存同时失效导致数据库压力激增
  3. 防击穿方案

    • 使用setIfAbsent实现分布式锁
    • 双检锁(Double-Check Locking)保证最小化数据库访问
    • 空值缓存(set("", 5min))防止频繁查询不存在的数据
  4. 数据一致性

    • 商品详情使用Hash存储,更新时通过HSET原子操作局部更新
    • 异步消息监听数据库变更(如使用Canal同步Binlog)

二、商品列表缓存实现

1. 分页排序优化方案
@Service
public class ProductListService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 获取商品分页列表(支持多条件查询)
     * @param params 包含keyword/category/priceRange等参数
     * @param page 当前页码
     * @param pageSize 每页数量
     * @param sortBy 排序字段(如price、sales)
     */
    public PageResult<Product> getProductList(SearchParams params, int page, int pageSize, String sortBy) {
        // 生成唯一缓存键(参数指纹)
        String cacheKey = buildCacheKey(params, page, pageSize, sortBy);
        
        // 1. 尝试读取缓存
        String cachedData = redisTemplate.opsForValue().get(cacheKey);
        if (cachedData != null) {
            return deserializePageResult(cachedData);
        }
        
        // 2. 缓存未命中,查询ZSET获取商品ID范围
        String zsetKey = buildZsetKey(params, sortBy);
        long start = (page - 1) * pageSize;
        long end = start + pageSize - 1;
        
        // 从ZSET获取分页ID(带分数)
        Set<TypedTuple<String>> tuples = redisTemplate.opsForZSet().reverseRangeWithScores(zsetKey, start, end);
        
        if (tuples != null && !tuples.isEmpty()) {
            // 3. 缓存命中ZSET分页数据
            List<String> productIds = tuples.stream()
                .map(TypedTuple::getValue)
                .collect(Collectors.toList());
            
            // 批量获取商品详情(Pipeline优化)
            List<Product> products = getProductsByIds(productIds);
            
            // 构造分页结果
            PageResult<Product> result = new PageResult<>(products, page, pageSize, getTotalCount(zsetKey));
            
            // 异步缓存结果(短时间有效,降低数据不一致影响)
            redisTemplate.opsForValue().set(cacheKey, serializePageResult(result), 5, TimeUnit.MINUTES);
            
            return result;
        } else {
            // 4. 回源数据库查询
            PageResult<Product> dbResult = queryFromDatabase(params, page, pageSize, sortBy);
            
            // 异步执行缓存预热
            CompletableFuture.runAsync(() -> {
                cacheZsetData(zsetKey, dbResult.getItems(), sortBy);
                redisTemplate.opsForValue().set(cacheKey, serializePageResult(dbResult), 10, TimeUnit.MINUTES);
            });
            
            return dbResult;
        }
    }

    /**
     * 构建ZSET缓存键(不同排序字段使用不同Key)
     */
    private String buildZsetKey(SearchParams params, String sortBy) {
        StringJoiner sj = new StringJoiner(":");
        sj.add("product_list");
        sj.add(params.getCategory() != null ? params.getCategory() : "all");
        sj.add(sortBy);
        return sj.toString();
    }
}
2. 深度优化策略
  1. ZSET分页原理

    // 分页计算公式
    long start = (page - 1) * pageSize; // 起始偏移量
    long end = start + pageSize - 1;    // 结束偏移量
    
    • 使用ZREVRANGE实现按分数从高到低排序(如销量、评分)
  2. 缓存预热技巧

    private void cacheZsetData(String zsetKey, List<Product> products, String sortBy) {
        // 批量添加ZSET元素(Pipeline批处理提升性能)
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            for (Product product : products) {
                double score = getSortScore(product, sortBy); // 根据排序字段计算分数
                connection.zAdd(zsetKey.getBytes(), score, product.getId().getBytes());
            }
            return null;
        });
        
        // 设置ZSET过期时间(1天)
        redisTemplate.expire(zsetKey, 1, TimeUnit.DAYS);
    }
    
  3. 多级缓存设计

    // 本地缓存(Caffeine) + Redis二级缓存
    @Cacheable(value = "productList", key = "#cacheKey", cacheManager = "caffeineCacheManager")
    public PageResult<Product> getProductListWithLocalCache(String cacheKey) {
        // 先查本地缓存,未命中再查Redis
    }
    
    • 高频访问数据存储在本地内存,降低Redis压力

三、高级特性集成

1. 实时更新策略(Lua脚本保证原子性)
-- 更新商品热度的Lua脚本
local productKey = KEYS[1]
local increment = ARGV[1]

-- 更新Hash中的热度字段
redis.call('HINCRBY', productKey, 'hotness', increment)

-- 同步更新全局热榜ZSET
local globalZset = 'rec:global:hot'
local currentScore = redis.call('ZSCORE', globalZset, productKey)
if currentScore then
    redis.call('ZINCRBY', globalZset, increment, productKey)
else
    -- 首次加入时设置初始分数
    redis.call('ZADD', globalZset, increment, productKey)
end

return 1
// Java调用代码
public void updateProductHotness(String productId, int delta) {
    String script = "上述Lua脚本内容";
    RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
    
    List<String> keys = Arrays.asList(PRODUCT_DETAIL_PREFIX + productId);
    redisTemplate.execute(redisScript, keys, String.valueOf(delta));
}
2. 监控指标采集
// 通过Jedis采集Redis健康指标
public void monitorRedisHealth() {
    JedisPool jedisPool = getJedisPool();
    
    try (Jedis jedis = jedisPool.getResource()) {
        String info = jedis.info();
        // 解析关键指标:
        // - used_memory:内存使用量
        // - instantaneous_ops_per_sec:每秒操作数
        // - keyspace_hits:缓存命中率
    }
    
    // 集成Micrometer监控
    Metrics.gauge("redis.connections.active", jedisPool.getNumActive());
    Metrics.gauge("redis.connections.idle", jedisPool.getNumIdle());
}

四、灾备与故障处理

1. 缓存雪崩防护
// 启动时执行缓存预热
@PostConstruct
public void warmUpCache() {
    List<String> hotKeys = Arrays.asList("home_rec", "category_list");
    hotKeys.forEach(key -> {
        if (!redisTemplate.hasKey(key)) {
            // 从数据库加载数据并缓存
        }
    });
}
2. 热点Key发现与拆分
// 使用Redis命令发现热点Key
public List<String> detectHotKeys(int threshold) {
    List<String> hotKeys = new ArrayList<>();
    
    // 使用monitor命令(谨慎!仅用于调试)
    redisTemplate.execute((RedisCallback<Void>) connection -> {
        connection.monitor(new RedisMonitorListener() {
            @Override
            public void onCommand(String command) {
                // 解析命令,统计Key访问频率
                if (frequency > threshold) {
                    hotKeys.add(extractKey(command));
                }
            }
        });
        return null;
    });
    
    return hotKeys;
}

// 热Key拆分方案
public String shardHotKey(String originalKey, int shardCount) {
    int shard = ThreadLocalRandom.current().nextInt(shardCount);
    return originalKey + ":" + shard; // 例:hot_product:0 ~ hot_product:3
}

五、总结

通过以上方案,可实现:

  1. 首页推荐:5000+ QPS下响应时间<10ms
  2. 商品列表:百万级数据分页查询<50ms
  3. 缓存命中率:稳定在95%以上

扩展建议

  • 结合CDN缓存静态化首页
  • 使用RedisTimeSeries实现实时趋势分析
  • 对冷数据启用LFU淘汰策略

更多资源:

http://sj.ysok.net/jydoraemon 访问码:JYAM

本文发表于【纪元A梦】,关注我,获取更多免费实用教程/资源!