记录工作中总结的 redis 相关知识

redis 底层数据接口对应关系

image.png

redis 如何保存数据

采用了哈希表来保存所有的键值对 一个哈希表对应了多个哈希桶 哈希桶中的entry元素中保存了key和value指针,分别指向了实际的键和值 通过链式冲突解决冲突

为了避免链式冲突解决方法上的不足 rehash , 增加哈希桶数量

  1. 两个 hash 操作, a 需要扩容, b 申请两倍的空间
  2. a 重新映射并赋值拷贝
  3. 释放 a 的空间

问题 复制的时候需要时间 可能会阻塞线程 无法服务请求

为什么采用 单线程

指的是 网络 io 和 键值对读写 持久化和集群数据是由额外的进程完成
避免并发带来的数据共享问题, redis 性能瓶颈不在于读写, 网络、cpu 才是瓶颈

6.0 引入多进程, 网络 io 到实际的读写还是单线程,但是网络性能提升, 性能瓶颈出现在了网络 io 上, 多 io 处理网络请求, 提高网络处理并行度, 但是读写命令还是单线程

为什么快

io 多路复用 并发处理客户端请求, 提升吞吐率
一个进程多个 io 流, select epoll 机制, 内核会一直监听多个套接字请求, 有请求到来, 交给 redis 处理
基于内存 操作迅速
底层数据结构 高效, 单进程 无竞争

重启后数据丢失问题

AOF RDB
AOF 先执行命令 在写入内存 再记录日志

什么时候写入磁盘
image.png

每条数据都会记录, 数据会越来越大,

AOF 重写机制

主线程 fork 后台 bgrewriteof
根据所有键值创建一个新的 AOF 文件,
重写前 拷贝主线程内存 拷贝页表, 不影响主线程的情况下, 逐一拷贝数据到重写, 如果有新的写命令 先放入缓存区, 完成后写入日志

RDB 快照

日志比较多的时候, 启动会比较慢, 全量快照, 两个命令生成, save 和 bgsave, 主线程中执行, 导致阻塞, bgsave, 子进程 避免阻塞,
借用操作系统的写时复制, 快照期间还是可以正常写入,

主要流程为:

  • bgsave子进程是由主线程fork出来的,可以共享主线程的所有内存数据。
  • bgsave子进程运行后,开始读取主线程的内存数据,并把它们写入RDB文件中。
  • 如果主线程对这些数据都是读操作,例如A,那么主线程和bgsave子进程互不影响。
  • 如果主线程需要修改一块数据,如C,这块数据会被复制一份,生成数据的副本,然主线程在这个副本上进行修改;bgsave子进程可以把原来的数据C写入RDB文件。

写时复制

通过 fork() 来创建一个子进程时,操作系统需要将父进程虚拟内存空间中的大部分内容全部复制到子进程中(主要是数据段、堆、栈;代码段共享)。这个操作不仅非常耗时,而且会浪费大量物理内存。引入了写时复制技术。内核不会复制进程的整个地址空间,而是只复制其页表,fork 之后的父子进程的地址空间指向同样的物理内存页 然而只要有一个进程试图写入共享区域的某个页面,那么就会为这个进程创建该页面的一个新副本。写时复制技术将内存页的复制延迟到第一次写入时,更重要的是,在很多情况下不需要复制。这节省了大量时间,充分使用了稀有的物理内存。
原理
fork() 之后,内核会把父进程的所有内存页都标记为只读。一旦其中一个进程尝试写入某个内存页,就会触发一个保护故障(缺页异常),此时会陷入内核。
内核将拦截写入,并为尝试写入的进程创建这个页面的一个新副本,恢复这个页面的可写权限,然后重新执行这个写操作,这时就可以正常执行了。
内存页本来被父子进程应用, 两个引用, 创建新副本后, 引用 -1 , 如果页面只有一个引用, 则直接修改数据

优缺点

优点:减少不必要的资源分配,节省宝贵的物理内存。
缺点:如果在子进程存在期间发生了大量写操作,那么会频繁地产生页面错误,不断陷入内核,复制页面。这反而会降低效率。

golang 中也有这样的使用, string,array 也是写时复制
快照全量有压力,Redis采用了增量快照,在做一次全量快照后,后续的快照只对修改的数据进行记录,需要记住哪些数据被修改了
在Redis4.0提出了混合使用AOF和RDB快照的方法,也就是两次RDB快照期间的所有命令操作由AOF日志文件进行记录。这样的好处是RDB快照不需要很频繁的执行,可以避免频繁fork对主线程的影响,而且AOF日志也只记录两次快照期间的操作,不用记录所有操作,

