Redis

1745483914895.png

单体下一人多单超卖问题

image-20230710225259092

原因

线程 1 查询库存,发现库存充足,创建订单,然后准备对库存进行扣减,但此时线程 2 和线程 3 也进行查询,同样发现库存充足,然后线程 1 执行完扣减操作后,库存变为了 0,线程 2 和线程 3 同样完成了库存扣减操作,最终导致库存变成了负数!这就是超卖问题的完整流程

悲观锁和乐观锁的比较

悲观锁比乐观锁的 性能低:悲观锁需要先加锁再操作,而乐观锁不需要加锁,所以乐观锁通常具有更好的性能。
悲观锁比乐观锁的 冲突处理能力低:悲观锁在冲突发生时直接阻塞其他线程,乐观锁则是在提交阶段检查冲突并进行重试。
悲观锁比乐观锁的 并发度低:悲观锁存在锁粒度较大的问题,可能会限制并发性能;而乐观锁可以实现较高的并发度。
应用场景:两者都是互斥锁,悲观锁适合 写入操作较多、冲突频繁 的场景;乐观锁适合 读取操作较多、冲突较少 的场景。

乐观锁解决一人多单超卖问题

cas 法

image-20230710231616439

1
2
3
4
5
6
7
// 5、秒杀券合法,则秒杀券抢购成功,秒杀券库存数量减一
boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
.eq(SeckillVoucher::getVoucherId, voucherId)
//.eq(SeckillVoucher:: getStock, voucher.getStock())
.gt(SeckillVoucher::getStock, 0)//优化, 只需库存大于 0
.setSql("stock = stock -1"));

单体下的一人一单超卖问题

image-20230711230802538

问题原因:

出现这个问题的原因和前面库存为负数数的情况是一样的,线程 1 查询当前用户是否有订单,当前用户没有订单准备下单,此时线程 2 也查询当前用户是否有订单,由于线程 1 还没有完成下单操作,线程 2 同样发现当前用户未下单,也准备下单,这样明明一个用户只能下一单,结果下了两单,也就出现了超卖问题

解决方法

image-20230711235556450

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 3、创建订单
Long userId = ThreadLocalUtls.getUser().getId();
synchronized (userId.toString().intern()) {
// 创建代理对象,使用代理对象调用第三方事务方法, 防止事务失效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(userId, voucherId);
}
}

/**
* 创建订单
*
* @param userId
* @param voucherId
* @return
*/
@Transactional
public Result createVoucherOrder(Long userId, Long voucherId) {
// synchronized (userId.toString().intern()) {
// 1、判断当前用户是否是第一单
int count = this.count(new LambdaQueryWrapper<VoucherOrder>()
.eq(VoucherOrder::getUserId, userId));
if (count >= 1) {
// 当前用户不是第一单
return Result.fail("用户已购买");
}
// 2、用户是第一单,可以下单,秒杀券库存数量减一
boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
.eq(SeckillVoucher::getVoucherId, voucherId)
.gt(SeckillVoucher::getStock, 0)
.setSql("stock = stock -1"));
if (!flag) {
throw new RuntimeException("秒杀券扣减失败");
}
// 3、创建对应的订单,并保存到数据库
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId(SECKILL_VOUCHER_ORDER);
voucherOrder.setId(orderId);
voucherOrder.setUserId(ThreadLocalUtls.getUser().getId());
voucherOrder.setVoucherId(voucherOrder.getId());
flag = this.save(voucherOrder);
if (!flag) {
throw new RuntimeException("创建秒杀券订单失败");
}
// 4、返回订单 id
return Result.ok(orderId);
// }
}

用代理的原因

@Transactional 的底层实现 依赖 AOP 代理:Spring 在容器启动时为带事务注解的类生成一个代理对象,把“开启事务、提交、回滚”等逻辑织入到代理的同名方法里。
只有“外部”通过代理对象调用的方法,才会先走进代理逻辑,事务才生效。

一旦调用路径是“自己类里一个普通方法去调另一个带 @Transactional 的方法”(即 this.method()),就发生 方法体内自调用,此时:

  1. 调用方是 原始对象本身this),而不是代理对象;
  2. 代码直接走目标方法本体,没经过任何代理拦截;
  3. 事务切面逻辑(开启事务、异常回滚等)因此完全“缺席”,表现为 @Transactional 失效。

