1. 项目概述
最近在给一个本地生活服务平台做商铺类型缓存优化,遇到了一个典型的缓存设计问题。与之前处理的单个商户缓存不同,这次需要缓存的是商铺类型列表数据。商铺类型数据的特点是更新频率低但查询频率高,非常适合用缓存来提升系统性能。
这个案例中,我们使用Redis作为缓存层,通过String类型存储经过JSON序列化的List集合。这种方案在中小型系统中非常实用,既能减少数据库压力,又能保证查询效率。下面我会详细拆解整个实现过程,包括技术选型考量、代码实现细节以及实际开发中容易踩的坑。
2. 技术方案设计
2.1 缓存数据结构选择
在Redis中存储List集合数据时,我们有几个可选方案:
- 使用String类型存储JSON序列化后的整个List
- 使用Redis原生的List类型
- 使用Hash类型存储各个元素
我们最终选择了第一种方案,主要基于以下考虑:
- 商铺类型数据量通常不大(一般不超过100条)
- 数据需要整体查询,很少单独操作某个元素
- JSON序列化后可以直接利用String类型的原子性操作
- 实现简单,与现有代码结构兼容性好
提示:当列表元素经常需要单独操作,或者列表非常大时(超过1000条),建议考虑使用Redis原生List类型。
2.2 缓存键设计规范
良好的缓存键设计能避免键冲突和提高可维护性。我们采用了三层命名空间结构:
code复制cache:shoptype:
这种设计有几个优点:
- 使用冒号分隔层级,符合Redis最佳实践
- 前缀表明这是缓存数据
- 中间层标明业务领域
- 预留了扩展空间(可在最后添加ID等)
3. 核心代码实现
3.1 控制器层实现
控制器层保持简洁,主要职责是接收请求和返回响应:
java复制@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
@Resource
private IShopTypeService typeService;
@GetMapping("list")
public Result queryTypeList() {
return typeService.queryAll();
}
}
这里有几个设计要点:
- 使用
@RestController自动处理JSON转换 - 路径前缀
/shop-type明确资源类型 - 业务逻辑完全委托给Service层
- 统一返回Result包装对象
3.2 服务接口定义
服务接口明确定义了业务契约:
java复制public interface IShopTypeService extends IService<ShopType> {
/**
* 查询所有商铺类型
* @return 商铺类型列表
*/
Result queryAll();
}
扩展MyBatis-Plus的IService接口可以获得基础的CRUD能力,同时自定义queryAll()方法实现特定业务逻辑。
3.3 服务实现细节
服务实现类是核心逻辑所在,完整实现了缓存查询和数据库回填流程:
java复制@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType>
implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryAll() {
// 1. 尝试从Redis获取缓存
String shopTypeJson = stringRedisTemplate.opsForValue()
.get(RedisConstants.CACHE_SHOPTYPE_KEY);
// 2. 缓存命中处理
if (StrUtil.isNotBlank(shopTypeJson)){
List<ShopType> cachedTypes = JSONUtil.toList(shopTypeJson, ShopType.class);
return Result.ok(cachedTypes);
}
// 3. 缓存未命中,查询数据库
List<ShopType> shopTypes = query().orderByAsc("sort").list();
// 4. 数据库无数据情况处理
if (shopTypes == null || shopTypes.isEmpty()){
return Result.fail("商铺类型不存在");
}
// 5. 写入Redis缓存
stringRedisTemplate.opsForValue().set(
RedisConstants.CACHE_SHOPTYPE_KEY,
JSONUtil.toJsonStr(shopTypes)
);
return Result.ok(shopTypes);
}
}
这段代码实现了标准的缓存查询流程,有几个关键点值得注意:
- 使用
StringRedisTemplate而不是普通的RedisTemplate,避免序列化问题 - 使用Hutool的
StrUtil进行字符串判空,比原生方法更安全 - JSON转换使用Hutool的
JSONUtil,简化操作 - 数据库查询使用MyBatis-Plus的链式调用,清晰简洁
- 对空结果做了防御性处理
3.4 Redis常量管理
将Redis相关的常量集中管理是个好习惯:
java复制public class RedisConstants {
public static final String CACHE_SHOPTYPE_KEY = "cache:shoptype:";
}
这种做法的好处是:
- 避免魔法字符串散落在代码各处
- 方便统一修改和维护
- 提高代码可读性
- 便于查找所有Redis键
4. 性能优化与问题排查
4.1 缓存穿透防护
当前的实现存在缓存穿透风险:当数据库中没有商铺类型数据时,每次查询都会打到数据库。改进方案:
java复制// 在查询数据库无结果后,缓存空值
if (shopTypes == null || shopTypes.isEmpty()){
stringRedisTemplate.opsForValue().set(
RedisConstants.CACHE_SHOPTYPE_KEY,
"",
Duration.ofMinutes(5) // 短期缓存空值
);
return Result.fail("商铺类型不存在");
}
设置短期空值缓存可以有效防止恶意请求穿透到数据库。
4.2 缓存雪崩预防
如果大量商铺类型数据同时失效,可能引发缓存雪崩。解决方案是给缓存过期时间增加随机值:
java复制// 设置缓存时添加随机过期时间
int randomExpire = 30 + new Random().nextInt(30); // 30-60分钟
stringRedisTemplate.opsForValue().set(
RedisConstants.CACHE_SHOPTYPE_KEY,
JSONUtil.toJsonStr(shopTypes),
Duration.ofMinutes(randomExpire)
);
4.3 数据一致性保障
当商铺类型数据变更时,需要及时更新缓存。可以在数据修改方法中添加缓存删除逻辑:
java复制@Transactional
public Result updateShopType(ShopType shopType) {
// 更新数据库
boolean success = updateById(shopType);
if(success) {
// 删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOPTYPE_KEY);
}
return Result.ok(success);
}
使用@Transactional确保数据库和缓存操作的原子性。
5. 扩展思考与优化方向
5.1 缓存预热策略
对于商铺类型这类变化少、查询多的数据,可以在系统启动时进行缓存预热:
java复制@PostConstruct
public void initShopTypeCache() {
List<ShopType> shopTypes = query().orderByAsc("sort").list();
if(shopTypes != null && !shopTypes.isEmpty()) {
stringRedisTemplate.opsForValue().set(
RedisConstants.CACHE_SHOPTYPE_KEY,
JSONUtil.toJsonStr(shopTypes)
);
}
}
使用@PostConstruct在Bean初始化后自动执行缓存预热。
5.2 二级缓存方案
对于更高性能要求的场景,可以考虑引入本地Caffeine缓存作为Redis的二级缓存:
java复制@Cacheable(value = "shopType", key = "'all'")
public Result queryAllWithL2Cache() {
// 原有逻辑不变
}
配置Spring Cache集成Caffeine和Redis,形成多级缓存体系。
5.3 监控与告警
添加缓存命中率监控,便于及时发现性能问题:
java复制public Result queryAllWithMetrics() {
// 增加命中率统计
cacheHitCounter.increment();
// ...原有逻辑
// 未命中时
cacheMissCounter.increment();
}
定期将统计指标输出到监控系统,设置合理的告警阈值。
6. 常见问题与解决方案
6.1 JSON序列化问题
问题现象:从Redis取出的JSON无法正确反序列化为Java对象。
解决方案:
- 确保使用一致的JSON库(推荐Jackson或Hutool的JSONUtil)
- 检查Java对象的无参构造器和getter/setter
- 复杂对象考虑实现自定义序列化逻辑
6.2 缓存更新延迟
问题现象:数据库已更新,但缓存还是旧数据。
解决方案:
- 确保所有数据修改操作都同步清理缓存
- 考虑使用发布/订阅机制通知其他节点更新缓存
- 对关键数据设置较短的过期时间
6.3 内存占用过高
问题现象:Redis内存使用率持续增长。
解决方案:
- 监控缓存数据大小,过大时考虑分片
- 对集合类数据设置合理的过期时间
- 定期分析内存使用情况,优化数据结构
7. 最佳实践总结
经过这个项目的实践,我总结了几个商铺类型缓存的关键经验:
-
选择合适的序列化方式:JSON在大多数场景下都是不错的选择,但要注意处理循环引用等复杂情况。
-
缓存粒度要适中:商铺类型这种小型列表适合整体缓存,大型数据集应考虑分片或分页。
-
异常情况要考虑周全:空结果、异常格式、网络问题等都需要有应对方案。
-
监控必不可少:没有监控的缓存就像没有仪表的汽车,无法知道何时会出问题。
-
文档和注释很重要:清晰的代码注释和架构文档能大大降低维护成本。
在实际部署后,这个缓存方案使商铺类型查询的响应时间从平均50ms降低到了5ms左右,数据库负载降低了90%,效果非常显著。对于类似的低频变化、高频访问的数据,这种缓存模式值得推荐。