上一篇确定了数据结构与访问模式的对应关系,本篇把其中一部分 key 明确定义为缓存。真正的问题不是“加不加 Redis”,而是允许旧多久、失效时允许多少并发回源、数据库更新后多久收敛。把缓存视为可重建的派生数据,才能为 TTL、空值、请求合并和失败降级给出一致答案。
一、痛点:TTL 不是随手填写的数字
缓存设计应先写服务等级:允许陈旧多久、源站峰值承受多少回源、空结果是否可信、故障时宁可旧读还是失败。TTL 太短会把流量重新压回数据库,太长会扩大陈旧窗口。若一百万个 key 都在整点过期,平均 TTL 看似合理,瞬时回源仍会击穿源站。因此固定基础 TTL 后增加随机抖动,例如 30 分钟加 0~5 分钟,使到期时间摊开。
Redis 的过期同时采用惰性删除与主动采样:访问已过期 key 时删除,后台也周期性清理。TTL 到点不代表内存会在同一微秒释放,但读取不会返回逻辑上过期的数据。SET key value EX seconds能原子写值和 TTL;先SET再EXPIRE会在进程中断时留下永不过期 key。更新已有 key 时还要注意某些写命令会清除 TTL,应以实际命令语义为准并测试TTL。
二、原理:穿透、击穿与雪崩是三种故障
缓存穿透是请求的数据根本不存在,每次都落到数据库。可缓存短 TTL 的“空标记”,但必须区分真实空值,并限制恶意随机 key;布隆过滤器适合先判断“一定不存在”,其假阳性意味着仍可能回源。缓存击穿是一个热点 key 到期,大量并发同时重建。进程内 singleflight 只能合并单实例请求,多实例可用短租约锁或逻辑过期。缓存雪崩则是大量 key 同时失效或 Redis 故障,需要 TTL 抖动、限流、熔断和源站容量共同防守。
Cache Aside 是最常用模式:读时先缓存,未命中读数据库再回填;写时先提交数据库,再删除缓存。选择删除而非直接更新,是因为数据库写入可能涉及多表派生,应用未必能计算出最终缓存值。它仍不是强一致:删除失败会留下旧值,读写交错也可能让旧查询结果回填。可通过重试队列、数据库变更日志、版本号和较短 TTL 缩短不一致窗口。需要强一致的数据不要只靠旁路缓存。
三、实现:抖动 TTL 与请求合并
下面可执行程序用线程模拟十个请求同时读取一个冷 key。条件变量保证只有一个加载者访问“数据库”,其他线程等待同一结果;TTL 抖动由稳定随机种子生成,输出可复现。生产中可直接使用 Gosingleflight、Java Caffeine 的原子加载或框架等价能力。
importrandomimportthreadingimporttime cache={}loading=set()condition=threading.Condition()database_reads=0defget_product(key):globaldatabase_readswithcondition:ifkeyincache:returncache[key]ifkeyinloading:condition.wait_for(lambda:keyincache)returncache[key]loading.add(key)time.sleep(0.02)value={"id":42,"stock":8}withcondition:database_reads+=1cache[key]=value loading.remove(key)condition.notify_all()returnvalue threads=[threading.Thread(target=get_product,args=("product:42",))for_inrange(10)]forthreadinthreads:thread.start()forthreadinthreads:thread.join()random.seed(42)ttls=[1800+random.randint(0,300)for_inrange(5)]print(f"database_reads={database_reads}")print(f"cached_stock={cache['product:42']['stock']}")print(f"jittered_ttls={ttls}")运行输出:
database_reads=1 cached_stock=8 jittered_ttls=[1857, 1812, 1940, 1925, 1914]下面脚本演示原子写 TTL、空标记与只在不存在时获取重建租约。租约 value 使用随机令牌,释放时不能裸DEL:持有者超时后,锁可能已被别人获取,裸删会误删他人的租约。这里用 Lua 比较令牌再删除。
#!/usr/bin/env bashset-euopipefailredis_url="${REDIS_URL:-redis://127.0.0.1:6379/0}"cache_key='demo:cache:product:42'miss_key='demo:cache:product:404'lock_key='demo:cache:lock:product:42'token="demo-$$"redis-cli-u"$redis_url"DEL"$cache_key""$miss_key""$lock_key">/dev/null redis-cli-u"$redis_url"SET"$cache_key"'{"id":42,"stock":8}'EX60>/dev/null redis-cli-u"$redis_url"SET"$miss_key"'__NULL__'EX10>/dev/nullacquired="$(redis-cli-u"$redis_url"--rawSET"$lock_key""$token"NX PX5000)"test"$acquired"=OKttl="$(redis-cli-u"$redis_url"--rawTTL"$cache_key")"test"$ttl"-gt0lua='if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end'released="$(redis-cli-u"$redis_url"--rawEVAL"$lua"1"$lock_key""$token")"printf'cache_ttl_positive=%s\n'"$(["$ttl"-gt0]&&printftrue)"printf'miss_marker=%s\n'"$(redis-cli-u"$redis_url"--rawGET"$miss_key")"printf'lease_released=%s\n'"$released"redis-cli-u"$redis_url"DEL"$cache_key""$miss_key">/dev/null四、取舍:一致性必须写成可观察的流程
逻辑过期把业务过期时间放进 value:读取发现过期后,只有一个请求异步刷新,其余请求继续拿旧值。这能保护高热 key,却明确牺牲新鲜度,适合商品详情,不适合余额。物理 TTL 仍要保留为兜底,防止永不访问的数据常驻。双层缓存还要考虑本地缓存与 Redis 的两层失效,通常用消息通知加短本地 TTL,而不是假设广播永不丢失。
空值缓存的 TTL 应短于正常值,否则新建的数据会长时间不可见。key 必须先做规范化和权限判断,不能让不同租户共享同一缓存项。序列化格式要带版本,部署新字段时采用向后兼容读取;压缩能省网络与内存,却消耗 CPU,并让局部更新更困难。热路径先测量 value 大小再决定。
不要把 Redis 故障自动转化为数据库故障。客户端应设置毫秒级连接、命令超时和有上限的重试;只读缓存失败可降级回源,但必须经并发限制。回源信号量、请求队列长度、缓存命中率和源站延迟应同时监控。命中率高也可能掩盖少数昂贵查询,最好按接口与 key 类别分组。
五、验证:故障演练比命中率更诚实
测试应包含冷启动、热点到期、Redis 超时、数据库慢查询、删除缓存失败、空值转为真实值和部署期间序列化兼容。用并发压测确认一次失效产生多少次数据库读取;把 TTL 分布画成直方图,确认没有整点尖峰。对 Cache Aside 的关键更新链路建立“数据库提交成功但缓存删除失败”的补偿指标,并让补偿幂等。
缓存容量规划使用实际序列化后的字节数和 key 数,不要只算业务字段。观测keyspace_hits与keyspace_misses时注意它们是实例累计值,需要按时间求增量。慢日志、延迟监控和源站 QPS 能帮助判断优化究竟发生在哪一层。
本篇把 TTL 和重建并发变成可验证策略,但某些 key 天生比其他 key 热、某些 value 会不断膨胀。下一篇专门识别和治理热 key、大 key,避免单个对象拖慢整个实例。
发布后还应抽样比较缓存值与权威库的版本或摘要,形成陈旧率指标。命中率回答“有没有”,陈旧率才回答“对不对”;二者与回源耗时一起看,才能判断策略是否真正改善用户体验。
参考来源
- Redis 官方文档:客户端侧缓存模式
- Redis 官方文档:EXPIRE
- Redis 官方文档:SET
👍 觉得有用就点个赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。
🚀 本文属于《Redis 应用实战》系列,持续更新,关注不迷路。
📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。