1. 项目背景与核心需求
在本地生活服务类应用中,店铺信息展示是最基础也最频繁的业务场景之一。以"黑马点评"这类应用为例,当用户搜索附近餐厅时,系统需要快速返回店铺列表,包含名称、评分、人均消费等关键信息。这类数据的特点是读多写少(每天可能有数万次查询,但店铺信息可能几天才更新一次),天然适合使用缓存来减轻数据库压力。
但缓存机制如果设计不当,会导致两类典型问题:
- 数据不一致:店铺信息已更新(如修改了营业时间),但用户看到的仍是缓存中的旧数据
- 内存溢出:长期不活跃的店铺缓存持续占用内存,影响系统整体性能
这正是我们需要实现"超时剔除"和"主动更新"双重策略的根本原因。前者通过TTL(Time To Live)机制自动清理过期数据,后者确保关键数据变更时能及时同步到缓存层。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis缓存策略设计
2.1 基础缓存方案选型
对于店铺信息这类结构化数据,通常有两种缓存方案:
- 序列化存储:将整个店铺对象转为JSON/String存入Redis
- 优点:一次查询即可获取完整信息
- 缺点:更新时需要反序列化-修改-再序列化
- Hash存储:利用Redis Hash结构存储字段
- 优点:可单独更新某个字段(如只修改评分)
- 缺点:查询所有字段时需要HGETALL操作
考虑到店铺信息的字段数量适中(通常10-20个字段),且需要频繁展示完整信息,本方案选择JSON序列化存储方式。示例数据结构如下:
java复制// Java实体类
public class Shop {
private Long id;
private String name;
private String address;
private Double score;
private String businessHours;
// 其他字段...
}
// 存储格式
{
"id": 1,
"name": "川湘居",
"address": "朝阳区建国路88号",
"score": 4.8,
"businessHours": "10:00-22:00"
}
2.2 超时剔除策略实现
Redis原生支持通过EXPIRE命令设置键的存活时间。我们需要考虑两个关键参数:
-
基础TTL时间:根据业务特点设置合理的过期时间
- 高频变更数据(如秒杀库存):设置较短TTL(如30秒)
- 低频变更数据(如店铺基础信息):设置较长TTL(如30分钟)
-
抖动因子:避免缓存雪崩
- 直接设置固定TTL可能导致大量缓存同时失效
- 解决方案:基础TTL + 随机抖动值(如±10%)
Java实现示例:
java复制public void setShopCache(Shop shop) {
// 基础TTL 30分钟 + 随机抖动(±3分钟)
int baseTtl = 30 * 60;
int randomTtl = ThreadLocalRandom.current().nextInt(-180, 180);
int finalTtl = baseTtl + randomTtl;
String key = "cache:shop:" + shop.getId();
String value = JSON.toJSONString(shop);
redisTemplate.opsForValue().set(key, value, finalTtl, TimeUnit.SECONDS);
}
2.3 主动更新策略设计
主动更新需要建立数据库与缓存的联动机制,常见模式有:
-
双写模式:在更新DB的同时直接更新缓存
- 优点:实现简单
- 缺点:非原子操作,可能产生不一致
-
消息队列:通过变更日志触发缓存更新
- 优点:解耦可靠
- 缺点:系统复杂度高
-
延迟双删:先删缓存→更新DB→延迟再删缓存
- 优点:平衡实现难度与一致性
- 缺点:需要合理设置延迟时间
考虑到"黑马点评"这类中型应用的实际情况,我们选择双写+异常重试的方案:
java复制@Transactional
public void updateShop(Shop shop) {
// 1. 更新数据库
shopMapper.updateById(shop);
// 2. 更新缓存
try {
String key = "cache:shop:" + shop.getId();
redisTemplate.opsForValue().set(
key,
JSON.toJSONString(shop),
30, TimeUnit.MINUTES
);
} catch (Exception e) {
log.error("更新店铺缓存失败,将加入重试队列", e);
// 3. 加入消息队列进行异步重试
retryQueue.add(new CacheRetryTask(key, shop));
}
}
3. 缓存一致性保障机制
3.1 读写策略优化
经典的缓存读写策略有以下几种:
| 策略 | 读操作 | 写操作 | 适用场景 |
|---|---|---|---|
| Cache Aside | 先查缓存,未命中查DB并回填 | 先更新DB,再删除缓存 | 通用场景 |
| Read Through | 缓存组件自动处理 | 同Cache Aside | 缓存组件支持时 |
| Write Through | 同Cache Aside | 缓存组件自动同步更新DB和缓存 | 强一致性要求场景 |
| Write Behind | 同Cache Aside | 先更新缓存,异步批量更新DB | 高写入吞吐量场景 |
我们采用增强型Cache Aside模式:
- 读流程:
mermaid复制graph TD A[查询缓存] --> B{存在?} B -->|是| C[返回缓存数据] B -->|否| D[查询数据库] D --> E[数据存入缓存] E --> F[返回数据] - 写流程:
mermaid复制graph TD A[更新数据库] --> B[删除缓存] B --> C[异步延时再删一次]
3.2 异常处理方案
在实际运行中需要特别关注以下异常场景:
-
缓存更新失败:
- 解决方案:建立重试机制,通过消息队列确保最终一致性
java复制@RabbitListener(queues = "cache.retry.queue") public void processRetry(CacheRetryTask task) { for (int i = 0; i < 3; i++) { try { redisTemplate.opsForValue().set( task.getKey(), task.getValue(), task.getTtl(), TimeUnit.SECONDS ); return; } catch (Exception e) { if (i == 2) { alertService.send("缓存重试失败告警", task); } } } } -
数据库与缓存不一致:
- 解决方案:添加校验任务,定期对比DB与缓存关键数据
java复制@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点执行 public void cacheConsistencyCheck() { List<Shop> shops = shopMapper.selectList(null); shops.forEach(shop -> { String cached = redisTemplate.opsForValue().get("cache:shop:" + shop.getId()); if (cached != null && !cached.equals(JSON.toJSONString(shop))) { log.warn("数据不一致,店铺ID:{}", shop.getId()); redisTemplate.delete("cache:shop:" + shop.getId()); } }); }
4. 性能优化实践
4.1 热点数据特殊处理
对于热门店铺(如评分前10%的店铺),建议采用差异化策略:
- 更短的TTL:设置为5-10分钟,保证信息及时性
- 多级缓存:本地缓存+Redis缓存
java复制@Cacheable(value = "localCache", key = "'shop:'+#id") public Shop getShop(Long id) { String key = "cache:shop:" + id; String json = redisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseObject(json, Shop.class); } Shop shop = shopMapper.selectById(id); if (shop != null) { redisTemplate.opsForValue().set( key, JSON.toJSONString(shop), 5, TimeUnit.MINUTES // 热门店铺TTL更短 ); } return shop; }
4.2 缓存预热策略
在系统启动或流量低谷期,主动加载高频访问数据:
-
定时任务预热:
java复制@Scheduled(cron = "0 30 6 * * ?") // 每天6:30执行 public void preloadHotShops() { List<Long> hotShopIds = shopMapper.selectHotShopIds(100); hotShopIds.forEach(id -> { Shop shop = shopMapper.selectById(id); if (shop != null) { redisTemplate.opsForValue().set( "cache:shop:" + id, JSON.toJSONString(shop), 10, TimeUnit.MINUTES ); } }); } -
启动时预热:
java复制@PostConstruct public void init() { if (isMasterNode()) { // 只有主节点执行预热 preloadHotShops(); } }
4.3 监控指标设计
完善的监控体系应包括:
-
基础指标:
- 缓存命中率 = 缓存命中次数 / 总查询次数
- 平均响应时间(区分缓存命中/未命中)
-
业务指标:
java复制// 使用Micrometer指标 MeterRegistry registry; public Shop getShopWithMetrics(Long id) { Timer.Sample sample = Timer.start(registry); boolean hitCache = true; try { String json = redisTemplate.opsForValue().get("cache:shop:" + id); if (json != null) { return JSON.parseObject(json, Shop.class); } hitCache = false; Shop shop = shopMapper.selectById(id); if (shop != null) { redisTemplate.opsForValue().set( "cache:shop:" + id, JSON.toJSONString(shop), 30, TimeUnit.MINUTES ); } return shop; } finally { Timer timer = hitCache ? registry.timer("shop.cache.hit") : registry.timer("shop.cache.miss"); sample.stop(timer); } }
5. 实战中的经验教训
5.1 缓存穿透防护
当查询不存在的店铺ID时,每次都会穿透到数据库。解决方案:
-
布隆过滤器:预先存储所有有效店铺ID
java复制public boolean mightContain(Long id) { return bloomFilter.mightContain(id); } public Shop getShopSafely(Long id) { if (!mightContain(id)) { return null; } return getShop(id); } -
空值缓存:对不存在的ID也缓存短时间
java复制public Shop getShopWithNullCache(Long id) { String key = "cache:shop:" + id; String json = redisTemplate.opsForValue().get(key); if (json != null) { if ("NULL".equals(json)) { return null; } return JSON.parseObject(json, Shop.class); } Shop shop = shopMapper.selectById(id); if (shop != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(shop), 30, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES); } return shop; }
5.2 并发更新控制
当多个线程同时更新同一店铺时,可能导致缓存与DB不一致。解决方案:
-
分布式锁:
java复制public void updateShopWithLock(Shop shop) { String lockKey = "lock:shop:" + shop.getId(); try { boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException("获取锁失败"); } updateShop(shop); } finally { redisTemplate.delete(lockKey); } } -
乐观锁:
java复制@Transactional public void updateShopWithVersion(Shop shop) { Shop dbShop = shopMapper.selectById(shop.getId()); if (!dbShop.getVersion().equals(shop.getVersion())) { throw new RuntimeException("版本不一致"); } shop.setVersion(shop.getVersion() + 1); shopMapper.updateById(shop); updateCache(shop); }
5.3 大Key优化
当店铺信息包含大量字段(如详细描述、图片列表)时,会导致单个缓存过大。解决方案:
-
拆分存储:
java复制public void saveLargeShop(Shop shop) { // 基础信息 ShopBase base = extractBaseInfo(shop); redisTemplate.opsForValue().set( "cache:shop:base:" + shop.getId(), JSON.toJSONString(base), 30, TimeUnit.MINUTES ); // 扩展信息 ShopExtra extra = extractExtraInfo(shop); redisTemplate.opsForValue().set( "cache:shop:extra:" + shop.getId(), JSON.toJSONString(extra), 30, TimeUnit.MINUTES ); } -
压缩存储:
java复制public void setCompressedData(String key, Object value) { String json = JSON.toJSONString(value); byte[] compressed = compress(json.getBytes()); redisTemplate.opsForValue().set( key.getBytes(), compressed, 30, TimeUnit.MINUTES ); } private byte[] compress(byte[] data) { // 使用GZIP等压缩算法 }
6. 总结与演进方向
在实际落地过程中,我们总结出几个关键点:
- TTL设置需要动态调整:根据店铺热度、变更频率等指标实现动态TTL配置
- 更新策略要区分场景:关键信息(如营业状态)需要立即更新,次要信息(如描述)可以延迟更新
- 监控比实现更重要:需要建立完善的缓存健康度监控体系
未来的优化方向包括:
- 引入Redis Module(如RedisSearch)实现复杂查询
- 试用Redis Stream实现更可靠的消息通知机制
- 探索持久化内存技术(如PMEM)进一步提升性能