一句话:事务只能由“代理”代劳,自己调自己绕过了代理,切面不起作用,事务就丢了。

实现细节:

  • 锁的范围尽量小。synchronized 尽量锁代码块,而不是方法,锁的范围越大性能越低

  • 锁的对象一定要是 一个不变的值。我们不能直接锁 Long 类型的 userId,每请求一次都会创建一个新的 userId 对象,synchronized 要锁不变的值,所以我们要将 Long 类型的 userId 通过 toString()方法转成 String 类型的 userId,toString()方法底层(可以点击去看源码)是直接 new 一个新的 String 对象,显然还是在变,所以我们要使用 intern() 方法从常量池中寻找与当前 字符串值一致的字符串对象,这就能够保障一个用户 发送多次请求,每次请求的 userId 都是不变的,从而能够完成锁的效果(并行变串行)

  • 我们 要锁住整个事务,而不是锁住事务内部的代码。如果我们锁住事务内部的代码会导致其它线程能够进入事务,当我们事务还未提交,锁一旦释放,仍然会存在超卖问题

  • Spring 的 @Transactional 注解要想事务生效,必须使用动态代理。Service 中一个方法中调用另一个方法,另一个方法使用了事务,此时会导致@Transactional 失效,所以我们需要创建一个代理对象,使用代理对象来调用方法。

集群下的一人一单超卖问题

原因

两者都进入了锁的内部,这个 synchronized 锁形同虚设,这是由于 synchronized 是本地锁,只能提供 线程级别 的同步,每个 JVM 中都有一把 synchronized 锁,不能跨 JVM 进行上锁,当一个线程进入被 synchronized 关键字修饰的方法或代码块时,它会尝试获取对象的内置锁(也称为监视器锁)。如果该锁没有被其他线程占用,则当前线程获得锁,可以继续执行代码;否则,当前线程将进入阻塞状态,直到获取到锁为止。而现在我们是创建了两个节点,也就意味着有两个 JVM,所以 synchronized 会失效!

解决方案

分布式锁

  • 分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁

    image-20230712223652852

分布式锁的特点:

  • 多线程可见。

  • 互斥。分布式锁必须能够确保在任何时刻只有一个节点能够获得锁,其他节点需要等待。

  • 高可用。分布式锁应该具备高可用性,即使在网络分区或节点故障的情况下,仍然能够正常工作。(容错性)当持有锁的节点发生故障或宕机时,系统需要能够自动释放该锁,以确保其他节点能够继续获取锁。

  • 高性能。分布式锁需要具备良好的性能,尽可能减少对共享资源的访问等待时间,以及减少锁竞争带来的开销。

  • 安全性。(可重入性)如果一个节点已经获得了锁,那么它可以继续请求获取该锁而不会造成死锁。(锁超时机制)为了避免某个节点因故障或其他原因无限期持有锁而影响系统正常运行,分布式锁通常应该设置超时机制,确保锁的自动释放。

  • setnx 指令的特点:setnx 只能设置 key 不存在的值,值不存在设置成功,返回 1 ;值存在设置失败,返回 0

    set key value ex 10 nx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 3、创建订单(使用分布式锁)
Long userId = ThreadLocalUtls.getUser().getId();
SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
boolean isLock = lock.tryLock(1200);
if (!isLock) {
// 索取锁失败,重试或者直接抛异常(这个业务是一人一单,所以直接返回失败信息)
return Result.fail("一人只能下一单");
}
try {
// 索取锁成功,创建代理对象,使用代理对象调用第三方事务方法, 防止事务失效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(userId, voucherId);
} finally {
lock.unlock();
}

分布式锁误删优化 1

简单的分布式锁,但是会存在一个问题:当线程 1 获取锁后,由于业务阻塞,线程 1 的锁超时释放了,这时候线程 2 趁虚而入拿到了锁,然后此时线程 1 业务完成了,然后把线程 2 刚刚获取的锁给释放了,这时候线程 3 又趁虚而入拿到了锁,这就导致又出现了超卖问题!(但是这个在小项目(并发数不高)中出现的概率比较低,在大型项目(并发数高)情况下是有一定概率的)
image-20230713151508982

