×

关键词搜索结果的缓存策略设计与 Redis 实战应用

admin admin 发表于2025-12-09 16:48:01 浏览33 评论0

抢沙发发表评论

在互联网应用中,关键词搜索是高频核心场景,电商商品搜索、资讯内容检索、短视频推荐等场景均依赖该能力。但搜索过程往往涉及复杂的索引查询、数据聚合与排序,直接查询数据库或搜索引擎会导致响应延迟高、后端服务压力大。缓存作为性能优化的核心手段,能有效提升搜索响应速度、降低后端负载。本文聚焦关键词搜索结果的缓存策略设计,并结合 Redis 实现落地,从场景分析、策略设计到代码实战,完整阐述搜索缓存的核心逻辑。

一、搜索结果缓存的核心挑战

与普通数据缓存(如用户信息、商品详情)不同,关键词搜索结果缓存面临独特的挑战:

  1. 关键词多样性:用户输入的关键词千变万化,存在同义词、近义词、拼写变体(如 “手机” 与 “智能手机”、“苹果手机” 与 “iphone 手机”),直接缓存原始关键词易导致缓存碎片化、命中率低;

  2. 结果实时性:搜索结果可能随数据更新(如商品上下架、资讯发布)动态变化,需平衡缓存有效期与数据新鲜度;

  3. 分页与排序:用户常使用分页(如第 1 页 / 第 2 页)、排序(如按销量 / 时间)筛选结果,缓存需兼容多维度查询条件;

  4. 缓存雪崩 / 击穿风险:热门关键词缓存失效时,大量请求直击后端,易引发服务雪崩;低频关键词缓存未命中时,也可能导致单点击穿。

二、搜索结果缓存策略设计

针对上述挑战,需从缓存键设计、过期策略、缓存更新、防雪崩 / 击穿 四个维度设计核心策略:

1. 缓存键(Key)设计:标准化 + 维度化

缓存键需唯一标识 “关键词 + 查询条件” 组合,同时兼顾可读性与性能:

  • 标准化关键词:对原始关键词做归一化处理(如去空格、转小写、同义词归一),例如将 “苹果 手机 13” 处理为 “苹果手机 13”,减少因格式差异导致的缓存冗余;

  • 维度拼接:将分页、排序、筛选条件拼接至键中,格式示例:search:{标准化关键词}:{排序类型}:{页码}:{页大小}

  • 哈希优化:若关键词过长(如超过 64 字符),可对关键词做 MD5 哈希,避免键过长影响 Redis 性能,示例:search:md5(长关键词):sort:price:page:1:size:20

2. 过期策略:分级 TTL + 主动失效

  • 分级 TTL(Time-To-Live):根据关键词热度设置不同过期时间:

    • 热门关键词(如 “2025 新款手机”):TTL=5 分钟(高频访问,缩短过期时间保证新鲜度);

    • 普通关键词:TTL=30 分钟;

    • 低频关键词:TTL=2 小时(减少缓存空间占用);

  • 主动失效:当底层数据更新时(如商品价格修改、资讯发布),主动删除关联关键词的缓存,保证数据一致性。例如:商品 ID=1001 的手机价格更新,删除所有包含 “苹果手机 13” 的缓存键。

3. 缓存更新策略:懒加载 + 异步更新

  • 懒加载(Cache Aside):核心逻辑为 “查缓存→未命中查后端→写入缓存”,是搜索缓存的基础模式;

  • 异步更新:对热门关键词,在缓存即将过期前(如剩余 1 分钟),异步触发后端查询并更新缓存,避免缓存失效后大量请求直击后端。

4. 防雪崩 / 击穿 / 穿透策略

  • 防雪崩:缓存过期时间添加随机偏移量(如 ±10 秒),避免大量缓存同时失效;

  • 防击穿:对热门关键词使用 Redis 分布式锁或 “缓存预热”,确保同一时间只有一个请求去后端查询;

  • 防穿透:对不存在结果的关键词(如 “不存在的商品 12345”),缓存空结果(TTL=1 分钟),避免恶意请求直击后端。

三、Redis 实战实现

