全栈进阶-redis入门实战概念篇
第一阶段:redis基础
1. 简介
Redis是一款开源的、基于内存的键值对数据库,支持将内存持久化到磁盘,还提供了丰富的数据结构、事务、发布订阅等功能,被广泛的用于缓存、消息队列、会话存储等场景。
作为一个前端开发,对于Redis第一影响就是读写操作非常的快,常用于一些需要快速读写数据的场景,比如存储会话session。Redis之所以这么快,在于Redis利用了内存操作、IO多路复用、避免线程切换开销三大核心优势,让单线程也足以支撑超高并发。
Redis并非纯单线程,只是在接受客户请求的核心处理流程是单线程的,在处理慢操作,比如持久化读写磁盘、异步删除大键、主从复制的网络同步,会启动多个辅助线程。为啥当初Redis不是设计成多线程呢,主要是单线程设计简单,核心逻辑都是串行执行的,后续的维护成本极低,同时也避免了多线程的死锁啊,数据一致性啊这些麻烦的问题;内存的操作足够快,多线程必然会涉及到线程切换和锁竞争,这些都会降低效率;IO的多路复用,Redis运行在网络层,使用的基于Unix系统的IO多路复用机制,就是主线程通过事件循环来监听所有客户端的IO操作,维护一个事件队列去处理IO操作,这种单线程非阻塞的IO多路复用让Redis可以同时管理上万的TCP连接。
2.redis数据基本结构
Redis基本数据结构主要五个,接下来挨个介绍下
首先安装下环境:
pip install redis
先创建一个虚拟环境,然后安装相应的依赖
import redis
# 连接到你的线上 Redis
r = redis.Redis.from_url(
"redis://:xxx",
decode_responses=True, # 返回 str 而不是 bytes
)
# 设置一个 study:string 的 key
r.set("study:string", "hello redis from python")
# 读出来验证一下
value = r.get("study:string")
print("study:string =", value)
2.1 String
String是Redis最基础、最常用的数据结构,所有的键值对的value本质上都可以使用String来存储,单key最大容量512M,所有的操作都是原子性的,支持位运算和过期策略。
比如前面的案例r.set("study:string", "hello redis from python"),就是设置一个字符串
如果要设置一个过期时间的话,也比较简单
r.set("study:string", "hello redis from python", ex=60),这里的ex就是秒数,如果是px就是毫秒数,这里设置的时间就表明key的过期时间,如果过期了key就会被删除。
2.1 Hash
Hash是一个键对应多个键值对的结构,类似于Map和字典,一般用来存储结构化的对象。
r.hset("study:hash", mapping={
"name": "张三",
"age": "20",
})
data = r.hgetall("study:hash")
print(data) # {'name': '张三', 'age': '20'}
如果要删除hash中的指定字段的话,可以使用这个方法hdel
r.hdel("study:hash", "age")
2.3 List
这里的List是按照插入顺序排序的字符串集合,支持两端搞笑增删,中间查询稍慢,是一个双向链表。
# 从右侧依次塞入几个元素
r.rpush("study:list", "apple", "banana", "orange")
# 从左侧再塞一个
r.lpush("study:list", "pear") # list 现在是: ["pear", "apple", "banana", "orange"]
# 读取整个 list(0 到 -1 表示所有元素)
items = r.lrange("study:list", 0, -1)
print("study:list =", items)
# 弹出一个元素(比如从左边弹)
left = r.lpop("study:list")
print("lpop 之后取出的 =", left)
print("剩下的 =", r.lrange("study:list", 0, -1))
可以用来存储一些任务队列和消息队列
2.4 Set
Set 是无序、元素唯一的字符串集合,支持集合间的交、并、差运算,适合处理 “去重” 和 “关系匹配” 场景。
# 往 set 里加元素(去重)
r.sadd("study:set", "apple", "banana", "orange")
r.sadd("study:set", "banana") # 再加一次不会重复
# 查看所有成员
members = r.smembers("study:set")
print("study:set =", members)
# 判断某个值是否在 set 中
print("是否包含 apple:", r.sismember("study:set", "apple"))
# 删除一个成员
r.srem("study:set", "banana")
print("删除 banana 后 =", r.smembers("study:set"))
# 给整个 set 设置过期时间 60 秒
r.expire("study:set", 60)
2.5 ZSet
ZSet 是 Set 的升级版,每个元素关联一个 “分数(score)”,Redis 会按分数从小到大排序,兼具 唯一性 和 有序性。
# 往 zset 里加数据:成员 + 分数
r.zadd("study:zset", {
"Alice": 100,
"Bob": 80,
"Charlie": 95,
})
# 按分数从小到大取出所有成员
print("从小到大:", r.zrange("study:zset", 0, -1, withscores=True))
# 按分数从大到小取出前 2 名
print("从大到小前2名:", r.zrevrange("study:zset", 0, 1, withscores=True))
# 给某个人加分(比如 Alice +10)
r.zincrby("study:zset", 10, "Alice")
print("Alice 加分后:", r.zrevrange("study:zset", 0, -1, withscores=True))
# 删除一个成员
r.zrem("study:zset", "Bob")
print("删除 Bob 后:", r.zrange("study:zset", 0, -1, withscores=True))
有一个常见的面试题,Hash和String都可以用来存储对象,一般用那个来存储对象呢,使用String来存储对象,简单直观,但是它不支持局部更新,改一个字段需要覆盖这个字符串,适合一些整体读写、字段少的场景;Hash存储对象,他就支持局部更新,适合一些复杂对象的存储,比如高频更新字段。
3. redis基本命令
因为Redis都是键值对的存储,所以他的方法也很简单,看下面这个例子:
# 1. SET:设置一个字符串 key
r.set("study:string", "hello")
# 2. GET:读取这个 key
print("GET study:string =", r.get("study:string")) # hello
# 3. INCR:自增一个数值型 key
# 如果这个 key 不存在,会从 0 开始加 1,变成 "1"
r.delete("study:count") # 为了方便测试,先删掉
r.incr("study:count") # 当前值 1
r.incr("study:count") # 当前值 2
r.incr("study:count", 5) # 加 5 -> 当前值 7
print("study:count =", r.get("study:count")) # 7
# 4. EXPIRE:给 key 设置过期时间(单位:秒)
r.expire("study:count", 5) # 5 秒后过期
读、写、自增和设置过期时间,都比较简单。
因为Redis都是键值对,没有表的概念,所以Key管理就成了问题,社区有一个约定的规范:业务标识:模块名称:唯一标识[:子字段],比如ecom:user:1001:name,这就是电商业务:用户模块:用户id:用户名。还有一些额外的补充规范:
- 统一小写:避免大小写混乱,
User:1和user:1是两个key - 简洁且语义化:看到名称基本就能了解存储的内容
- 避免特殊字符:比如空格、换行符、下划线
第二阶段:redis核心机制
4.redis内存模型
Redis是一个内存数据库,大多数数据都保存才内存中。它的内存可以分为两大部分,核心内存和辅助内存。其中核心内存存储的就是我们常用的键值对,也就是key内存和value内存,存储的都是我们所用到的数据;辅助内存放的都是非业务数据,就是Redis运行所需的额外内存,比如一些过期字典、进程本身的开销等。
Redis有一套完善的内存管理机制,主要有这么几步
- 基于
jemalloc内存分配,将内存划分为不同大小的内存页,比如8B,16B,32B等,分配时匹配最接近的页,减少碎片;线程缓存,减少锁竞争,提升分配效率 - 内存回收:内存回收主要有两种,惰性删除和定期删除。惰性删除指的是访问key时检查是否过期,过期了就删除;定期删除,每100ms随机抽查过期的key,去删除已经过期的,但是这里有个问题,如果key过期了,但是没有被抽查到呢,为啥不扫描全量的key呢,这就是一个平衡了,全量扫描需要占用大量的CPU,会影响到业务的,这个就叫做延迟回收,也就是说可能一时半会回收不了,但是终归会被回收。
Redis的内存处理机制天然就有一种滞后性,可能就会出现内存满了的情况,这里的内存满了,并不是设置某个key的value大小超过512M,而是Redis进程占用的内存满了,这里的满有两个意思:主动设置的maxmemory,这个在生产环境上是必须要设置的
maxmemory 4GB # 限制 Redis 最大使用内存为 4GB
还有一种满就是,如果不设置这个最大值,Redis就会无限制的占用服务器的物理内存,直到耗尽服务器所有可用的物理内存,这个时候操作系统会将Redis的内存数据交换到磁盘的swap分区,这个是磁盘模拟的内存,速度巨慢,最终导致Redis性能暴跌,也可能因为服务器内存耗尽而被系统杀死。
当内存满了后,Redis也有一套内存淘汰策略来处理这种情况,当Redis占用的内存超过设置的maxmemory后,然后再去执行写操作,就会去触发我们的内存淘汰策略,主要有这么几种策略:
-
LRU
最近最少使用,就是淘汰那些最近访问次数最少的key,标准的LRU需要维护一个访问时间链表,内存和cpu开销大,
Redis实现的是近似LRU:维护一个候选池,触发淘汰时,从目标范围随机抽取key,也就是触发淘汰时,随机抽取一批key,然后比一比谁的访问时间最远,然后就淘汰它。 -
LFU
优先淘汰访问频率最低的key,
Redis实现的LFU并不是简单的访问次数统计,而是通过概率递增的访问计数+时间衰减机制来近似的反应key的长期访问价值;在触发淘汰时,在通过随机采样的方式选择访问评率最低的key去淘汰。
当内存满了后,再去对数据库做读写操作,读的操作没有影响,但是在触发写的操作时,如果内存满了,会先根据maxmemory-policy设置的内存淘汰策略,在写操作触发的同时根据LFU、LRU去更新内存,直到内存会到安全区;如果内存淘汰策略味为noeviction或者无法淘汰,直接回报错。
5.持久化机制
前面也提到了,Redis是一种基于内存的键值数据库,内存的特性就是在服务器重启后会全部丢失,这就需要将数据做持久化,即使服务器重启了,也可以从磁盘中恢复数据至内存。
Redis提供了两种核心的持久化方式:RDB和AOF,快照持久化和追加文件持久化,接下来挨个介绍下:
快照持久化
RDB是定时对Redis内存中的全量数据做一次拍照,生成一个压缩的二进制文件,比如dump.rdb,保存到磁盘的指定目录。Redis重启时直接加载这个二进制文件,将数据恢复到内存中。
RDB有手动触发和自动触发两种方式:手动触发可以使用save来同步触发,同步触发会阻塞主进程,直到RDB文件生成完成,异步触发通过bgsave来触发,Redis会fork一个子进程来执行RDB文件的生成,主进程会继续处理客户端的请求;自动触发是在配置文件redis.conf中通过快照规则来配置的,满足条件就会自动执行bgsave
save 900 1 # 900 秒内至少 1 次写
save 300 10 # 300 秒内至少 10 次写
save 60 10000 # 60 秒内至少 10000 次写
这里就是自动触发RDB的规则:满足其中的任意条件就会触发一次,比如60s内写一次、300s内写10次等。
RDB优点就在于性能开销小,生成RDB由子进程负责,主进程仅做fork操作,几乎不影响业务;二进制文件直接加载到内存速度也很快。但是缺点也很明显,RDB是定时快照,如果Redis意外崩溃,比如服务器断电,就会丢掉最后一次快照前到崩溃前的所有数据。
追加日志持久化
AOF就是为了解决RDB数据库丢失而设计的持久化方式,就是将Redis的操作日志按照顺序记录下来,重启后通过重放AOF文件中所有的写命令去恢复内存数据。默认是关闭的,需要appendonly yes命令来手动重启。
AOF的相关配置在redis.conf文件中
appendonly yes # 开启AOF(默认no,关闭)
appendfilename "appendonly.aof" # AOF文件名,默认保存在Redis工作目录
dir ./ # 持久化文件(RDB/AOF)的保存目录,默认是Redis启动目录
AOF主要分为三个步骤:
-
命令追加
Redis执行完一个写命令后,会将该命令按照协议追加到内存中的AOF缓冲区,避免直接写入磁盘,减少IO开销 -
文件写入
Redis会定期将AOF缓冲区的数据写入到内核页缓存,这个操作是调用操作系统的write方法,属于异步操作,不会阻塞主线程。 -
文件同步
将内核页缓存中的AOF数据写到测盘中,这个是调用的操作系统的同步方法,会阻塞主线程的,直到刷盘完成。
将AOF缓冲区中的命令刷到磁盘的AOF文件中,有三种策略:
# appendfsync 有三个取值: appendfsync always # 每次写命令都立即刷盘(同步),数据最安全,性能最差 appendfsync everysec# 每秒刷盘一次(默认值),平衡数据安全和性能 appendfsync no # 由操作系统决定何时刷盘,性能最好,数据丢失风险最高
由于AOF是日志追加的形式,会产生大量的中间态,比如set key 1 → set key 2 → set key 3 ,这种中间态其实是没有意义的,还会导致AOF文件变得很大,这就需要AOF重写机制了,重写就是遍历内存中的所有的数据,根据当前的键值对生成一套最简的写命令集来替换原有的AOF文件,重写的触发也分为手动触发和自动触发:手动触发需要执行bgrewriteaof命令;自动触发是通过配置文件,当文件的体积增长到达阙值时,自动触发`bgrewriteaof:
auto-aof-rewrite-min-size 64mb # AOF文件的最小体积,低于这个值不触发重写(默认64mb)
auto-aof-rewrite-percentage 100 # 重写触发的百分比,指当前AOF文件体积比上一次重写后的体积增长了多少(默认100%)
AOF的优点就是,可以通过刷盘策略来控制数据丢失的风险,默认的everysec仅丢失1s的数据,alway几乎无丢失;缺点就是AOF文件体积较大,恢复数据时加载较慢。
混合持久化
RDB和AOF单独使用都各有优缺点,在Redis 4.0之后,引入了混合持久化机制,融合恶RDB和AOF的优点,成为了目前生产环境的首选方案。
在redis.conf配置文件中开启混合持久化:
aof-use-rdb-preamble yes # 开启混合持久化(Redis 4.0+,默认no;Redis 6.0+ 部分版本默认yes)
开启后,AOF文件就不再是纯文本了,头部就成了RDB格式的全量数据快照,也就是二进制文件,尾部是AOF格式写的增量命令,记录从生成RDB快照到当前的所有写命令,是纯文本。
其工作流程主要有这么几个步骤:
当AOF触发重写时,
- redis主进程进入fork子进程,执行AOF重写
- 子进程首先将内存中的全量数据以RDB格式写入到临时的AOF文件头部
- 子进程完成RDB写入后,主进程将AOF重写缓冲区中所有的增量写命令,以AOF格式写入到临时的AOF文件尾部
- 主进程用临时AOF文件替换掉旧的AOF文件,完成混合持久化的重写。
混合持久化的优点就在于加载速度快,数据丢失风险小,而且文件的体积也不会很大。
下面推荐一个常见的生产环境的配置,开启混合持久化+RBD默认自动快照:
# ===================== RDB 核心配置 =====================
save 900 1
save 300 10
save 60 10000
rdbcompression yes # 开启RDB压缩
dbfilename dump.rdb # RDB文件名
dir ./ # 持久化文件存储目录(建议修改为独立的磁盘目录)
# ===================== AOF 核心配置 =====================
appendonly yes # 开启AOF(混合持久化的前提)
appendfilename "appendonly.aof" # AOF文件名
appendfsync everysec # 刷盘策略,生产首选
auto-aof-rewrite-min-size 64mb # AOF重写最小体积
auto-aof-rewrite-percentage 100 # AOF重写增长百分比
aof-use-rdb-preamble yes # 开启混合持久化(Redis 4.0+)
aof-load-truncated yes # 加载AOF时,若尾部损坏则忽略,继续加载(默认yes)
6. redis事务
Redis事务就是提供一种机制,将多个Redis命令打包成一个执行单元,保证这个单元内的命令会按照顺序、无中断的执行,同时支持对命令执行结果的统一处理,解决多命令批量执行的原子性需求。
Redis事务只依赖五个命令:
- MULTI 标记事务开始,后续所有的命令都会加入到事务队列中
- EXEC 执行事务队列中的所有命令,执行完成后结束事务,返回所有命令的执行结果
- DISCARD 放弃事务队列中的所有命令,清空队列结束事务,回到正常的执行模式
- WATCH KEY 对key加乐观锁,监控key是否修改,必须在
MULTI之前修改 - UNWATCH 取消所有被watch监控的key,事务取消或者执行后会自动执行
看下这个最基础的实务流程:
MULTI
SET balance 100
INCR balance
EXEC
执行到MULTI时,会进入事务状态,后续的SET、INCR会被放入到一个事务队列中,直行到EXEC时才会执行队列中的所有的命令。
传统的关系型数据库事务严格遵循ACID原则,原子性、一致性、隔离性和持久性,但是Redis事务为了极致的性能,并不是完全遵循ACID原则。接下来介绍下他的区别:
-
原子性
原子性的定义就是事务中的所有的操作,要么全部执行,要么全部不执行,不会出现部分执行的情况,而
Redis事务的原子性分为两种情况:1) 事务入队前出错,全不执行:当在MULTI后,EXEC前出现语法错误,
Redis会立即返回错误,执行EXEC时会直接放弃整个事务2) 执行事务中出现错误,部分执行,没有回滚,命令入队时只会做语法检查,不会做逻辑检查,执行时如果出现了运行错误,
Redis就会跳过这个命令,继续执行后续的命令,而且不会对已经执行的命令做回滚不支持回滚主要也是从性能考量,实现回滚需要记录每个命令的逆操作,比如SET的操作就是恢复原值,这个会增加
Redis内核的复杂度,牺牲执行的性能。 -
一致性
一致性就是事务执行的前后,数据库的状态始终保持合法,不会因为事务的执行而出现脏数据。
Redis事务可以在所有的异常情况下,比如入队错误、执行错误、宕机,都可以保证数据的一致性。 -
隔离性
隔离就是在多个事务并发执行时,一个事务的执行不会被其他的事务干扰,各个事务之间相互隔离。
Redis是单线程处理客户端请求的,这就会导致事务的执行会按照队列中的顺序连续执行,不会被其他的命令打断 -
持久性
持久性是指事务执行成功后,对数据的修改会被永久的保存到磁盘中,不会应为宕机而丢失。
Redis事务本身并不保证持久性,持久性是由Redis的持久化机制来实现的,前面也介绍过
接下来写一个小的demo,利用watch来控制库存防止超卖
def try_purchase(stock_key: str, user: str, qty: int = 1) -> bool:
"""使用 WATCH + 事务进行扣库存,避免超卖。
乐观锁思路:
1. WATCH 库存 key,监听是否被别人改动;
2. 读当前库存,判断是否足够;
3. 使用 MULTI 开启事务,扣减库存;
4. EXEC 提交,如果在这期间库存被别人改了,EXEC 会失败(抛 WatchError),然后重试。
"""
with r.pipeline() as pipe:
while True:
try:
# 1. 监听库存 key
pipe.watch(stock_key)
# 2. 读取当前库存
current = pipe.get(stock_key)
if current is None:
print(f"{user}: 商品不存在")
pipe.unwatch()
return False
current = int(current)
if current < qty:
print(f"{user}: 库存不足,当前库存={current}")
pipe.unwatch()
return False
# 3. 开启事务,扣减库存
pipe.multi()
pipe.decrby(stock_key, qty)
# 4. 提交事务
pipe.execute()
print(f"{user}: 抢购成功,扣减 {qty},扣减前库存={current}")
return True
except redis.WatchError:
# 在 WATCH 之后、EXEC 之前,有其他客户端修改了 stock_key,
# 这次事务会失败,需要重试。
print(f"{user}: 检测到并发冲突,重试中...")
continue
第三阶段:高并发&分布式
7. 缓存模式与一致性
Redis作为缓存的核心亮点就在于其高速的读写操作,来降低传统数据库的压力,基于此Redis推出了有大概四种主流的缓存策略,来将缓存融入业务读写流程,同时保证缓存与数据库的一致性。接下来挨个介绍下:
-
缓存穿透模式
缓存和数据库分离,业务代码主动管理缓存和数据库的交互。在读操作时,先查询缓存,如果命中就直接返回,如果没有就查询数据库,同时将数据库的结果写入缓存;在写操作时,先更新数据库,在删除缓存。
这种模式适合绝大多数的生产场景,是
Redis作为缓存的首选模式。优点就是简单易实现,缺点就是需要额外处理缓存穿透、击穿雪崩等场景。 -
读写穿透
业务代码只和缓存交互,不直接操作数据库,缓存作为中间层,主动管理数据库的读写。在读操作时,先查询缓存,如果命中就直接返回,未命中就查询数据库,将结果写入缓存,然后返回;写操作就更新缓存,然后再去更新数据库。
这种模式的特点就是业务代码只专注于业务,数据库由缓存层来处理,简化了业务代码逻辑,缺点就是缓存层需要额外的代码开发,而且不支持新增数据,因为新增数据要先执行读操作,才能存入缓存,不太符合常规的业务逻辑。
-
写穿模式
这种模式是读写穿模式的增强版,支持新增、更新数据。在读操作时,和读写穿透模式一样,命中返回,未命中查库更新缓存;写操作时和新增数据时,缓存同步更新数据库,然后返回给业务。
这种模式的特点在于写操作时,缓存和数据库同步更新,缓存和数据库有着非常强的一致性,常用于支付业务的核心数据缓存。
-
写回模式
这种模式是写穿模式的异步版,差异就在与写操作时是异步的。在读操作时,和读写穿透、写穿模式一样,命中就返回,未命中查库更新缓存后返回;在新增和更新的写操作时,缓存立即返回,然后异步去更新数据。
这种模式适合并发高、一致性要求较低的场景,比如日志缓存等。
生产模式中比较常用和推荐的,就是缓存穿透模式,然后这种模式在一致性的问题上需要额外处理。比如有这么几个场景:
- 在写操作时,正常的流程是,更新数据库,然后删除缓存,更新缓存需要下次读的时候去查库更新。但是如果更新数据库后,遇到宕机或者网络异常,就会导致缓存未及时删除,这就出现了脏数据,知道缓存过期。这也是最常见的不一致场景,属于操作中断导致的缓存未更新。
- 在并发的场景下,客户端A和客户端B同时分别执行读写操作,A读操作时,缓存未命中就会去查库,B写操作时更新完数据库后,回去删除缓存,这时如果读操作的写缓存的动作晚于写操作的删除动作,就会产生数据库与缓存的不一致场景,数据库是新的数据,缓存中是旧的脏数据。
在数据一致性上有这样一个原则,最终数据一致性即可,而非强制一致性。Redis作为缓存,是没有办法实现和数据库的强一致性。因为缓存和数据库属于两个独立的存储系统,非要强一致性就需要加锁,这就会牺牲Redis的高性能,而且在实际业务中,带短暂的不一致,对于用户来说并没有感知的。
在缓存穿透模式中,有这么几个方案可以解决缓存与数据库的一致性问题
-
单实例低并发场景,在一些后台管理,小流量业务汇中,可以直接使用缓存穿透模式的基础逻辑,即读操作时先缓存后数据库,如果不存在就写一个缓存空值,加上过期时间;在写操作时,先更新数据库,在删除缓存,给所有的缓存加上过期时间。
这里设置缓存空值,就是为了防止缓存杀手-穿透,比如查询一个数据库没有的值,这就会直接访问数据库,万一遇到恶意访问的脚本,就会导致数据库压力;而设置一个空值,在过期时间内,他是一个有效的缓存,虽然没有值,但减轻了数据库的压力,算是为了系统的稳定性做了一次兜底。
-
单实例高并发,在电商的商品详情、商品秒杀库存业务中,在基础方案上增加延迟删除缓存,来解决
读操作写缓存晚于写操作删缓存的问题。 流程就是在写操作执行更新数据库后,延迟N毫秒删除缓存,让读操作查库、写缓存的动作先完成。
还有一个常见的八股文:缓存的三大杀手,穿透、击穿和雪崩。
-
穿透,在前面的穿透模式时介绍过了,就是查询一些数据库中不存在的值时,每次都会去查询数据库,导致缓存失效,数据库增加额外的压力。解决方案有下面几种:
1)设置空值,前面也介绍过,这是最简通用的方案
2)布隆过滤器,提前将数据库中所有的合法key存入过滤器,请求先过过滤器,判定不存在就会直接拒绝,就走不到缓存和数据库了
3)IP/接口 限流熔断,对于穿透请求高频的IP限流,对查询接口做熔断保护。这里的限流就是限制单位时间内允许请求的数量,比如单位时间内某个IP大量请求不存在的ID,加了限流之后直接回报错429错误码,就走不到缓存、数据库了;熔断就是当下游服务器持续失败或者过慢时,暂时切断请求,防止雪崩扩散。
-
击穿,某个极高的热点key,恰好过期或者被删除,此时大量并发请求同时访问该key,全部缓存未命中,所有的请求都会访问到数据库。这里的解决方案有:互斥锁串行重建缓存,热点key永不过期,热点key主动更新
-
雪崩,大量缓存key在同一时间集体过期,或者
Redis缓存集体宕机,导致请求绕过缓存直接访问数据库,导致数据库负载瞬间爆表,引发整个服务链路雪崩。解决方案有:过期时间随机化,Redis集群高可用,服务限流、熔断、降级。
8. 分布式锁
在分布式、微服务架构中,一个服务会运行在多台机器上,这就是多进程的概念,多台机器会共享一个资源,这个时候python的线程锁就会失效,因为这些锁的作用范围是当前进程的内存,只能管自己进程内的线程。这时就需要分布式锁了,分布式锁就是跨进程、跨服务的锁,要保证多个进程对共享资源的互斥访问。
分布式锁有四个核心的特性:
- 互斥性,同一时间只有一个客户端持有锁,其他的客户端必须等待
- 安全性,锁只能被其持有者释放,不能被其他客户端误删
- 避免死锁,即使持有锁的客户端崩溃、中断,也可以在一定时间后自动释放
- 可用性,
Redis集群环境下,锁服务不能单点故障,要保证大部分节点可用
Redis做分布式锁的优点,就在于其性能极高,获取、释放锁都是毫秒级,而且部署也比较简单。
接下来介绍下Redis单节点分布式锁的几个命令:
- key 锁的唯一标识,比如要给ID=111的资源加锁
- value 客户端的唯一标识,保证锁只能被持有者释放
- NX 全程
Not Exist,只有当key不存在时才会设置成功 - EX/PX 设置的过期时间,EX单位是秒,PX单位是毫秒
- timeout 锁的过期时间,避免死锁
比如这行代码:
SET lock:order:123 uuid:192.168.1.100 NX EX 30
就表明给资源key为lock:order:123的资源加锁,锁的持有者是uuid:192.168.1.100,30秒后过期
而释放锁,就回到刚刚的安全性了,必须只有锁的持有客户端才可以释放。流程就是先判断加锁的客户端是不是自己,如果是才可以去释放,看下相关的Iua脚本:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1]) -- 标识匹配,删除锁
else
return 0 -- 标识不匹配,不做任何操作
end
之前写过一个卖票的函数,就是使用Redis的分布式锁来控制库存,防止超卖:
async def sell_one_with_lock(r: Redis, window_name: str) -> bool:
"""使用 Redis 分布式锁保护“检查+扣减”关键区,成功卖出返回 True,售罄或失败返回 False。"""
lock = r.lock(LOCK_KEY, timeout=5, blocking_timeout=1) # 超时时间与获取等待时间可调
acquired = await lock.acquire(blocking=True)
if not acquired:
# 未拿到锁,视为本次卖票失败(可重试)
return False
try:
# 关键区:读取剩余、判定、扣减
remaining_str = await r.get(TICKET_KEY)
remaining = int(remaining_str) if remaining_str is not None else 0
if remaining <= 0:
return False
# 扣减一张(原子自减命令)
await r.decr(TICKET_KEY)
return True
finally:
try:
await lock.release()
except Exception:
# 若锁已过期或其他异常,忽略释放错误
pass
代码第一行就创建了一个分布式锁,传入了一个过期时间防止死锁,lock.acquire是真正的加锁步骤。之前看到这里有个疑惑:每次调用这个方法,都会创建一个分布式锁,如何保证对一个资源加锁,在创建锁的时候传入了LOCK_KEY,这个就是要加锁的key,也就是要加锁的资源,这个方法每次执行都会创建一个锁,但是lock.acquire在资源没有释放的时候,返回的是false,也就是会走到if not这里的。
其实后续章节还有单点故障,主从、哨兵、 Redlock、 扩容、数据分片等,觉得分布式、缓存还需要消化下,后面就不在深入了,打算进入实战环节了,后续打算设计三个实战项目来进一步深入的学习下。
三个实战项目分别是
- 信息查询系统,使用
MySQL存储用户信息,Redis作为缓存,巩固下前面学习的缓存模式,同时加上压测环节,通过QPS,响应时间更加直观的了解缓存的意义 - 抢票系统,学习下Lua脚本,
- 秒杀系统,学习下高并发处理、限流、防超卖策略