解决方法

: 我们为分布式锁添加一个 线程标识,在释放锁时判断当前锁是否是自己的锁,是自己的就直接释放,不是自己的就不释放锁,从而解决多个线程同时获得锁的情况导致出现超卖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId() + "";
// SET lock: name id EX timeoutSec NX
Boolean result = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}

/**
* 释放锁
*/
@Override
public void unlock() {
// 判断 锁的线程标识 是否与 当前线程一致
String currentThreadFlag = ID_PREFIX + Thread.currentThread().getId();
String redisThreadFlag = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (currentThreadFlag != null || currentThreadFlag.equals(redisThreadFlag)) {
// 一致,说明当前的锁就是当前线程的锁,可以直接释放
stringRedisTemplate.delete(KEY_PREFIX + name);
}
// 不一致,不能释放

单机环境 下,只用 Thread.currentThread().getId() 确实可以区分不同线程;但一到 集群/分布式部署 就失效了——同一时刻:

  • 机器 A 的线程 101 可能拿到锁;
  • 机器 B 的线程 101 也会认为自己“就是当前线程”,从而误删别人的锁。

UUID 给每台 JVM 一个 全局唯一前缀,把
线程ID机器唯一ID + 线程ID
这样即使线程号相同,不同机器的标识串也完全不一样,分布式场景下才不会出现“我删你锁” 的情况。

释放锁时的原子性问题

在上一节中,我们通过给锁添加一个线程标识,并且在释放锁时添加一个判断,从而防止锁超时释放产生的超卖问题,一定程度上解决了超卖问题,但是仍有可能发生超卖问题(出现超卖概率更低了):当线程 1 获取锁,执行完业务然后并且 判断完当前锁是自己的锁时,但就在此时发生了阻塞,结果锁被超时释放了,线程 2 立马就趁虚而入了,获得锁执行业务,但就在此时线程 1 阻塞完成,由于已经判断过锁,已经确定锁是自己的锁了,于是直接就删除了锁,结果删的是线程 2 的锁,这就又导致线程 3 趁虚而入了,从而继续发生 超卖问题

解决方案

Lua 脚本, 可控制原子性

lua 脚本原子性:

Lua 脚本在 Redis 里能保证原子性,核心原因是 Redis 单线程执行模型

  1. 一旦 EVAL/EVALSHA 命令到达,Redis 会把整个脚本 一次性载入内存
  2. 主线程 暂停对外服务,顺序执行脚本里所有指令;期间不会插入任何其他客户端请求。
  3. 脚本执行完(或报错)后,才把结果返回给调用方,并重新接受外部命令。

因此,脚本内的多条读写操作 既不会交错,也不会被其他客户端看到中间状态,天然具备原子性;无需事务 (MULTI/EXEC) 或加锁,就能实现“读-改-写”一类的并发安全逻辑。

Lua 代码


1
2
3
4
5
6
7
8
9
10
11
12
13
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by ghp.
--- DateTime: 2023/7/13 16:19
---
-- 比较缓存中的线程标识与当前线程标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 一致,直接删除
return redis.call('del', KEYS[1])
end
-- 不一致,返回0
return 0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 加载 Lua 脚本
*/
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}