混合持久化

- 在Redis 4.0及以上版本中,可以同时使用RDB和AOF进行持久化。
- **优化**:
    - 结合RDB的快速启动和AOF的数据完整性优势。
    - 在发生故障时,可以使用RDB快速恢复大部分数据,然后使用AOF重放最近的写入操作。

redis 数据同步

第一次数据同步, 发送 rdb 文件
级联的“主-从-从”模式
手动选择一个从库,用来同步其他从库的数据,以减少主库生成RDB文件和传输RDB文件的压力;
这样从库就可以知道在进行数据同步的时候,不需要和主库直接交互,只需要和选择的从库进行写操作同步就可以了,从而减少主库的压力

redis 的增量同步

在 redis 中 .offsetrepl_backlog_buffer是一个环形的缓冲区,如果从库断开时间太长导致自己的offset被覆盖了,就只能再次全量同步了。
全量同步是自动的

数据一致性问题

当从服务器已经完成和主服务的数据同步之后,再新增的命令会以异步的方式发送至从服务器,在这个过程中主从同步会有短暂的数据不一致,如在这个异步同步发生之前主服务器宕机了,会造成数据不一致。

redis 事务

使用 lua 脚本是简单直接的方法, 但是用户程序中需要对 lua 脚本进行校验, 避免出现注入的情况

正常方式是使用

  • MULTI 开启
  • EXEC commit
  • WATCH 查看是否变更, 变更事务会打断
  • DISCARD 取消事务
  • UNWATCH 取消所有监视

注意 事务中的命令 redis 都会执行, 不管对错, Redis 不会停止命令的处理
注意 redis 不算满足了原子性

大 key 问题

redis-cli -h b.redis -p 1959 --bigkeys 使用该命令查看都有哪些大 key , 程序频繁请求大 key 会出现问题, 一个是内存问题, 一个是网络问题, 同时会阻塞其他请求, 这种大 key 需要尽量避免, 防止出现问题, 可以通过加 lru 或者拆分去解决

redis 的高可用

常用的有
redis cluster, 好像问题较多, 没有具体使用过
twemproxy 不算集群方案, 属于前置加了一层 hash 分片处理,传输 ==lua 脚本的时候会出现问题, 一个 lua 脚本 同时操作的 key 如果不在同一个 redis, 会尝试分发, 可能会失败==
哨兵模式, 客户端程序容易出问题, 主从切换的时候, 客户端需要及时切换, 不是所有客户端都支持, 可以把这一层切换到 haproxy 中去执行, 其他客户端直联 haproxy, 故障后 haproxy 自动切换连接的 redis

哨兵模式下的从服务器只读性

默认在情况下,处于复制模式的主服务器既可以执行写操作也可以执行读操作,而从服务器则只能执行读操作。

可以在从服务器上执行 config set replica-read-only no 命令,使从服务器开启写模式,但需要注意以下几点:

  • 在从服务器上写的数据不会同步到主服务器;
  • 当键值相同时主服务器上的数据可以覆盖从服务器;
  • 在进行完整数据同步时,从服务器数据会被清空。

redis 哨兵模式的风险点

  1. 三个节点请情况下, 哨兵挂了两个, 也会有问题,是指至少两个哨兵认为需要切换才切换主备, 则两台哨兵挂了 则不会切换
  2. redis 哨兵模式, 三个机器全量数据, 数据过多则会消耗内存

redis scan

scan 如何实现的, 参考: https://my.oschina.net/liboware/blog/5371977
生产环境用 scan 禁止 keys

scan 用法

local cursor = "0"  -- 初始化游标
repeat
    -- 扫描匹配的键
    local resp = redis.call('SCAN', cursor, 'MATCH', 'authToken*', 'COUNT', 10000) -- count 表示 hash 槽遍历的数量, 不是返回的 key 的数量
    cursor = tonumber(resp[1])  -- 获取新的游标
    local dataList = resp[2]  -- 获取扫描到的键

    -- 遍历所有符合条件的键
    for i = 1, #dataList do
        local d = dataList[i]
        local ttl = redis.call('TTL', d)

        -- 如果 TTL 为 -1(没有过期时间),则删除该键
        if ttl == -1 then
            redis.call('DEL', d)
        end
    end
until cursor == 0  -- 如果游标为 0,表示扫描完成

return 'all finished'

redis lua 使用时的最佳实践

显式的参数传递

无论单机还是集群模式,对于 key 的操作必须通过参数列表,显示的传进去,而不能依赖脚本或是随机逻辑

https://mytechshares.com/2022/10/07/dive-redis-lua/

