缓存方案对比:本地缓存、Redis 与分布式缓存的多层策略
缓存方案对比本地缓存、Redis 与分布式缓存的多层策略一、深度引言与场景痛点每个请求都查一次数据库接口响应从 50ms 涨到 800ms7 月给刷题系统加了一个热门题目排行榜的功能。第一版实现很简单每次请求都执行SELECT problem_id, COUNT(*) as freq FROM submissions GROUP BY problem_id ORDER BY freq DESC LIMIT 20。用户量是个位数的时候50ms 就返回了。当我把系统开放给部门的 30 个同事试用后峰值并发到了 20 QPS——相同的查询跑在积累了一周的数据表上响应时间飙升到了 800ms。问题的根源是排行榜是一个每分钟更新就够的数据但我每次请求都进行了一次全表扫描。引入缓存后响应时间降回 10ms。但引入缓存也带来了新问题缓存什么时候失效多实例部署时缓存怎么保持一致本文通过刷题系统中的三类数据场景对比本地缓存、Redis、分布式缓存的适用边界。二、底层机制与原理深度剖析缓存设计的三个核心问题所有缓存设计都需要回答三个问题存什么、存多久、怎么失效。存什么不是所有数据都适合缓存。适合缓存的数据特征是读多写少、计算代价高、对实时性要求不高。排行榜是典型——很多人查看、很少人更新每次提交才更新排名、允许几分钟的延迟。存多久TTL过期时间的选择是关键。TTL 太短命中率低缓存没有发挥作用。TTL 太长数据过期用户看到的是旧的排行榜。TTL 的选择取决于业务对实时性的容忍度排行榜可以设 5 分钟用户会话可以设 30 分钟系统配置可以设 1 小时。怎么失效缓存更新策略有三种——定时过期TTL 到期自动删除、主动失效数据变更时删除对应缓存、惰性更新请求时检查是否需要更新。实践中通常组合使用TTL 到期作为兜底数据变更时主动删除作为精确控制。三、生产级代码实现与最佳实践三级缓存架构 刷题系统的三级缓存架构 L1 本地缓存进程内存 → L2 Redis分布式缓存 → L3 数据库持久化存储 from dataclasses import dataclass from functools import lru_cache from typing import Optional, List, Dict import time import json # L1本地缓存进程内存 本地缓存适合热点数据、量小、不需要跨实例共享 如用户会话信息、系统配置 class LocalCache: 基于字典的本地缓存 适合单实例场景下的小规模热数据 多实例部署时各实例独立缓存数据可能不一致 def __init__(self, default_ttl: int 300): self._cache: Dict[str, tuple] {} # {key: (value, expire_time)} self.default_ttl default_ttl def get(self, key: str) - Optional[any]: 读取缓存 —— 惰性过期策略 if key not in self._cache: return None value, expire_at self._cache[key] if time.time() expire_at: del self._cache[key] # 惰性删除 return None return value def set(self, key: str, value: any, ttl: int None): 写入缓存 —— 设置过期时间 ttl ttl or self.default_ttl self._cache[key] (value, time.time() ttl) def delete(self, key: str): 主动删除 —— 数据变更时调用 self._cache.pop(key, None) property def size(self) - int: return len(self._cache) # L2Redis 分布式缓存 Redis 适合多实例共享的数据、需要持久化的缓存、排行榜等特殊数据结构 class RedisCache: 基于 Redis 的分布式缓存 多实例共享同一份缓存数据 支持过期时间、数据结构丰富 def __init__(self, redis_url: str redis://localhost:6379): # self.redis redis.from_url(redis_url, decode_responsesTrue) pass def get_leaderboard(self, top_n: int 20) - List[Dict]: 读取排行榜 —— 利用 Redis Sorted Set ZSET 天然适合排行榜O(log N) 更新O(log N M) 查询 Top N 不用像 MySQL 那样每次全表扫描 GROUP BY ORDER BY # 获取排行榜前 N 名及其得分 # results self.redis.zrevrange( # leaderboard:weekly, 0, top_n - 1, withscoresTrue # ) # return [ # {problem_id: pid, count: int(score)} # for pid, score in results # ] return [] def update_leaderboard(self, problem_id: str): 更新排行榜 —— 每有一次提交对应题目的分数 1 ZINCRBY 原子操作多并发安全 # self.redis.zincrby(leaderboard:weekly, 1, problem_id) pass def get_with_fallback( self, key: str, fallback_fn, ttl: int 300 ) - Optional[any]: Cache-Aside 模式读缓存 → 未命中 → 查数据库 → 回写缓存 这是最常用的缓存使用模式 # 1. 尝试从缓存获取 # cached self.redis.get(key) # if cached: # return json.loads(cached) cached None if cached is None: # 2. 缓存未命中调用回调函数从数据库加载 data fallback_fn() if data is not None: # 3. 回写缓存设置过期时间 # self.redis.setex(key, ttl, json.dumps(data)) pass return data return cached # 缓存更新策略 class CacheUpdateStrategy: 缓存更新策略实现 Cache-Aside读时回写 写时删除最常用 Write-Through写操作同时更新缓存和数据库 Write-Behind写操作只更新缓存异步批量写入数据库 staticmethod def cache_aside_read( cache: RedisCache, key: str, db_query_fn, ttl: int ): Cache-Aside 读策略 return cache.get_with_fallback(key, db_query_fn, ttl) staticmethod def cache_aside_write( cache: RedisCache, db, key: str, data, delete_cache: bool True ): Cache-Aside 写策略先写数据库再删缓存 为什么不是先更新缓存 因为缓存更新可能有并发写入导致缓存数据不一致 删除缓存是最安全的方式——下次读请求会从数据库重新加载 # db.update(data) # 先更新数据库 if delete_cache: # cache.redis.delete(key) # 再删除缓存而非更新 pass三级缓存架构的核心思想是80% 的请求在 L1/L2 命中只有 20% 抵达数据库。每一层都有明确的适用场景——L1 处理热点、L2 处理共享、L3 处理持久化。四、边界分析与架构权衡缓存一致性困境缓存引入的最大风险是数据不一致。当多个请求同时写入时可能出现先请求的缓存更新被后请求的覆盖的情况。解决这个问题的标准做法是写操作不更新缓存而是删除缓存。让下一次读请求从数据库重新加载。但对于某些数据删除缓存可能导致缓存雪崩——大量请求同时未命中缓存全部涌入数据库把数据库压垮。缓解雪崩的策略给不同的缓存 key 设置不同的 TTL避免同时失效对热点 key 做预加载定时从数据库刷新到缓存使用本地缓存做 Redis 的热数据备份另一个重要的权衡是一个只有 30 个用户的刷题系统真的需要 Redis 吗答案是否定的。用户量达到数据库响应时间超过业务可接受阈值时才需要引入 Redis。在此之前本地缓存已经足够。不要为了用缓存而用缓存——缓存解决的是规模化问题而你的系统可能还没到规模化阶段。结论缓存是后端系统中最有效的性能优化手段也是出了 bug 最难排查的数据一致性问题的根源。使用缓存的核心原则是先确认缓存是必要的读请求确实多、计算代价确实大再设计缓存策略根据数据特征选缓存方式最后完善失效机制TTL 兜底 主动删除。对于小型刷题系统一个本地缓存Python 的 functools.lru_cache 或自定义的 TTL 字典可能已经足够。随着系统规模的增长逐步升级到 Redis再到分布式缓存。缓存架构应该随业务规模生长出来而不是一开始就设计成重型的三级架构。记住一个数字一次 Redis 查询约 0.5ms一次 MySQL 查询约 5ms。差距只有 10 倍。如果你的系统还没有到10 倍差距让你感受到疼痛的阶段缓存不是你的第一优先级。