/**
* 释放锁
*/
@Override
public void unlock() {
// 执行 lua 脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}

超卖总结

1759816017241

redis 数据类型

Redis 字符串类型常用命令:
SET key value 设置指定 key 的值 eg: set name jack
GET key 获取指定 key 的值
SETEX key seconds value 设置指定 key 的值,并将 key 的过期时间设为 seconds 秒
SETNX key value 只有在 key 不存在时设置 key 的值

hash 类型

Redis hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象,常用命令:
HSET key field value 将哈希表 key 中的字段 field 的值设为 value
HGET key field 获取存储在哈希表中指定字段的值
HDEL key field 删除存储在哈希表中的指定字段
HKEYS key 获取哈希表中所有字段
HVALS key 获取哈希表中所有值
hgetall key 获取 key 对应 field value

Hash 类型的底层数据结构是由 压缩列表或哈希表 实现的:

  • ·如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用 压缩列表 作为 Hash 类型的底层数据结构;
  • ·如果哈希类型元素不满足上面条件,Redis 会使用 哈希表 作为 Hash 类型的底层数据结构。

list

Redis 列表 是简单的字符串列表,按照插入顺序排序,常用命令:
LPUSH key value1 (value2) 将一个或多个值插入到列表头部
LRANGE key start stop 获取列表指定范围内的元素
RPOP key 移除并获取列表最后一个元素
LLEN key 获取列表长度
  • 老版本: List 类型的底层数据结构是由 双向链表或压缩列表 实现的
  • 新版本 quicklist 快表

SET

SADD key member1 [member2] 向集合添加一个或多个成员
SMEMBERS key 返回集合中的所有成员
SCARD key 获取集合的成员数
SINTER key1 [key2] 返回给定所有集合的交集
SUNION key1 [key2] 返回所有给定集合的并集
SREM key member1 [member2] 删除集合中一个或多个成员

Set 类型的底层数据结构是由 整数集合哈希表 实现的:

  • 如果集合中的元素都是整数且元素个数小于 512(默认值,set-maxintset-entries 配置)个,Redis 会使用 整数集合 作为 Set 类型的底层数据结构;
  • 如果集合中的元素不满足上面条件,则 Redis 使用 哈希表 作为 Set 类型的底层数据结构。

ZSet 类型

Redis 有序集合是 string 类型元素的集合,且不允许有重复成员。每个元素都会关联一个 double 类型的分数。常用命令:

ZADD key score1 member1 [score2 member2] 向有序集合添加一个或多个成员
ZRANGE key start stop [WITHSCORES] 通过索引区间返回有序集合中指定区间内的成员
ZINCRBY key increment member 有序集合中对指定成员的分数加上增量 increment
ZREM key member [member …] 移除有序集合中的一个或多个成员

Zset 类型的底层数据结构是由 压缩列表或跳表 实现的:

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Rdis 会使用 压缩列表 作为 Zset 类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Rdis 会使用 跳表 作为 Zset 类型的底层数据结构;
Redis 的通用命令是不分数据类型的,都可以使用的命令:
KEYS pattern 查找所有符合给定模式( pattern)的 key
EXISTS key 检查给定 key 是否存在
TYPE key 返回 key 所储存的值的类型
DEL key 该命令用于在 key 存在是删除 key

redis 数据结构底层

img

ziplist(压缩列表)

压缩列表(zip1ist)是 列表哈希 的底层实现之一。

1745722705283.png

1745723248883.png

  • 如果前一节点的长度小于 254 字节, 那么 previous_entry_length 属性的长度为 1 字节,前一节点的长度就保存在这一个字节里面。
  • 如果前一节点的长度大于等于 254 字节, 那么 previous_entry_length 属性的长度为 5 字节: 其中属性的第一字节会被设置为 0xFE(十进制值 254), 而之后的四个字节则用于保存前一节点的长度.
  • 节点的 encoding 属性记录了节点的 content 属性所保存 数据的类型以及长度

listpack(ziplist 改进版)

1745735338681.png

quicklist(快表)

quicklist 实际上是 zipList 和 linkedList 的混合体,它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。

1745730426223.png

跳表

Redis 的跳跃表由 zskiplistNode 和 skiplist 两个结构定义, 其中 zskiplistNode 结构用于表示 跳跃表节点, 而 zskiplist 结构 则用于保存 跳跃表节点的相关信息, 比如节点的数量, 以及指向表头节点和表尾节点的指针等等。

1745732243699.png
  • level: 记录目前跳跃表内, 层数最大的那个节点的层数(表头节点的层数不计算在内),通过这个属性可以再 O(1)的时间复杂度内获取层高最好的节点的层数。
  • length: 记录跳跃表的长度, 也即是, 跳跃表目前包含节点的数量(表头节点不计算在内),通过这个属性,程序可以再 O(1)的时间复杂度内返回跳跃表的长度。

结构右方的是四个 zskiplistNode 结构, 该结构包含以下属性

  • 层(level):

    节点中用 L1、L2、L3 等字样标记节点的各个层, L1 代表第一层, L 代表第二层, 以此类推。

    每个层都带有两个属性: 前进指针跨度。前进指针用于访问位于表尾方向的其他节点, 而跨度则记录了前进指针所指向节点和当前节点的距离(跨度越大、距离越远)。在上图中, 连线上带有数字的箭头就代表前进指针, 而那个数字就是跨度。当程序从表头向表尾进行遍历时, 访问会沿着层的前进指针进行。

  • 每次创建一个新跳跃表节点的时候, 程序都根据幂次定律(powerlaw, 越大的数出现的概率越小)随机生成一个介于 1 和 32 之间的值作为 level 数组的大小, 这个大小就是层的“高度”。

  • 后退(backward)指针:

    节点中用 BW 字样标记节点的后退指针, 它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。与前进指针所不同的是每个节点只有一个后退指针,因此每次只能后退一个节点。

  • 分值(score): 各个节点中的 1.0、2.0 和 3.0 是节点所保存的分值。在跳跃表中, 节点按各自所保存的分值从小到大排列。

  • 成员对象(oj):

    各个节点中的 o1、o2 和 o3 是节点所保存的成员对象。在同一个跳跃表中, 各个节点保存的成员对象必须是唯一的, 但是多个节点保存的 分值却可以是相同的: 分值相同的节点将按照成员对象在字典序中的大小来进行排序, 成员对象较小的节点会排在前面(靠近表头的方向), 而成员对象较大的节点则会排在后面(靠近表尾的方向)。

Redis 与 Memcached 区别:

Redis 支持的数据类型更丰富(String、Hash、List、Set、ZSet), 而 Memcached 只支持最简单的 key-value 数据类型;

·Rdis 支持数据的特久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了,

·Redis 原生支持集群模式,Memcached 没有原生的集群模式,需要依靠客户

端来实现往集群中分片写入数据;

·Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持,

单线程解释

Rdis 单线程指的是[接收客户端请求-> 解析请求-> 进行数据读写等操作-> 发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Rdis 是单线程的原因。

Redis 在启动的时候,是会启动后台线程(BIO)关闭文件、AOF 刷盘、释放内存

Redis 采用单线程(网络 I/O 和执行命令)那么快

  • 1.都在内存中完成

  • 2.采用单线程模型可以避免了多线程之间的竞争

  • 3.io 多路复用

CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到内存大小和网络 I/O 的限制

redis 三种主动更新方式

1745558547346.png

Cache Aside(旁路缓存)策略

1744185149235.png

不能先删除缓存再更新数据库

原因是在「读+写」并发的时候,会出现缓存和数据库的数据不一致性的问题。

举个例子,假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。

redis 三种数据持久化:

1.AOF

AOF 持久化功能的实现

可以简单分为 5 步:

  1. 命令追加(append):所有的写命令会追加到 AOF 缓冲区中。

  2. 文件写入(write):将 AOF 缓冲区的数据写入到 AOF 文件中。这一步需要调用 write 函数(系统调用),write 将数据写入到了系统内核缓冲区之后直接返回了(延迟写)。注意!!!此时并没有同步到磁盘。

  3. 文件同步(fsync):AOF 缓冲区根据对应的持久化方式( fsync 策略)向硬盘做同步操作。这一步需要调用 fsync 函数(系统调用), fsync 针对单个文件操作,对其进行强制硬盘同步,fsync 将阻塞直到写入磁盘完成后返回,保证了数据持久化。

    存在三种( fsync 策略),它们分别是:

    1. appendfsync always:主线程调用 write 执行写操作后,后台线程( aof_fsync 线程)立即会调用 fsync 函数同步 AOF 文件(刷盘),fsync 完成后线程返回,这样会严重降低 Redis 的性能(write + fsync)。
    2. appendfsync everysec:主线程调用 write 执行写操作后立即返回,由后台线程( aof_fsync 线程)每秒钟调用 fsync 函数(系统调用)同步一次 AOF 文件(write+fsyncfsync 间隔为 1 秒)(一般选择这个)
    3. appendfsync no:主线程调用 write 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write 但不 fsyncfsync 的时机由操作系统决定)。
  4. 文件重写(rewrite):随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。

  5. 重启加载(load):当 Redis 重启时,可以加载 AOF 文件进行数据恢复。

AOF 工作基本流程

AOF 为什么是在执行完命令之后记录日志?

关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令之后再记录日志。

AOF 记录日志过程

为什么是在执行完命令之后记录日志呢?

  • 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
  • 在命令执行完之后再记录,不会阻塞当前的命令执行。

这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):

  • 如果刚执行完命令 Redis 就宕机会导致对应的修改丢失;

  • 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。