使用 keys传入, 不能随机或者直接硬编码, 更不能依赖 hgetall 获取 key
解释: 对于集群模式, 主从节点同时执行相同的 lua 脚本(3.2 版本原生模式下), 但是 hgetall 得到的数据在主从是不一致的, 最终导致主从数据的不一致

# Python 示例,使用 redis-py 库
script = """
local value1 = redis.call('get', KEYS[1])
local value2 = redis.call('get', KEYS[2])
return value1 .. ' ' .. value2
"""
keys = ['user:1000', 'user:1001']
result = redis.eval(script, len(keys), *keys)

集群模式下的命令传递

Redis 集群(Cluster)模式下,当你使用 Lua 脚本操作多个键时,这些键必须位于同一个 slot(哈希槽)上,并且必须在同一个 shard(分片)内。
使用 tempproxy 的时候不能执行复杂的 redis keys 操作, 需要保证数据在同一个 redis 实例, 不然会存在非预期错误

lua 脚本在主从同步上的改进

3.2 版本前, 脚本原生模式, 主从都执行相同的 lua 脚本, 但是结果可能不同, 导致不一致
3.2 后 出现了效果复制, 复制 lua 实际的执行命令

lua 脚本为了数据一致性做的改进

  • 不允许获取系统时间或外部状态:Lua 脚本不允许直接调用 TIME 或访问外部变量,这保证了脚本的执行不受外部状态变化的影响,避免了主从节点之间的差异。
  • 修改伪随机函数 math.random:Redis 对 math.random 函数做了修改,确保每次调用时,若没有重新设置种子(math.randomseed),返回的随机数序列是确定的。这样,即使在不同节点上执行相同的 Lua 脚本,也能保证返回相同的随机结果。
  • 命令返回顺序的统一:在 Redis 4.0 版本之前,像 SMEMBERS 这类命令返回的结果是有序的(按字典顺序),但从 Redis 5.0 版本开始,这种排序行为被移除了。因此,Lua 脚本不能假设任何返回结果的顺序,除非文档明确说明该命令有顺序保证。
  • 禁止调用随机命令并修改数据库:为了保持脚本的确定性,Redis 不允许在 Lua 脚本中调用 RANDOMKEYSRANDMEMBERTIME 等不确定性命令后,尝试修改数据库。如果 Lua 脚本调用这些命令,且后续试图进行写操作,Redis 会报错。这是为了避免基于随机数的操作影响到主从一致性。

lua 缓存

lua 每次执行解析性能有消耗, 可以优化, 使用 cache 将脚本缓存下来

script load "redis.call('incr', KEYS[1])" -- 加载后返回 hash 
EVALSHA da0bf4095ef4b6f337f03ba9dcd326dbc5fc8ace 1 testkey -- 直接使用 hash 执行 lua 脚本

redis 分布式锁的最佳实践

使用 SET 命令实现分布式锁

SET lock_key unique_lock_value NX PX 10000
- `NX`:只有在 `lock_key` 不存在时才设置成功。
- `PX`:设置键的过期时间(单位:毫秒),防止锁被永久占用,避免死锁。

通过这种方式,==一次原子操作==就可以完成锁的获取和过期时间的设置,减少了潜在的竞争风险。

redis 删除大 key

  1. 逐渐删除, 使用 hscan 等小批量删除
  2. 使用 unlink , 异步删除

redis 压测

redis  和 tewmproxy 极限性能
redis-benchmark -h node -p 8985 -t set,lpush -n 10000 -q
redis-benchmark -h node -p 8985 -t set, INCR, hincrby,expireat -n 300000 -q
redis-benchmark -h node -p 8985 -n 100000 -q script load "redis.call('hincrby','foo','foo','1') redis.call('expire','foo',10)"
redis-benchmark -h node -p 8099 -t set,lpush -n 10000 -q
redis-benchmark -s ./twemproxy/data/yjsv5-rate-limit-twemproxy.sock -t set,lpush -n 10000 -q
redis-benchmark  -s ./twemproxy/data/yjsv5-rate-limit-twemproxy.sock -n 100000 -q script load "redis.call('hincrby','foo','foo','1') redis.call('expire','foo',10)"

redis 内部 key 删除策略

不超过设置最大内存

