在软件开发项目中,缓存的使用十分普遍。缓存作为一种存储机制,能够暂时保存数据,从而加速数据的读取和访问。然而,当数据同时存在于缓存和数据库中时,如何保证两者的数据一致性成为了一个关键问题。在 PmHub 项目中,同样面临着这样的挑战,下面将详细介绍 PmHub 中保证缓存和数据库一致性的相关内容。
1 缓存的重要性
缓存可分为本地缓存和分布式缓存。本地缓存如 JDK 自带的 HashMap 和 ConcurrentHashMap,以及 Ehcache、Guava Cache、Spring Cache、Caffeine 等常见的本地缓存框架;分布式缓存如常用的 Redis,它可以单机或者集群部署在不同的服务器上。本地缓存与应用处于同一位置,而分布式缓存独立部署在不同服务器。无论是哪种缓存,其核心目的都是用空间换时间,通过存储可能重复使用或计算的数据,减少数据的重新获取或计算时间。
2 缓存一致性问题
导致缓存和数据库数据不一致的原因主要有以下几点:
- 缓存过期:缓存中的数据存在生命周期,过期后若未及时更新,会出现数据不一致情况。
- 写操作延迟:执行写操作时,数据库更新和缓存更新时间不同步,可能导致缓存中的数据不一致。
- 并发操作:多个并发操作同时进行,会出现竞态条件,从而导致缓存和数据库数据不一致。
- 缓存失效策略不当:使用不当的缓存失效策略,会使缓存中的数据无法及时更新,造成不一致。
- 网络延迟或故障:网络延迟或故障会导致缓存服务器和数据库之间通信出现问题,进而导致数据不一致。
3 常见的解决不一致问题的方案
- Cache Aside 模式:在读写操作中使用该模式,确保在写操作后及时失效缓存中的数据。
- 分布式锁:在并发写操作时使用分布式锁,保证同时只有一个操作能够更新缓存和数据库,避免竞态条件。
- 双写一致性:在写操作时同时更新数据库和缓存,以确保数据的一致性。
- 延迟双删:在写操作时,先删除缓存中的数据,更新数据库后,再次删除缓存中的数据,确保缓存中数据的一致性。
- 版本控制:在缓存和数据库中使用版本号或时间戳,确保数据更新时的一致性检查。
- 监控和告警:对缓存和数据库中的数据进行监控,发现不一致时及时告警并处理。
4 常见缓存更新策略
模式名称 | 描述 | 优点 | 缺点 | 使用场景 |
---|---|---|---|---|
Cache Aside Pattern (旁路缓存模式) | 读取数据时先检查缓存,缓存未命中则从数据库读取并更新缓存。写入数据时先更新数据库,然后使缓存失效。 | - 读取性能高 - 实现简单 |
- 首次请求数据一定不在缓存问题 - 写操作较频繁的话会导致缓存中数据被频繁删除,会影响缓存命中率 |
数据读取频率高,写入频率较低的场景 |
Read/Write Through Pattern (读写穿透模式) | 所有的读写操作都通过缓存进行,缓存负责同步数据库。 | - 数据一致性好 - 实现了读写操作的统一 |
- 实现复杂 - 依赖缓存的高可用性 |
数据读取和写入频率均较高的场景 |
Write Behind Pattern (异步缓存写入) | 写操作首先更新缓存,然后异步地将数据写入数据库。 | - 写操作性能高 - 减少数据库压力 |
- 存在数据丢失的风险 - 数据一致性较差 |
写操作频繁,且对实时一致性要求不高的场景 |
4 Cache Aside 模式
4.1 Cache Aside 模式概述
读取数据时先检查缓存,缓存未命中则从数据库读取并更新缓存;写入数据时先更新数据库,然后使缓存失效。
4.2 Cache Aside 模式优点
- 读取性能高:缓存命中时可直接从缓存读取数据,显著减少数据库访问压力。
- 实现简单:模式逻辑清晰,容易理解和实现。
- 灵活性强:程序员可根据需要灵活控制缓存的更新和失效策略,适应不同应用场景和需求。
- 缓存利用率高:只有在缓存未命中时才从数据库读取数据并更新缓存,避免不必要的数据冗余。
4.3 Cache Aside 模式局限
- 写操作较慢:每次写操作都需更新数据库并使缓存失效,操作过程较长,导致写操作性能较低。
- 数据一致性挑战:在高并发环境下,缓存和数据库之间容易出现数据不一致的情况,如缓存失效不及时或更新顺序问题。
- 缓存预热问题:初始时缓存为空,第一次读取时会有较大延迟,因此建议将热点数据直接放入缓存。
- 复杂的失效策略:程序员需要设计合理的缓存失效策略,增加了实现和维护的复杂性。
- 过期数据风险:若没有合适的失效机制,缓存中可能存在过期数据,返回错误或过时的信息给用户。
5 PmHub 中的实践
5.1 PmHub 中的数据读取
先查询缓存,若有数据则直接返回,若无则去数据库查询,然后写入缓存。例如根据键名查询参数配置信息即采用此方式:com.laigeoffer.pmhub.system.service.impl.SysConfigServiceImpl#selectConfigByKey
。
/**
* 根据键名查询参数配置信息
* 查询逻辑:
* 1. 优先从Redis缓存中获取配置值
* 2. 缓存未命中时查询数据库
* 3. 将数据库查询结果写入缓存(缓存穿透保护)
* 4. 最终未找到配置时返回空字符串
*
* @param configKey 参数key
* @return 参数键值,未找到时返回空字符串
*/
@Override
public String selectConfigByKey(String configKey) {
// 从Redis获取缓存值(使用系统配置专用前缀)
String configValue = Convert.toStr(redisService.getCacheObject(getCacheKey(configKey)));
// 命中缓存直接返回
if (StringUtils.isNotEmpty(configValue)) {
return configValue;
}
// 构造查询条件对象
SysConfig config = new SysConfig();
config.setConfigKey(configKey);
// 查询数据库配置信息
SysConfig retConfig = configMapper.selectConfig(config);
// 数据库查询结果处理
if (StringUtils.isNotNull(retConfig)) {
// 更新缓存(设置永不过期)
redisService.setCacheObject(getCacheKey(configKey), retConfig.getConfigValue());
return retConfig.getConfigValue();
}
// 未找到配置时返回空字符串
return StringUtils.EMPTY;
}
5.2 PmHub 中的数据更新
更新数据时,先更新数据库信息,然后删除缓存信息,例如在批量删除参数信息的场景中即采用此方式:com.laigeoffer.pmhub.system.service.impl.SysConfigServiceImpl#deleteConfigByIds
。
/**
* 批量删除参数信息
* 删除逻辑:
* 1. 遍历所有待删除配置ID
* 2. 对每个配置执行:
* - 校验是否为内置参数(禁止删除内置配置)
* - 执行数据库删除
* - 清理对应Redis缓存
* 3. 遇到内置参数时抛出业务异常终止操作
*
* @param configIds 需要删除的参数ID数组
* @throws ServiceException 当尝试删除内置参数时抛出
*/
@Override
public void deleteConfigByIds(Long[] configIds) {
// 遍历所有待删除配置ID
for (Long configId : configIds) {
// 获取完整配置信息(用于后续校验和缓存清理)
SysConfig config = selectConfigById(configId);
// 内置参数校验(UserConstants.YES 表示内置系统参数)
if (StringUtils.equals(UserConstants.YES, config.getConfigType())) {
throw new ServiceException(String.format("内置参数【%1$s】不能删除 ", config.getConfigKey()));
}
// 执行数据库删除
configMapper.deleteConfigById(configId);
// 清理对应的Redis缓存(避免脏数据残留)
redisService.deleteObject(getCacheKey(config.getConfigKey()));
}
}
6 总结
本文围绕软件开发中缓存使用展开,阐述缓存重要性,分析缓存与数据库数据不一致原因,介绍常见解决不一致的方案和缓存更新策略,展示PmHub项目中数据读取和更新实践,为确保数据一致性提供参考。
7 参考链接
- PmHub如何保证缓存和数据库的一致性
- 项目仓库(GitHub):https://github.com/laigeoffer/pmhub
- 项目仓库(码云):https://gitee.com/laigeoffer/pmhub (国内访问速度更快)