1. 技术选型

  • 缓存中间件:Redis(5.0+),使用 String 类型存储序列化后的搜索结果(JSON 格式);

  • 开发语言:Java(Spring Boot 2.7+),结合 Spring Data Redis 简化 Redis 操作;

  • 序列化:Jackson,实现搜索结果的 JSON 序列化 / 反序列化。

2. 核心代码实现

(1)配置 Redis 连接

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        
        // Jackson序列化配置
        Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
        jacksonSerializer.setObjectMapper(om);
        
        // String序列化器(键)
        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setValueSerializer(jacksonSerializer);
        template.setHashValueSerializer(jacksonSerializer);
        
        template.afterPropertiesSet();
        return template;
    }
    
    // 缓存过期时间配置(分级TTL)
    @Bean
    public CacheTTLConfig cacheTTLConfig() {
        CacheTTLConfig config = new CacheTTLConfig();
        config.setHotKeywordTTL(5 * 60); // 热门关键词:5分钟
        config.setNormalKeywordTTL(30 * 60); // 普通关键词:30分钟
        config.setLowFreqKeywordTTL(2 * 60 * 60); // 低频关键词:2小时
        config.setEmptyResultTTL(60); // 空结果:1分钟
        return config;
    }
}

// 缓存TTL配置类
@Data
@Component
@ConfigurationProperties(prefix = "search.cache.ttl")
public class CacheTTLConfig {
    private int hotKeywordTTL;
    private int normalKeywordTTL;
    private int lowFreqKeywordTTL;
    private int emptyResultTTL;
}

(2)关键词标准化工具类

@Component
public class KeywordNormalizer {
    // 同义词映射(示例)
    private static final Map<String, String> SYNONYM_MAP = new HashMap<>();
    static {
        SYNONYM_MAP.put("iphone13", "苹果手机13");
        SYNONYM_MAP.put("智能手机", "手机");
        // 可扩展更多同义词
    }
    
    /**
     * 关键词标准化:去空格、转小写、同义词替换
     */
    public String normalize(String keyword) {
        if (StringUtils.isBlank(keyword)) {
            return "";
        }
        // 去空格、转小写
        String normalized = keyword.trim().toLowerCase().replaceAll("\\s+", "");
        // 同义词替换
        return SYNONYM_MAP.getOrDefault(normalized, normalized);
    }
}

(3)搜索缓存核心服务

