1. 项目背景与核心价值
在本地生活服务类应用中,"黑马点评"这类商户展示平台面临的核心挑战之一是如何应对高并发访问带来的数据库压力。当用户频繁刷新商铺列表页面或查看热门商户详情时,如果每次都直接查询数据库,不仅响应速度会变慢,还可能在高流量时段导致系统崩溃。
我在实际开发中就遇到过这样的场景:某次平台促销活动期间,由于没有做缓存层设计,数据库QPS瞬间飙升至8000+,导致整个服务不可用。那次事故后,我们痛定思痛,决定对商户数据和商铺列表进行全面缓存改造。经过实测,合理使用Redis缓存后,相同流量下数据库QPS下降了92%,页面响应时间从原来的1.2秒降至200毫秒以内。
2. 缓存方案设计思路
2.1 为什么选择Redis作为缓存中间件
Redis作为内存数据库具有几个不可替代的优势:
- 单线程模型避免了锁竞争,读写性能极高(10万+ QPS)
- 丰富的数据结构支持(String/List/Hash/Set等)
- 内置过期机制和持久化方案
- 成熟的集群模式支持横向扩展
对于商户数据这类读多写少的热点数据,使用Redis的String类型存储序列化后的商户对象是最佳选择。而商铺列表这类需要分页查询的数据,则适合用List或Sorted Set结构存储。
2.2 缓存策略选型对比
我们对比了三种常见缓存策略的适用场景:
| 策略类型 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 旁路缓存 | 先查缓存,未命中查DB | 实现简单,缓存命中率高 | 存在缓存穿透风险 | 商户详情等点查询 |
| 写穿透 | 写操作同时更新缓存和DB | 保证强一致性 | 写性能较低 | 对一致性要求高的场景 |
| 异步刷新 | 定时任务定期更新缓存 | 降低数据库压力 | 存在短暂数据不一致 | 商铺列表等时效性不强的数据 |
经过评估,我们最终采用"旁路缓存+异步刷新"的混合方案:对于商户详情使用旁路缓存,商铺列表则采用定时异步刷新策略。
3. 商户详情缓存实现细节
3.1 缓存数据结构设计
商户对象在Redis中的存储采用标准的key-value结构:
code复制key: "shop:{id}"
value: JSON序列化的商户对象
这里有几个设计要点:
- key中使用冒号分隔形成命名空间,便于管理
- 设置合理的TTL(我们设置为30分钟)
- 使用JSON而非Java序列化,便于跨语言使用和调试
3.2 缓存读写流程实现
典型的缓存查询代码如下(Spring Boot示例):
java复制public Shop getShopById(Long id) {
String key = "shop:" + id;
// 1. 尝试从缓存查询
String shopJson = redisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(shopJson)) {
return JSON.parseObject(shopJson, Shop.class);
}
// 2. 缓存未命中,查询数据库
Shop shop = shopMapper.selectById(id);
if (shop == null) {
return null;
}
// 3. 写入缓存
redisTemplate.opsForValue().set(key, JSON.toJSONString(shop), 30, TimeUnit.MINUTES);
return shop;
}
3.3 缓存更新策略
当商户信息变更时,我们采用"先更新数据库,再删除缓存"的策略(Cache-Aside模式):
java复制@Transactional
public void updateShop(Shop shop) {
// 1. 更新数据库
shopMapper.updateById(shop);
// 2. 删除缓存
String key = "shop:" + shop.getId();
redisTemplate.delete(key);
}
注意:这里不能直接更新缓存而应该删除缓存,因为并发写可能导致缓存数据不一致。采用删除策略让下次查询时重新加载,虽然可能造成一次缓存穿透,但保证了数据正确性。
4. 商铺列表缓存实现方案
4.1 分页缓存的设计挑战
商铺列表缓存比单商户缓存复杂得多,主要面临三个问题:
- 分页参数组合爆炸(每页大小、排序方式、筛选条件等)
- 数据更新时缓存失效难以管理
- 冷数据占用大量内存空间
我们的解决方案是:
- 只缓存前N页的热门数据(实践中发现90%的访问集中在前5页)
- 使用Sorted Set存储商铺ID,value为商铺评分/热度等可排序字段
- 商铺详情依然走单商户缓存
4.2 列表缓存数据结构
code复制// 商铺ID有序集合
key: "shops:sort:score"
value: [shopId1:score1, shopId2:score2...]
// 分页缓存
key: "shops:page:{page}:{size}"
value: [shopId1, shopId2...]
4.3 缓存预热与更新
通过定时任务每天凌晨2点刷新列表缓存:
java复制@Scheduled(cron = "0 0 2 * * ?")
public void refreshShopListCache() {
// 1. 查询最新商铺ID列表(按评分排序)
List<Long> shopIds = shopMapper.selectAllOrderByScore();
// 2. 更新有序集合
String sortKey = "shops:sort:score";
redisTemplate.delete(sortKey);
shopIds.forEach(shopId -> {
Shop shop = shopMapper.selectById(shopId);
redisTemplate.opsForZSet().add(sortKey, shopId, shop.getScore());
});
// 3. 重建分页缓存
int pageSize = 10;
int totalPages = (shopIds.size() + pageSize - 1) / pageSize;
for (int page = 1; page <= Math.min(totalPages, 5); page++) {
int from = (page - 1) * pageSize;
int to = Math.min(from + pageSize, shopIds.size());
List<Long> pageIds = shopIds.subList(from, to);
String pageKey = "shops:page:" + page + ":" + pageSize;
redisTemplate.delete(pageKey);
redisTemplate.opsForList().rightPushAll(pageKey, pageIds);
}
}
5. 缓存问题应对方案
5.1 缓存穿透防护
对于商户详情缓存,当查询不存在的商铺ID时,会导致每次请求都穿透到数据库。解决方案是:
- 缓存空对象(设置较短的TTL,如5分钟)
- 使用布隆过滤器预先过滤非法ID
改进后的查询逻辑:
java复制public Shop getShopById(Long id) {
// 1. 布隆过滤器检查
if (!bloomFilter.mightContain(id)) {
return null;
}
String key = "shop:" + id;
String shopJson = redisTemplate.opsForValue().get(key);
// 2. 区分空缓存和未缓存
if (shopJson != null) {
return "".equals(shopJson) ? null : JSON.parseObject(shopJson, Shop.class);
}
Shop shop = shopMapper.selectById(id);
if (shop == null) {
// 3. 缓存空对象
redisTemplate.opsForValue().set(key, "", 5, TimeUnit.MINUTES);
return null;
}
redisTemplate.opsForValue().set(key, JSON.toJSONString(shop), 30, TimeUnit.MINUTES);
return shop;
}
5.2 缓存雪崩预防
如果大量缓存在同一时间过期,会导致瞬间数据库压力激增。我们的解决方案:
- 对TTL设置随机波动(如基础30分钟±随机5分钟)
- 对热点数据采用永不过期策略,通过后台任务异步更新
java复制// 设置随机过期时间
int baseTTL = 30;
int randomTTL = baseTTL * 60 + (int)(Math.random() * 5 * 60);
redisTemplate.opsForValue().set(key, value, randomTTL, TimeUnit.SECONDS);
5.3 热点key重建优化
当某个热门商铺缓存过期时,可能会引发大量线程同时重建缓存。我们使用Redis的setnx实现互斥锁:
java复制public Shop getShopByIdWithLock(Long id) {
// ... 省略前置逻辑
// 缓存重建锁
String lockKey = "lock:shop:" + id;
try {
// 尝试获取锁
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 查询数据库并重建缓存
Shop shop = shopMapper.selectById(id);
redisTemplate.opsForValue().set(key, JSON.toJSONString(shop), 30, TimeUnit.MINUTES);
return shop;
} else {
// 未获取到锁,短暂休眠后重试
Thread.sleep(50);
return getShopByIdWithLock(id);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
redisTemplate.delete(lockKey);
}
}
6. 性能优化实践
6.1 缓存压缩
当商户对象较大时(如包含长描述、多张图片URL),可以考虑对缓存值进行压缩:
java复制// 写入时压缩
byte[] compressed = CompressionUtils.gzip(JSON.toJSONString(shop));
redisTemplate.opsForValue().set(key, compressed, ttl, TimeUnit.SECONDS);
// 读取时解压
byte[] compressed = redisTemplate.opsForValue().get(key);
String json = CompressionUtils.gunzip(compressed);
实测一个平均5KB的商户对象,压缩后能减少到1KB左右,内存占用降低80%。
6.2 本地二级缓存
对于极端热点的商户(如平台头部商家),可以增加本地缓存作为二级缓存:
java复制// 使用Caffeine作为本地缓存
private final Cache<Long, Shop> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
public Shop getShopByIdMultiLevel(Long id) {
// 1. 检查本地缓存
Shop shop = localCache.getIfPresent(id);
if (shop != null) {
return shop;
}
// 2. 检查Redis缓存
shop = getShopById(id);
if (shop != null) {
localCache.put(id, shop);
}
return shop;
}
6.3 批量查询优化
对于商铺列表页,使用Redis的pipeline批量获取商铺详情:
java复制public List<Shop> batchGetShops(List<Long> ids) {
// 1. 构建pipeline
List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Long id : ids) {
connection.stringCommands().get(("shop:" + id).getBytes());
}
return null;
});
// 2. 处理结果
List<Shop> shops = new ArrayList<>();
for (Object result : results) {
if (result != null) {
shops.add(JSON.parseObject((String)result, Shop.class));
}
}
// 3. 补充未命中缓存的数据
if (shops.size() < ids.size()) {
List<Long> missIds = new ArrayList<>();
for (int i = 0; i < ids.size(); i++) {
if (results.get(i) == null) {
missIds.add(ids.get(i));
}
}
List<Shop> dbShops = shopMapper.selectBatchIds(missIds);
shops.addAll(dbShops);
// 异步写入缓存
CompletableFuture.runAsync(() -> {
for (Shop s : dbShops) {
redisTemplate.opsForValue().set("shop:" + s.getId(),
JSON.toJSONString(s), 30, TimeUnit.MINUTES);
}
});
}
return shops;
}
7. 监控与运维
7.1 缓存命中率监控
我们在应用中集成了Micrometer指标,实时监控缓存命中情况:
java复制private final MeterRegistry meterRegistry;
public Shop getShopById(Long id) {
String key = "shop:" + id;
String shopJson = redisTemplate.opsForValue().get(key);
if (shopJson != null) {
meterRegistry.counter("cache.hit", "type", "shop").increment();
return JSON.parseObject(shopJson, Shop.class);
} else {
meterRegistry.counter("cache.miss", "type", "shop").increment();
// ... 后续逻辑
}
}
通过Grafana仪表盘可以直观看到各业务缓存的命中率变化,当命中率低于85%时会触发告警。
7.2 大key扫描
定期使用SCAN命令检查Redis中的大key:
bash复制redis-cli --bigkeys
对于超过10KB的key进行优化(拆分或压缩),避免单key过大影响集群性能。
7.3 缓存清理策略
建立完善的缓存清理机制,包括:
- 按业务前缀批量删除(如清除所有商铺缓存)
- 按模式匹配删除(如清除某区域商铺缓存)
- 渐进式删除,避免一次性删除大量key导致Redis阻塞
java复制public void clearShopCacheByPattern(String pattern) {
// 使用SCAN分批删除
String matchKey = "shop:" + pattern + "*";
redisTemplate.execute((RedisCallback<Void>) connection -> {
Cursor<byte[]> cursor = connection.scan(ScanOptions.scanOptions()
.match(matchKey)
.count(100) // 每批100个
.build());
int batchSize = 0;
while (cursor.hasNext()) {
connection.del(cursor.next());
if (++batchSize % 100 == 0) {
// 每100条暂停10ms,避免阻塞Redis
try { Thread.sleep(10); }
catch (InterruptedException e) { break; }
}
}
return null;
});
}
8. 踩坑经验分享
在实际落地缓存方案时,我们遇到过几个典型问题:
-
序列化选择不当:早期使用Java原生序列化,导致缓存大小膨胀3-5倍,改为JSON后内存占用大幅降低。后来发现Kryo序列化比JSON更高效,但牺牲了可读性。
-
TTL设置不合理:最初所有缓存都用固定30分钟TTL,导致流量高峰时集中失效。改为基础TTL+随机波动后,数据库压力曲线变得平稳。
-
缓存与DB不一致:有用户反馈看到的信息与实际不符,排查发现是缓存删除失败导致的。后来我们增加了删除重试机制和异步校验任务。
-
热点key问题:某次明星店铺活动导致单一key访问QPS超过3万,Redis CPU飙升至90%。通过增加本地二级缓存和拆分热点key解决了这个问题。
-
内存泄漏:由于没有限制缓存数量,某次运营活动导致Redis内存爆满。后来我们增加了每个业务缓存的内存上限和淘汰策略。