AOF 重写

当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积 更小

AOF 文件重写期间,Redis 还会维护一个 AOF 重写缓冲区,该缓冲区会在 子进程 创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。

1744608860002.png

2.RDB

bgsave 不阻塞, save 阻塞主线程

如果主线程(父进程)要修改共享数据里的某一块数据(比如键值对 A)时,就会发生写时复制,于是这块数据的物理内存就会被复制一份(键值对·),然后主线程在这个数据副本(键值对 A’)进行修改操作。与比同时,bgsave 子进程可以继续把原来的数据(键值对 A)写入到 RDB 文件。

1744608944146.png

3.混合持久化工作在 AOF 日志重写 过程。

当开启了混合持久化时,在 AOF 重写日志时, fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

主从复制

1744609029369.png

TCP 长连接传播命令

1744609046469.png

replication buffer、repl backlog buffer 区别:

那么为了保证主从服务器的数据一致性,主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区里:

  • 主服务器生成 RDB 文件期间:
  • 主服务器发送 RDB 文件给从服务器期间;
  • 「从服务器」加载 RDB 文件期间:

出现的阶段不一样:

  • repl backlog buffer 是在 增量复制 阶段出现,一个主节点只分配一个 repl backlog buffer;.

  • replication buffer 是在全量复制阶段和增量复制阶段都会出现,主节点会给每个新连接的从节点,分配一个 replication buffer;·