@Service
public class SearchCacheService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private KeywordNormalizer keywordNormalizer;
    @Autowired
    private CacheTTLConfig cacheTTLConfig;
    @Autowired
    private SearchBackendService searchBackendService; // 后端搜索服务(如ES/数据库)
    
    // 分布式锁前缀
    private static final String LOCK_PREFIX = "search:lock:";
    // 锁超时时间(秒)
    private static final long LOCK_EXPIRE = 10;
    
    /**
     * 核心搜索方法:缓存优先,未命中则查后端并更新缓存
     */
    public SearchResult search(String keyword, String sortType, int pageNum, int pageSize) {
        // 1. 关键词标准化
        String normalizedKeyword = keywordNormalizer.normalize(keyword);
        if (StringUtils.isBlank(normalizedKeyword)) {
            return new SearchResult(); // 空关键词返回空结果
        }
        
        // 2. 构建缓存键
        String cacheKey = buildCacheKey(normalizedKeyword, sortType, pageNum, pageSize);
        
        // 3. 查缓存
        SearchResult cacheResult = (SearchResult) redisTemplate.opsForValue().get(cacheKey);
        if (cacheResult != null) {
            return cacheResult;
        }
        
        // 4. 缓存未命中,加分布式锁防击穿
        String lockKey = LOCK_PREFIX + cacheKey;
        boolean locked = false;
        try {
            locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_EXPIRE, TimeUnit.SECONDS);
            if (locked) {
                // 5. 查后端服务
                SearchResult backendResult = searchBackendService.search(normalizedKeyword, sortType, pageNum, pageSize);
                
                // 6. 写入缓存(分级TTL)
                int ttl = getTTLByKeywordHeat(normalizedKeyword, backendResult);
                redisTemplate.opsForValue().set(cacheKey, backendResult, ttl, TimeUnit.SECONDS);
                
                return backendResult;
            } else {
                // 7. 其他线程已加锁,等待后重试查缓存
                Thread.sleep(100);
                return (SearchResult) redisTemplate.opsForValue().get(cacheKey);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return new SearchResult();
        } finally {
            // 8. 释放锁
            if (locked) {
                redisTemplate.delete(lockKey);
            }
        }
    }
    
    /**
     * 构建缓存键
     */
    private String buildCacheKey(String normalizedKeyword, String sortType, int pageNum, int pageSize) {
        // 关键词过长则MD5哈希
        String keyKeyword = normalizedKeyword.length() > 64 
                ? DigestUtils.md5DigestAsHex(normalizedKeyword.getBytes()) 
                : normalizedKeyword;
        return String.format("search:%s:sort:%s:page:%d:size:%d", 
                keyKeyword, sortType, pageNum, pageSize);
    }
    
    /**
     * 根据关键词热度和结果类型获取TTL
     */
    private int getTTLByKeywordHeat(String keyword, SearchResult result) {
        // 1. 空结果返回空结果TTL
        if (result == null || result.getItems().isEmpty()) {
            return cacheTTLConfig.getEmptyResultTTL();
        }
        
        // 2. 模拟判断热门关键词(实际可通过访问频次、业务规则判断)
        Set<String> hotKeywords = new HashSet<>(Arrays.asList("2025新款手机", "苹果手机13", "华为mate70"));
        if (hotKeywords.contains(keyword)) {
            return cacheTTLConfig.getHotKeywordTTL();
        }
        
        // 3. 模拟判断低频关键词(实际可通过访问频次判断)
        Set<String> lowFreqKeywords = new HashSet<>(Arrays.asList("2020旧款手机", "功能机"));
        if (lowFreqKeywords.contains(keyword)) {
            return cacheTTLConfig.getLowFreqKeywordTTL();
        }
        
        // 4. 普通关键词
        return cacheTTLConfig.getNormalKeywordTTL();
    }
    
    /**
     * 主动删除缓存(数据更新时调用)
     */
    public void deleteCache(String keyword, String sortType) {
        String normalizedKeyword = keywordNormalizer.normalize(keyword);
        if (StringUtils.isBlank(normalizedKeyword)) {
            return;
        }
        // 模糊匹配删除(Redis 5.0+支持SCAN遍历,避免KEYS命令阻塞)
        String pattern = "search:%s:sort:%s:*";
        ScanOptions options = ScanOptions.scanOptions().match(String.format(pattern, normalizedKeyword, sortType)).count(100).build();
        Cursor<String> cursor = redisTemplate.scan(options);
        while (cursor.hasNext()) {
            redisTemplate.delete(cursor.next());
        }
    }
}

// 搜索结果实体类
@Data
public class SearchResult implements Serializable {
    private static final long serialVersionUID = 1L;
    private List<SearchItem> items; // 搜索结果列表
    private long total; // 总条数
    private int pageNum; // 当前页
    private int pageSize; // 页大小
}

// 搜索项实体类
@Data
public class SearchItem implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id; // 数据ID
    private String title; // 标题
    private String content; // 内容
    private Double price; // 价格(电商场景)
    private Long createTime; // 创建时间
}

// 后端搜索服务(模拟实现)
@Service
public class SearchBackendService {
    /**
     * 模拟调用ES/数据库查询搜索结果
     */
    public SearchResult search(String keyword, String sortType, int pageNum, int pageSize) {
        // 模拟查询逻辑
        SearchResult result = new SearchResult();
        List<SearchItem> items = new ArrayList<>();
        if ("苹果手机13".equals(keyword)) {
            SearchItem item1 = new SearchItem();
            item1.setId(1L);
            item1.setTitle("苹果手机13 256G");
            item1.setContent("全新未拆封,官方正品");
            item1.setPrice(5999.0);
            item1.setCreateTime(System.currentTimeMillis());
            items.add(item1);
            
            SearchResult item2 = new SearchResult();
            // 省略更多数据...
        }
        result.setItems(items);
        result.setTotal(items.size());
        result.setPageNum(pageNum);
        result.setPageSize(pageSize);
        return result;
    }
}

(4)控制器层调用示例

