切换日光/暗黑模式
056. 缓存失效与 Redis 分布式锁
学习目标
这一节讨论缓存失效后的并发问题,并用 Redis 实现分布式锁。
学完后,你应该能理解:
- 为什么缓存命中后不会查数据库;
- 什么是缓存击穿;
- 为什么缓存集中失效会压垮数据库;
- 后端为什么不能只靠普通变量排队;
- Redis 的
NX能解决什么; - 释放锁为什么不能直接
delete; - Lua 脚本为什么能保证原子操作。
缓存命中的效果
第一次查询某个用户信息时,如果 Redis 没有缓存,就会查 MySQL。
查到之后写入 Redis。
第二次再查询同一个用户,如果缓存还没过期,就直接从 Redis 返回。
这时不会再执行 SQL。
这就是缓存提高性能的直接体现。
缓存失效的问题
缓存总会失效。
常见原因是:
- key 过期;
- key 被删除;
- 服务刚启动还没预热;
- 缓存里本来就没有这个数据。
缓存失效后,请求会打到数据库。
如果同一时间大量请求访问同一个热点数据,数据库压力会突然变大。
缓存击穿
缓存击穿指的是某个热点 key 失效后,大量并发请求同时去查数据库。
例如秒杀活动开始时,很多用户同时请求同一个商品信息。
如果缓存刚好没了,所有请求都穿过缓存,直接打到数据库。
这会造成很大的瞬时压力。
缓存集中失效
如果很多 key 在同一时间过期,也会形成压力峰值。
例如早上大量用户登录后,缓存都设置为两小时过期。
到了中午,这批缓存一起失效。
数据库请求就会像波峰一样突然升高。
常见缓解方式是给过期时间加随机偏移,让缓存分散过期。
热点数据永不过期
一种处理方式是让热点数据不过期,或者启动服务时主动预热。
例如服务启动后立刻把关键配置写入 Redis。
这种做法适合部分场景。
但它也带来数据更新和内存占用问题。
所以项目里更常见的是结合过期时间和锁机制。
为什么需要分布式锁
前端刷新 token 时,可以用一个 Promise 队列让后续请求等待。
后端不一样。
后端可能有多个线程、多个进程,甚至多台服务器。
普通变量只能约束当前进程,无法约束其他服务实例。
所以要借助外部系统实现互斥。
这里使用 Redis 做分布式锁。
锁的目标
多个请求同时要加载同一个资源时,只允许一个请求去查数据库。
其他请求等待。
第一个请求查完数据库并写入缓存后,其他请求再从缓存读取。
这样数据库只承受一次查询,而不是一批并发查询。
Redis NX
Redis 设置 key 时可以使用 NX。
它的含义是:只有 key 不存在时才设置成功。
用它可以表达“抢锁”:
txt
set lock:user:zhangsan taskA nx ex 10如果设置成功,说明当前任务拿到了锁。
如果设置失败,说明锁已经被别的任务占用。
锁要有过期时间
锁必须设置过期时间。
否则拿到锁的任务如果崩溃,锁会一直留在 Redis 里。
后续所有请求都会一直等待。
过期时间是兜底机制。
它不能太短,否则任务还没结束,锁就提前过期。
它也不能太长,否则异常后等待时间过久。
等待重试
没有拿到锁的请求不应该立刻放弃。
它可以循环等待一小段时间,再重试获取锁。
等待间隔不能为 0。
如果不等待,就会空转 CPU。
例如每次等待 0.1s,再尝试获取锁。
具体间隔要根据业务接口耗时调整。
释放锁的风险
释放锁不能简单地直接删除 key。
原因是任务可能执行太久。
例如:
- A 拿到锁,锁 10 秒过期;
- A 执行 11 秒;
- 10 秒时锁自动过期;
- B 拿到同一个锁;
- A 执行完后直接删除锁;
- B 的锁被 A 删掉。
这会破坏互斥关系。
锁标识
锁的 value 要存当前任务自己的标识。
释放锁时必须先判断:
- Redis 里的锁标识是不是我自己的;
- 如果是,才释放;
- 如果不是,不处理。
这样 A 就不会删除 B 的锁。
为什么需要 Lua
判断锁标识和删除锁必须是一个原子操作。
不能先 get,再在代码里判断,最后 delete。
因为这三个步骤之间有网络传输和时间间隔。
中间可能插入其他操作,导致判断结果过期。
Redis 支持执行 Lua 脚本。
Lua 脚本在 Redis 内部执行,可以把读取、判断、删除放在同一个原子操作里。
Lua 释放锁逻辑
释放锁的逻辑可以理解为:
txt
如果 lock key 当前值等于我的 lock token:
删除 lock key
否则:
什么都不做这个逻辑用 Lua 在 Redis 里一次执行完成。
这样就不会因为网络间隔误删别人的锁。
这一节的重点
这一节的核心是缓存失效后的并发控制。
你需要记住:
- 缓存失效时可能出现大量并发查库;
- 热点 key 失效会形成缓存击穿;
- 过期时间集中会形成请求波峰;
- 多实例后端要用分布式锁;
- 获取锁可以用 Redis
NX; - 释放锁必须校验锁标识,并用 Lua 保证原子性。