这两个 Buffer 都有大小限制的,当缓冲区满了之后,发生的事情不一样:

  • 当 repl backlog buffer 满了,因为是 环形结构,会直接覆盖起始位置数据

  • 当 replication buffer 满了,会导致连接断开,删除缓存,从节点重新连接,重新开始全量复制。

主从切换过程中,产生数据丢失的情况有两种:

  • 异步复制同步丢失(所有的从节点数据复制和同步的延迟都超过了 min-slaves-max-lag 定义的值,那么主节点就会拒绝接收任何请求。)如果此时主节点还没来得及同步给从节点时发生了断电,那么主节点内存中的数据会丢失。
  • 集群产生脑裂数据丢失(min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用, 假设为 N 和 T。这两个配置项组合后的要求是,主节点连接的从节点中至少有 N 个从节点,「并且」主节点进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主节点就不会再接收客户端的写请求了。)

缓存雪崩\击穿\穿透

1744609292771.png

Redis 使用的过期删除策略是什么?

每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典

(expires dict)保存了数据库中所有 key 的过期时间。

当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:·

Redis 使用的过期删除策略是「惰性删除+定期删除」这两种策略配和使用。

Redis 的 定期删除 的流程:

1.从过期字典中随机抽取 20 个 key;

2.检查这 20 个 key 是否过期,并删除已过期的 key;

3.如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 ky 的数量」占比「随机抽取 k©y 的数量」大于 25%,则继续重复步聚 1;如果已过期的 key 比例小于 25%,则停止

继续删除过期 key, 然后等待下一轮再检查。

可以看到,定期删除是一个循环的流程。那 Rdis 为了保证定期删除不会出现循环过度,导

致线程卡死现象,为比增加了定期删除循环流程的时间上限,默认不会超过 25ms。

Redis 持久化时, 过期键

1744609453277.png

Redis 内存淘汰

1744609523702.png

Redisson

底层是使用 Lua 脚本 实现的原子性获取锁释放锁.

1
2
3
4
5
6
7
8
9
10
11
12
  lock=redissonClient.getlock("lock");
boolean isLock=lock.tryLock();
//1.无参, 获取失败就返回, 不重试
//2.三个参数
// waitTime ----重试时间
// leaseTime ----超时施放时间
// TimeUnit ----时间单位
//3.两个参数,
// waitTime ----重试时间
//leaseTime 没传为-1, 看门狗 10 秒一刷新 30s
// TimeUnit ----时间单位
lock.unlock();
  • 可重入

  • 可重试