@RestController
@RequestMapping("/api/search")
public class SearchController {
    @Autowired
    private SearchCacheService searchCacheService;
    
    /**
     * 搜索接口
     */
    @GetMapping
    public ResponseEntity<SearchResult> search(
            @RequestParam String keyword,
            @RequestParam(defaultValue = "time") String sortType,
            @RequestParam(defaultValue = "1") int pageNum,
            @RequestParam(defaultValue = "20") int pageSize) {
        SearchResult result = searchCacheService.search(keyword, sortType, pageNum, pageSize);
        return ResponseEntity.ok(result);
    }
    
    /**
     * 数据更新后主动删除缓存接口
     */
    @PostMapping("/cache/delete")
    public ResponseEntity<Void> deleteCache(
            @RequestParam String keyword,
            @RequestParam(defaultValue = "time") String sortType) {
        searchCacheService.deleteCache(keyword, sortType);
        return ResponseEntity.noContent().build();
    }
}

3. 缓存预热与监控

(1)缓存预热

针对热门关键词,可在服务启动时主动查询后端并写入缓存:

@Component
public class CacheWarmUp implements CommandLineRunner {
    @Autowired
    private SearchCacheService searchCacheService;
    @Autowired
    private CacheTTLConfig cacheTTLConfig;
    
    // 热门关键词列表
    private static final List<String> HOT_KEYWORDS = Arrays.asList("2025新款手机", "苹果手机13", "华为mate70");
    
    @Override
    public void run(String... args) throws Exception {
        // 异步预热,不阻塞服务启动
        CompletableFuture.runAsync(() -> {
            for (String keyword : HOT_KEYWORDS) {
                searchCacheService.search(keyword, "time", 1, 20);
            }
            System.out.println("热门关键词缓存预热完成");
        });
    }
}

(2)缓存监控

通过 Redis 的INFO stats命令或 Spring Boot Actuator 监控缓存命中率:

@RestController
@RequestMapping("/actuator/cache")
public class CacheMonitorController {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @GetMapping("/hit-rate")
    public ResponseEntity<Map<String, Object>> getCacheHitRate() {
        // 获取Redis统计信息
        Properties stats = (Properties) redisTemplate.execute((RedisCallback<Properties>) connection -> {
            return connection.info("stats");
        });
        
        long hits = Long.parseLong(stats.getProperty("keyspace_hits", "0"));
        long misses = Long.parseLong(stats.getProperty("keyspace_misses", "0"));
        double hitRate = hits == 0 ? 0 : (double) hits / (hits + misses);
        
        Map<String, Object> result = new HashMap<>();
        result.put("hits", hits);
        result.put("misses", misses);
        result.put("hitRate", String.format("%.2f%%", hitRate * 100));
        return ResponseEntity.ok(result);
    }
}

四、性能优化与注意事项

  1. Redis 集群部署:生产环境使用 Redis 集群(主从 + 哨兵 / 分片),避免单点故障;

  2. 缓存压缩:对大体积搜索结果(如超过 10KB),使用 GZIP 压缩后存储,减少网络传输和 Redis 内存占用;

  3. 避免缓存污染:对恶意 / 异常关键词(如超长乱码),直接过滤不缓存,避免占用 Redis 空间;

  4. TTL 随机偏移:在分级 TTL 基础上添加 ±10 秒随机值,避免大量缓存同时失效:

private int getTTLWithRandom(int baseTTL) {
    Random random = new Random();
    return baseTTL + random.nextInt(21) - 10; // ±10秒
}

    5.限流降级:结合 Sentinel/Hystrix 对搜索接口限流,缓存失效时降级返回基础结果,避免后端服务过载。

五、总结

关键词搜索结果的缓存设计需兼顾命中率、实时性、稳定性三大核心目标,通过标准化关键词、分级 TTL、分布式锁、主动失效等策略,可有效解决搜索缓存的特有问题。本文基于 Redis 的实战实现,覆盖了缓存设计、代码落地、性能优化全流程,可直接适配电商、资讯、短视频等主流搜索场景。在实际应用中,需结合业务特点调整缓存策略(如热度判断规则、TTL 时长),并通过监控持续优化缓存命中率,最终实现搜索接口的低延迟、高可用。


群贤毕至

访客