主库

  1. 每次访问 key 检查是否过期 (惰性删除)
  2. 启动时注册定期函数, 每周期循环 随机获取一批 key 检查是否过期 (定期删除 默认每 100 ms 扫描一次, 每次随机扫描 20 个 key, 默认优先 key 是否过期, 如果默认超过 25% 的 key 都是过期 key , 则直接再重复一次定期删除, 直到最后小于 25%)
    从库
  3. 从库如果可写, 从库自己维护自己的过期 key, 也没惰性删除策略, 但是不会从从库获取到已经过期的 key, 从库得知 key 过期了不会删除, 之后等待主库 key 下发 del 删除过期 key

过期 key 的自动删除机制。它是 Redis 用来回收内存空间的常用机制,应用广泛,本身就会引起 Redis 操作阻塞,导致性能变慢,所以,你必须要知道该机制对性能的影响。
https://mp.weixin.qq.com/s/v_Wnrvf25UyKzRgvDziMeA

对上述描述的说明: Redis 4.0 之前是阻塞的, 4.0 后 过期 key 的自动删除机制是后台进程, 不会直接造成 redis 阻塞, 但是极端情况下会占用 cpu 资源, 导致 redis 的响应变慢, 上述是不准确的

超过设置最大内存

超过了 maxmemory 配置的值,就会触发新的内存淘汰了

数据删除后, 内存可能还是会很高, 不会主动归还给操作系统 因为存在内存碎片, 可以手动也会自动进行碎片整理

内存淘汰策略

策略类型 策略名称 说明
内存淘汰策略 noeviction 当内存不足时,Redis 不会淘汰任何键,而是返回错误, 不在进行数据的写入OOM command not allowed when used memory > 'maxmemory')。适用于不希望丢失任何数据的场景。
allkeys-lru 使用 LRU(最近最少使用)策略来淘汰任意键,优先淘汰最近最少使用的键。如果所有键都有过期时间,则会按过期时间来删除;否则按 LRU 进行淘汰。
volatile-lru 只淘汰设置了过期时间的键,并使用 LRU 策略删除最少使用的键。该策略只作用于设置了过期时间的键,对于没有过期时间的键不会进行淘汰。
allkeys-random 使用随机策略淘汰任意键,选择随机的键进行删除,适用于需要保证内存空间的场景,但不关心哪个键被删除。
volatile-random 只淘汰设置了过期时间的键,使用随机策略随机删除其中的一些键。
volatile-ttl 只淘汰设置了过期时间的键,并根据剩余的过期时间(TTL)选择即将过期的键进行淘汰,优先删除那些 TTL 较短的键。

说明 如果 缓冲区 占用了大量内存,淘汰策略就会失效,无法自动清理缓冲区的 内存,导致 Redis 无法继续工作。
Redis 中每个客户端都有输入缓冲区和输出缓冲区。若出现大量请求,缓冲区可能被填满,进而引发内存问题。特别是大 Key 操作(如大对象的写入和读取),可能导致输出缓冲区迅速膨胀,占用过多内存
可以通过设置 client-output-buffer-limit 默认值, 强制断开 buffer 过大的 client , 关闭该客户端连接,从而防止内存被过度占用

redis 的 rehash 机制

Redis 的 rehash 过程是为了扩展或收缩哈希表,以保持高效的查找、插入和删除操作。为了避免一次性 rehash 带来的性能开销,Redis 采用渐进式 rehash
redis 还在处理请求 每处理一次请求 将该 entry 拷贝到 b hash , 为了避免一直没有请求 周期性的搬移一些数据到 hash b

1. 触发 rehash

rehash 通常在以下两种情况下触发:

  1. 哈希表负载因子过高:哈希表中的键值对数量接近或超过哈希表容量。
  2. 哈希表负载因子过低:在大量键值对被删除后,哈希表的利用率下降到一定程度。

2. 哈希表结构

在 Redis 中,每个 dict(字典)包含两个哈希表实例 ht[0]ht[1],rehash 过程中会使用这两个哈希表:

  • ht[0]:现有的哈希表。
  • ht[1]:rehash 过程中使用的新哈希表。

3. 渐进式 rehash 过程

渐进式 rehash 通过在常规操作(如插入、删除、查找)中逐步迁移键值对,以避免一次性 rehash 导致的性能抖动。

redigo 并发使用

不使用连接池的话 redigo 并发(redigo 执行 Do Send 命令的时候不能并发)下会 redis conn 出错断开。

redis 问题排查

info Clients

  • connected_clients 当前正在连接数量
    redis 抓包
sudo tcpdump -i any tcp and port 6379 -n -nn -s0 -tttt -w redis.cap

查看当前连接

redis-cli  -a "password"  CLIENT LIST|grep -v watch | sort -t " " -k 1
netstat -na | grep 6379 | grep TIME_WAIT | wc -l

空闲连接 自动断开时间

config get timeout
config set timeout 600