Redis旁路缓存深度解析
为什么需要缓存
在互联网应用里,数据库往往是整个系统的性能瓶颈。想象一下,当海量并发读取请求瞬间涌来时,如果每次都直接去敲数据库的门,会发生什么?
免费影视、动漫、音乐、游戏、小说资源长期稳定更新! 👉 点此立即查看 👈
结果通常不太美妙:数据库连接池被迅速榨干,复杂查询带来的响应延迟动辄几十甚至几百毫秒,用户体验直线下降。更棘手的是,想通过简单增加数据库实例来扩容,成本高昂且收效未必显著。
这时候,缓存的价值就凸显出来了。它的思路很直接:把那些被频繁访问的“热点”数据,提前搬到内存里。内存的访问速度是微秒级的,相比磁盘I/O,这简直是降维打击。
旁路缓存核心原理
在众多缓存应用模式中,旁路缓存(Cache-Aside Pattern)堪称经典中的经典。它的核心哲学非常清晰:由应用程序主动管理缓存,数据库是绝对的数据源头,缓存只是它的一个高效“副本”。
读操作流程
public Product getProduct(Long id) {
// 1. 先查缓存
Product cachedProduct = redis.get("product:" + id);
if (cachedProduct != null) {
// 缓存命中,直接返回
return cachedProduct;
}
// 2. 缓存未命中,查询数据库
Product product = productMapper.findById(id);
if (product != null) {
// 3. 将数据写入缓存,设置过期时间
redis.set("product:" + id, product, 3600);
}
return product;
}
读操作的逻辑链条非常直观:
- 第一步,先去Redis里看看有没有。
- 如果有(缓存命中),皆大欢喜,直接返回。
- 如果没有(缓存未命中),那就只能老老实实去查数据库。
- 从数据库拿到数据后,别忘了“回填”到缓存里,并给它设置一个合理的过期时间,方便后续请求快速获取。
写操作流程
public void updateProduct(Product product) {
// 1. 先更新数据库
productMapper.update(product);
// 2. 再删除缓存
redis.del("product:" + product.getId());
}
写操作采用了先更新数据库,再删除缓存的策略。为什么是“删除”而不是“更新”缓存?这里面有几个关键的考量:
- 保证数据最终一致性:即使缓存删除这一步失败了,数据库里的数据也已经是最新的。下次读取时,缓存未命中会触发数据库查询,并将新数据重新加载到缓存中,最终达到一致。
- 规避并发难题:在并发写场景下,更新缓存的时序很难控制,容易导致缓存与数据库数据不一致。直接删除缓存则简单粗暴且有效。
- 简化系统复杂度:不需要去维护一套缓存与数据库之间强同步的复杂机制。
缓存三大经典问题
1. 缓存穿透
问题描述:查询一个根本不存在的数据。因为缓存和数据库里都没有,所以每次请求都会穿透缓存,直接打到数据库上。
危害:这可不是小问题。如果被恶意攻击者利用,持续用大量不存在的数据ID发起请求,数据库很可能被压垮。
解决方案:
public Product getProduct(Long id) {
// 参数校验
if (id == null || id <= 0) {
return null;
}
// 1. 先查缓存
Product cachedProduct = redis.get("product:" + id);
if (cachedProduct != null) {
return cachedProduct;
}
// 2. 缓存未命中,查询数据库
Product product = productMapper.findById(id);
if (product == null) {
// 3. 缓存空值,防止穿透
redis.set("product:" + id, null, 60); // 短过期时间
return null;
}
// 4. 写入缓存
redis.set("product:" + id, product, 3600);
return product;
}
额外防护措施:对于穿透风险极高的场景,可以引入布隆过滤器(Bloom Filter)作为前置屏障。
// 使用布隆过滤器 private BloomFilterbloomFilter; public Product getProduct(Long id) { // 布隆过滤器检查 if (!bloomFilter.mightContain(id)) { return null; // 一定不存在 } // 正常查询流程 Product product = getProductFromCacheOrDB(id); return product; }
2. 缓存击穿
问题描述:注意,这和“穿透”不同。击穿指的是某个热点Key在缓存过期的瞬间,大量并发请求同时发现缓存失效,于是这些请求像洪水一样全部涌向数据库。
解决方案:
方案一:互斥锁
private boolean lock = false;
public Product getProduct(Long id) {
Product product = redis.get("product:" + id);
if (product != null) {
return product;
}
// 获取锁
if (tryLock("lock:product:" + id)) {
try {
// Double Check
product = redis.get("product:" + id);
if (product != null) {
return product;
}
// 查询数据库
product = productMapper.findById(id);
redis.set("product:" + id, product, 3600);
} finally {
unlock("lock:product:" + id);
}
} else {
// 等待后重试
Thread.sleep(100);
return getProduct(id);
}
return product;
}
方案二:逻辑过期
public Product getProduct(Long id) {
// 1. 查缓存
Product product = redis.get("product:" + id);
if (product == null) {
// 缓存为空,尝试获取锁重建缓存
if (tryLock("lock:product:" + id)) {
Product newProduct = productMapper.findById(id);
redis.set("product:" + id, newProduct, 3600);
unlock("lock:product:" + id);
return newProduct;
}
// 等待后重试
Thread.sleep(100);
return getProduct(id);
}
// 2. 检查是否逻辑过期
if (isLogicalExpired(product)) {
// 异步重建缓存,不阻塞请求
if (tryLock("lock:product:" + id)) {
threadPool.execute(() -> {
Product newProduct = productMapper.findById(id);
redis.set("product:" + id, newProduct, 3600);
unlock("lock:product:" + id);
});
}
}
return product;
}
3. 缓存雪崩
问题描述:大量缓存数据在同一时间过期失效,导致所有请求在那一刻都去查询数据库,数据库压力瞬间激增,可能引发连锁故障。
解决方案:
方案一:随机过期时间
// 设置过期时间添加随机值
int baseExpire = 3600;
int randomExpire = ThreadLocalRandom.current().nextInt(300);
redis.set("product:" + id, product, baseExpire + randomExpire);
方案二:多级缓存
public Product getProduct(Long id) {
// 1. 先查本地缓存
Product product = localCache.get(id);
if (product != null) {
return product;
}
// 2. 查 Redis
product = redis.get("product:" + id);
if (product != null) {
// 回填本地缓存
localCache.put(id, product, 300);
return product;
}
// 3. 查数据库
product = productMapper.findById(id);
redis.set("product:" + id, product, 3600);
return product;
}
方案三:服务降级
public Product getProduct(Long id) {
try {
// 1. 查缓存
Product product = redis.get("product:" + id);
if (product != null) {
return product;
}
// 2. 缓存未命中,降级处理
return getProductFromBackup(id);
} catch (Exception e) {
// Redis 异常,降级到数据库
log.error("Redis error, fallback to DB", e);
return productMapper.findById(id);
}
}
数据一致性方案
延迟双删
public void updateProduct(Product product) {
// 1. 删除缓存
redis.del("product:" + product.getId());
// 2. 更新数据库
productMapper.update(product);
// 3. 延迟删除缓存
threadPool.execute(() -> {
try {
Thread.sleep(1000);
redis.del("product:" + product.getId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
延迟双删策略主要用在写操作非常频繁,且对数据一致性要求极高的场景。第二次延迟删除,是为了清理掉在“更新数据库”这个极短时间窗口内,可能被其他线程读请求重新写入缓存的旧数据。
订阅 Binlog + Canal
// Canal 配置
@CanalMessageListener(topic = "product_db.product")
public void onMessage(CanalEntry.Entry entry) {
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
if (rowChange.getEventType() == CanalEntry.EventType.UPDATE) {
// 更新操作
for (Column column : rowData.getBeforeColumnsList()) {
if ("id".equals(column.getName())) {
Long id = Long.parseLong(column.getValue());
redis.del("product:" + id);
}
}
}
}
}
优势:
- 完全解耦:缓存更新异步化,完全不干扰主业务逻辑。
- 保证最终一致性:基于数据库的Binlog,能可靠地捕获所有数据变更。
- 适合大型系统:在复杂的分布式架构中,这种方案的可维护性和扩展性更好。
缓存策略最佳实践
缓存 key 设计
// 好的设计 String key = "product:info:" + categoryId + ":" + productId; String key = "user:profile:" + userId; String key = "order:summary:" + dateStr; // 避免的设计 String key = "product_" + productId; // 缺少命名空间,易冲突 String key = getComplexKey(product); // 包含复杂计算,影响性能 String key = "temp:" + System.currentTimeMillis(); // 使用时效性数据,难以管理
缓存 Value 设计
// 序列化配置
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public Product getProduct(Long id) {
return productMapper.findById(id);
}
// JSON 序列化
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
RedisTemplate template = new RedisTemplate<>();
template.setConnectionFactory(factory);
Jackson2JsonRedisSerializer
过期时间策略
| 数据类型 | 过期时间 | 原因 |
|---|---|---|
| 热点商品 | 24 小时 | 数据相对稳定 |
| 用户会话 | 30 分钟 | 安全性考虑 |
| 排行榜数据 | 5 分钟 | 更新频繁 |
| 配置信息 | 1 小时 | 变更不频繁 |
| 计数器 | 不过期 | 需要持久化 |
容量规划
// 预估缓存容量
// 假设每秒 10000 次查询,缓存 10000 条数据
// 每条数据 1KB
// 需要的内存 = 10000 * 1KB = 10MB
// 实际规划需要预留 20-30% 冗余
// 还需要考虑 Redis 本身的内存开销
监控告警
# 监控指标
- alert: RedisHighMemoryUsage
expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "Redis 内存使用率过高"
- alert: RedisHighHitMissRatio
expr: redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total) < 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "Redis 缓存命中率过低"
性能优化技巧
批量操作
// 批量查询 public MapgetProducts(List ids) { List keys = ids.stream() .map(id -> "product:" + id) .collect(Collectors.toList()); List products = redis.mGet(keys); Map result = new HashMap<>(); for (int i = 0; i < ids.size(); i++) { if (products.get(i) != null) { result.put(ids.get(i), products.get(i)); } } // 批量回补缓存 result.forEach((id, product) -> redis.set("product:" + id, product, 3600) ); return result; }
Pipeline 批量写入
public void batchWriteProducts(Listproducts) { redis.executePipelined((RedisCallback
缓存预热
@PostConstruct
public void warmupCache() {
// 系统启动时预加载热点数据
log.info("Start cache warmup...");
List hotProductIds = productService.getHotProductIds();
for (Long id : hotProductIds) {
Product product = productMapper.findById(id);
redis.set("product:" + id, product, 3600);
}
log.info("Cache warmup completed, {} products loaded", hotProductIds.size());
}
常见错误与规避
错误一:缓存与数据库双写不一致
// 错误写法:先更新缓存,再更新数据库
public void updateProduct(Product product) {
redis.set("product:" + product.getId(), product); // 先更新缓存
productMapper.update(product); // 后更新数据库
// 并发时可能缓存是旧数据
}
// 正确写法:先删缓存,再更新数据库
public void updateProduct(Product product) {
redis.del("product:" + product.getId()); // 先删缓存
productMapper.update(product); // 后更新数据库
}
错误二:缓存频繁更新
// 错误写法:每次访问都更新缓存
public Product getProduct(Long id) {
Product product = redis.get("product:" + id);
if (product == null) {
product = productMapper.findById(id);
}
// 每次都更新,浪费资源
redis.set("product:" + id, product, 3600);
return product;
}
// 正确写法:只在缓存不存在时更新
public Product getProduct(Long id) {
Product product = redis.get("product:" + id);
if (product == null) {
product = productMapper.findById(id);
if (product != null) {
redis.set("product:" + id, product, 3600);
}
}
return product;
}
错误三:大对象缓存
// 错误写法:缓存整个列表 public ListgetAllProducts() { List products = redis.get("all_products"); if (products == null) { products = productMapper.findAll(); redis.set("all_products", products, 300); } return products; } // 正确写法:分页缓存或使用压缩 public List getProducts(int page, int size) { String key = "products:" + page + ":" + size; return redis.get(key); }
总结
旁路缓存确实是提升系统读取性能的一把利器,但要想用好它,有几个关键点必须牢牢把握:
- 设计要合理:没有放之四海而皆准的策略,必须根据业务的数据访问模式、一致性要求来量身定制。
- 一致性是根本:根据业务对一致性的容忍度,在“延迟双删”和“Binlog订阅”等方案中做出明智选择。
- 容错不能忘:穿透、击穿、雪崩这“三兄弟”的防护措施,是线上系统的安全底线。
- 监控是眼睛:缓存命中率、内存使用量、响应时间,这些关键指标必须纳入实时监控和告警体系。
- 预案需完备:系统启动时的缓存预热,异常发生时的服务降级,这些预案能让系统更稳健。
说到底,正确且深入地理解并应用旁路缓存,往往能让系统性能实现数量级的提升。这不仅是优化技巧,更是现代后端开发者必须掌握的核心架构能力之一。
游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。
同类文章
Sql Server 2008 精简版(Express)+Management Studio Express第一次安装使用图文教程
SQL Server 2008 Express 精简版安装与连接全指南 对于需要在本地搭建小型CMS系统或进行应用程序测试开发的用户而言,SQL Server 2008 Express版本是一个理想且免费的数据库选择。虽然正式生产环境更推荐使用功能更全面的企业版,但Express版足以满足学习和开发
SQL Server 打开或关闭自增长
如何在特定场景下手动插入自增列的值 在数据库管理与开发过程中,我们有时会遇到一个看似矛盾的需求:某个字段已被定义为自增列,但在特定情况下,却需要手动为其指定一个具体的数值进行插入。掌握一个关键的数据操作语句,就能轻松应对此类场景。 为了更直观地理解,我们假设存在以下数据表: id | text 1
在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误。未找到或无法访问服务器
SQL Server 2008连接失败:报错40无法打开连接?手把手教你解决 许多用户在启动SQL Server 2008的SQL Server Management Studio (SSMS)时,输入sa账户密码后遭遇登录失败,系统提示如下网络连接错误: “在与 SQL Server 建立连接时出
把CSV文件导入到SQL Server表中的方法
SQL Server CSV数据导入实战指南:从基础到高级处理 在数据分析、报表生成或系统迁移过程中,将CSV格式的数据文件导入SQL Server数据库是一项高频且关键的操作。许多开发者可能会考虑编写外部程序来实现,但实际上,SQL Server自身就提供了高效、直接的批量导入功能,无需依赖额外代
SQL Server 2005 中使用 Try Catch 处理异常
TRY CATCH:SQL Server异常处理的优雅进化 如果你是SQL Server的老用户,一定对2005和2008版本引入的TRY CATCH功能记忆犹新。它彻底改变了我们处理数据库错误的方式,把开发人员从繁琐的全局变量检查中解放了出来,让异常处理变得清晰、直观。今天,我们就来好好聊
- 日榜
- 周榜
- 月榜
1
2
3
4
5
6
7
8
9
10
相关攻略
2015-03-10 11:25
2015-03-10 11:05
2021-08-04 13:30
2015-03-10 11:22
2015-03-10 12:39
2022-05-16 18:57
2025-05-23 13:43
2025-05-23 14:01
热门教程
- 游戏攻略
- 安卓教程
- 苹果教程
- 电脑教程
热门话题