redis 存储秒杀优惠券的库存,和下单 userId 先判断库存和一人一单

大 key

bigkey 判定标准

纯英文字符串需要约 105 万个字符才超 1MB;中文约 35 万个;emoji 约 26 万个。

bigkey 通常是由于下面这些原因产生的:

  • 程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。
  • 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。
  • 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对。

查找大 key

1.redis-cli -p 6379 --bigkeys -i 3 表示扫描过程中每次扫描后休息的时间间隔为 3 秒。

2.SCAN 命令可以按照一定的模式和数量返回匹配的 key。获取了 key 之后,可以利用 STRLENHLENLLEN 等命令返回其长度或成员数量。

3.借助开源工具分析 RDB 文件。

异步删除 key

删除分为两步:

  • 判定过期:Redis 内部一直异步(主动+被动),不卡用户命令。
  • 回收内存
    • 默认是同步 free,大 value 会阻塞;
    • 打开 lazyfree-lazy-expire yes 后,回收也变成后台线程异步,这就是「慢慢删」的含义。

在 Redis 里「真正」异步删除,用 4.0+ 提供的 UNLINK 命令 即可;
如果版本 < 4.0,只能先 SCAN + DEL 分批,或者走 lazy-expire + 后台线程 的折中方案。
下面给出 三种现网场景 的完整做法。


一、Redis ≥ 4.0(最干净)

  1. 命令

    1
    UNLINK key [key ...]

    行为:

    • 把 key 从 DB 的 dict 里 立即摘除(客户端瞬间返回 OK)。
    • 真正的内存回收交给 bio_lazy_free 后台线程,不阻塞主线程。
  2. 代码示例(Jedis)

    1
    2
    3
    4
    5
    6
    // 单条
    jedis.unlink("bigHash");

    // 批量
    List<String> batch = Arrays.asList("key1","key2","key3");
    jedis.unlink(batch.toArray(new String[0]));
  3. 集群版
    JedisCluster 同样支持 unlink(key),会自动定位到 slot 所在节点。


二、Redis < 4.0(没有 UNLINK)

只能 “阉割版异步”

  • 主线程仍会被 DEL 卡住,必须分批+sleep
  • 或者干脆 改 expires,让 key 在后台过期线程里慢慢删。

方案 A:SCAN + DEL 分批 + 限速

1
2
3
4
5
6
7
8
9
10
11
String cursor = "0";
int batch = 100;
do {
ScanResult<String> scan = jedis.scan(cursor, new ScanParams().match("big:*").count(batch));
cursor = scan.getCursor();
if (!scan.getResult().isEmpty()) {
// 一次性 DEL 100 个,仍可能小卡,可再拆小
jedis.del(scan.getResult().toArray(new String[0]));
Thread.sleep(50); // 主动让出 CPU,降低抖动
}
} while (!cursor.equals("0"));

方案 B:lazy-expire(几乎不卡)

1
2
// 把 key 的过期时间设成 1~2 s 后,Redis 的 expire 线程会异步回收
jedis.expire("bigKey", 1);

适合「允许延迟几秒消失」的场景;立即需要内存就别用


三、超大单个 key(value 几百 MB 以上)

即使 4.0 的 UNLINK,回收阶段仍可能让 bio 线程 CPU 飙升。
终极方案:先逻辑拆分,再异步删

  1. 设计期就把大 key 拆成 hash-split / list-split(如 big:0 big:1 … big:1023)。
  2. 删除时
    1
    EVAL "for i=0,1023 do redis.call('UNLINK', 'big:'..i) end" 0
    每个小块回收很快,对线程池压力平摊。

四、一句话总结

版本 命令 阻塞? 备注
≥ 4.0 UNLINK ❌ 完全不阻塞 推荐
< 4.0 SCAN + DEL + sleep ⚠️ 轻微阻塞 分批+限速
< 4.0 & 可延迟 EXPIRE 1 等后台过期线程
特大 value 先拆 key 再 UNLINK 最平稳

现网只要 Redis 4.0+,直接 UNLINK 就是官方提供的“异步删除”标准答案。