Eli's Blog

1. 概述

Redis:Remote Dictionary Server,高性能非关系型(NoSQL)键值对数据库

Redis特性:

  • key-value 存储
  • 支持数据可靠性存储及落地
  • 单进程但线程高性能服务器
  • crash safe & recovery slow
  • 单机qps可达10W
  • 适合小数据量高速读写访问

1.1 应用场景

  • 高速缓存

    • 高频次,热门数据,降低数据库IO
    • 分布式,做session共享
  • 多样化数据结构

    • 最新N个数据:通过List实现按时间排序的数据
    • 排行榜,top N:zset有序集合
    • 时效性数据,验证码:Expire过期
    • 计算器,秒杀:原子性,INCR, DECR
    • 大数据量去重:set集合
    • 队列:list
    • 发布订阅:pub/sub

1.2 安装

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# 1. 编译工具
apt install -y build-essential pkg-config libssl-dev

# 2. 下载
wget https://download.redis.io/releases/redis-6.2.5.tar.gz
tar zxvf redis-6.2.5.tar.gz

# 3. 编译
cd redis-6.2.5
make MALLOC=libc BUILD_TLS=yes

# 4. 创建redis系统用户
adduser --system --group --no-create-home redis

# 5. 拷贝编译后的命令
cd src
cp redis-server redis-cli redis-benchmark redis-check-aof redis-check-rdb /usr/local/bin/

# 6. 创建数据目录
mkdir /var/lib/redis
chown -R redis:redis /var/lib/redis
chmod 770 /var/lib/redis

# 7. 创建日志目录
mkdir /var/log/redis
chown -R redis:redis /var/log/redis

# 8. 运行PID目录
mkdir /var/run/redis
chown -R redis:redis /var/run/redis

# 9. 配置文件
mkdir -p /etc/redis
cp ../redis.conf /etc/redis

vi /etc/redis/redis.conf
bind 0.0.0.0 ::
daemonize yes
supervised systemd
pidfile /var/run/redis_6379.pid
logfile "/var/log/redis/redis.log"
dir /var/lib/redis/


# 10. 开机启动
cat > /etc/systemd/system/redis.service <<EOF
[Unit]
Description=Redis data structure server
Documentation=https://redis.io/documentation

[Service]
Type=forking
ExecStart=/usr/local/bin/redis-server /etc/redis/redis.conf
ExecStop=/usr/local/bin/redis-cli shutdown
ExecReload=/bin/kill -s HUP \$MAINPID
PrivateTmp=true
RestartSec=10
User=redis
Group=redis
LimitCORE=infinity
LimitNOFILE=10032
LimitNPROC=10032
Restart=always

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl start redis
systemctl status redis
systemctl enable redis

1.3 相关知识

Redis: 单线程 + 多路 IO 复用

多路复用:使用一个线程来检查多个文件描述符 (socket) 的就绪状态,比如调用select 和pol函数,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后,进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如线程池)

1.4 管理命令

1
2
3
4
5
6
7
8
9
10
11
12
13
keys *
exists KEY
type KEY
del KEY
unlink KEY # 非阻塞删除

expire KEY 10 # 10s后过期
ttl KEY # 过期剩余时间 -1:永不过期 -2: 已过期

dbsize # key总数量

flushdb # 清空当前库
flushall # 清空所有库

Redis-benchmark: 服务器性能测试

1
2
# 100个并发,100000次
redis-benchmark -h localhost -p 6379 -c 100 -n 100000

1.5 优缺点

优点:

  • 读写性能高
  • 支持持久化,RDB & AOF
  • 数据类型丰富,五种:string, list, set, sorted-set, hash
  • 支持简单事务
  • 支持TTL
  • 支持主从复制,可以进行读写分离

缺点:

  • 数据库容量受限物理内存(低于物理内存的60%),不能支持海量数据的高性能读写。

  • 不具备自动容错和恢复功能

  • 主节点宕机,可能会有部分数据未及时同步到从节点,导致数据不一致

  • 很难在线扩容,一般在系统上线前必须保有足够的空间

  • buffer io造成系统OOM

2. 数据类型

2.1 基础类型

  • string: 字符串操作、原子计数器等

  • hash: 以hashmap方式存储,可用来存储json对象。

  • list: 消息队列,timeline等

  • set:Unique去重操作。统计独立IP,好友推荐去重等

  • sorted-set: 排行榜,TOP N操作,带权重

跳表:一种随机化的数据结构,实质就是一种可以进行二分查找的有序链表。Redis中的set类型低层使用跳表实现。

2.1.1 String

数据结构:简单动态字符串 (Simple Dynamic String, SDS),是可以修改的,内部结构类似Golang的Slice,采用预先分配冗余空间方式来减少内存的频繁分配。扩容机制,长度小于1M,按两倍扩容;超过1M,扩容一次只新增1M。最大512M

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
get KEY

set KEY VALUE
setex KEY 10 VALUE

append KEY VALUE
strlen KEY

setnx KEY VALUE # 不存在才设置

# 原子性操作
incr KEY
decr KEY
incrby KEY 5
decrby KEY 3

mset k1 v1 k2 v2 ...
mget k1 k2 ...

2.1.2 List

数据结构:快速链表 quickList.

  • 在元素较少的情况下,使用一块连续的内存存储,结构为ziplist,即压缩列表。
  • 数据量较大时。会改成quickList,即链表存储,结构上有二外的指针prev 和 next

img

Redis 将链表和ziplist 集合起来组成quicklist,即满了快速插入删除性能,又不会出现太大的冗余空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
lpush KEY e1 e2 ...
rpush KEY e1 e2 ...

lpop KEY
rpop KEY

rpoplpush KEY1 KEY2 # KEY1列表右吐出一个附加到KEY左边

lrange KEY 0 -1

lindex KEY 2

llen KEY

linsert KEY before pivot element
linsert KEY after pivot element

lrem KEY count element # 从左边删除count个elment元素

lset KEY index element # 替换

2.1.3 Set

数据结构:string类型的无序集合,底层其实是一个value为null的hash表,所以添加、删除和查找的时间复杂度都是o(1)

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sadd KEY member...

smembers KEY
sismember KEY member

scard KEY #

srem KEY member...
spop KEY # 随机取出一个值
srandmember KEY count # 随机取出多个值,注意:不删除

smove source destination member # 将source中的member移动到destination

sinter k1 k2 # 交集
sunion k1 k2 # 并集
sdiff k1 k2 # 差集

2.1.4 Hash

数据结构:ziplist(压缩列表),hashtable(哈希表)。当field-value长度短且个数少时,使用ziplist,否则使用hashtable

1
2
3
4
5
6
7
8
9
10
11
12
hset k f1 v1 f2 v2  # hmset 效果一样
hget k f1
hmget k f1 f2 f3

hexists k f1

hkeys k
kvals k

hincrby k age 5

hsetnx k addr LA # 不存在时才添加

2.1.5 ZSet

数据结构:hashtable + skiplist

  • hash:关联member和score,保证member的唯一性,可直接通过member找到相应的score值
  • 跳跃表:根据score值,为member排序

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
zadd k 100 java 200 cpp 300 golang 400 python 500 php

zrange k 0 -1 # 按score从小到大排序

zrangebyscore k 200 400
zrevrangebyscore k 200 400

zincrby k 200 golang

zrem k php cpp

zcount k 100 300

zrank k golang # 排名

2.1.6 数据结构

img

2.1.6.1 hashTable

哈希表:由数组 + 链表构成的二维数据结构,数组是第一维,链表是第二维。数组中的每个元素称为槽或者桶,存储着链表的第一个元素的指针。

整体结构图:

img

一维数组:

img

二维链表:

img

扩容和缩容:

  • 扩容:元素个数等于一维数组长度时,会对数组进行两倍扩容
  • 缩容:元素个数小于一维数组的10%

img

扩缩容时,需要重新申请一维数组,并对所有元素重新hash并挂载到元素链表。Redis采用rehash策略:所有的字典结构内部首层时一个数组,数组的两个元素分别指向一个hashtable,正常情况下只有一个hashtable,而在迁移过程中,保留新旧两个hashtable,元素可能会在两个表中任意一个中,因此同时尝试从两个hashtable中查找数据。当数据搬迁完毕,旧的hashtable会被自动删除。

哈希函数:将key值打散的越均匀越好,高随机性的元素分布能够提升整体的查找效率。Redis的hash函数为siphash。hash函数打散效率如果很差或有迹可循,就会存在hash攻击,攻击者利用模式偏向性产生大量数据,并将这些数据挂载在同一个链表上,这种不均匀会导致查找性能急剧下降,同时浪费大量内存空间,导致Redis性能低下

2.1.6.2 跳跃表

img

根据score值插入紫色kv节点,首先从 kv-head 的最高层启动,判断指针的下一个元素的score值是否小于新元素的score,小于则继续向前遍历,否则从kv-head降一层,重新比较

img

寻找51:

  • 从第2层开始,1节点比51小,向后比较、
  • 21节点比51小,继续向后,但后面是NULL,则从21节点下降到第1层
  • 41节点比51小,继续向后,61节点比51大,则从41节点下降到第0层
  • 51节点即为所要查找的节点,共查找了四次

2.2 发布订阅

1
2
3
4
5
6
# 1. 订阅
subscribe mychannel

# 2. 发布消息
publish mychannel hello
publish mychannel world

2.3 新类型

2.3.1 Bitmaps

Bitmaps本身不是数据类型,实际它就是字符串,但可可以对字符串的位进行操作

注意:初始化bitmaps时,如果offset过大,整个初始化过程会较慢,可能会造成redis的阻塞

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
setbit permission 2 1
setbit permission 5 1

getbit permission 5

bitcount permission
bitcount permission 0 -2


setbit k1 2 1
setbit k1 3 1
setbit k1 4 1
setbit k1 5 1

setbit k2 0 1
setbit k2 1 1
setbit k2 2 1
setbit k2 3 1

bitop and k3 k1 k2
bitcount k3 # 2

bitop xor k4 k1 k2
bitcount k4 # 4

bitop or k5 k1 k2
bitcount k5 # 6

bitop not k6 k1
getbit k6 1 # 1
getbit k6 2 # 0

2.3.2 HyperLogLog (基数统计)

网站统计PV(PageView): 可使用 incr, incrby实现

但独立访客UV(UniqueVistor)、独立IP、搜索记录数等需要去重和计数的问题。

HyperLogLog 用来做基数统计,其优点是,在输入元素的数量或者体积非常大时,计算基数需要的空间是固定的,并且很小。

每个 HyperLogLog 键只需要花费 12KB内存,就可以计算接近 2^64个不同元素的基数。

HyperLogLog 只会根据输入的元素来计算基数,但不会存储输入元素,所以它不能像集合一样,返回输入的各个元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pfadd lang1 java
pfadd lang1 php
pfadd lang1 golang
pfadd lang1 python
pfadd lang1 golang
pfadd lang1 php
pfadd lang1 c++

pfcount lang1 # 5

pfadd lang2 swift
pfadd lang2 js
pfadd lang2 dart

pfmerge lang lang1 lang2
pfcount lang # 8

2.3.3 Geospatial

Geospatial 是一个2维坐标,即经纬度。

1
2
3
4
5
6
7
8
9
geoadd cities 121.47 31.23 Shanghai
geoadd cities 106.50 29.53 Chongqing
geoadd cities 114.05 22.52 Shenzhen

geopos cities Shanghai

geodist cities Shanghai Chongqing km

georadius cities 110 30 1000 km

3. 事务和锁

3.1 事务

Redis事务:是一个单独的隔离操作。事务中所有命令都会序列化,按顺序执行;在执行过程中,不会被其他客户端发送的命令请求打断。

作用:串行执行多个命令,防止其他命令插队

Redis事务特性:

  • 单独的隔离操作,一次性、顺序性、排他性的执行一个队列中的一系列命令
  • 没有隔离级别
  • 不保证原子性

相关命令:

  • Multi: 组队阶段
  • Exec:执行阶段
  • Discard: 放弃执行

Queued失败,无法执行:

1
2
3
4
5
> MULTI
> set k1 v1
> set k2 v2
> getset k3 v3 # 语法性错
> EXEC # 上述命令不会被执行

Queued成功,正常执行,跳过失败命令:

1
2
3
4
5
6
> MULTI
> set k1 v1
> set k2 v2
> incr k3 # 运行时异常
> set k4 v4
> EXEC # 上述命令被执行,执行incr时抛出错误,但不影响其他命令

3.2 锁

  • 悲观锁:每次操作,都上锁,别人不能操作,等我释放锁后,才能操作。MySQL中的行锁、表锁;读锁、写锁即为该类锁。
  • 乐观锁:使用版本机制,在操作前,所有人均能获得当前的版本,在提交操作时,比对用户用户操作的版本是否和当前系统中的版本一致,只有在一致的情况下,操作才能成功,并生成新的版本号。乐观锁适用于多读的的应用类型,这样可提高吞吐量。

Redis使用乐观锁,CAS: check-and-set 机制实现事务。

1
2
3
4
5
6
7
8
9
10
11
> set balance 10000

# session 1
> WATCH balance
> MULTI
> decrby balance 2000
> EXEC # 事务失败
> UNWATCH

# session 2
> decrby blanace 5000 # 在 session 1的 WATCH后,EXEC前操作

3.3 Lua

Redis 的乐观锁,在多写的情况下,复杂的事务操作提交失败,导致与预想不一致的情况发生

Lua脚本,很容易被 C/C++调用,也可反过来调用C/C++函数。解释器不超过200k,适合做嵌入式脚本语言。

在Redis中,可将复杂、多步调用操作,写为一个Lua脚本,一次提交给Redis执行,减少连接Redis的次数,提升性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
local uid=KEYS[1];
local pid=KEYS[2];
local stock="sk:"..pid..":stock";
local users="sk:"..pid..":users";

local grabbed=redis.call("ismember", users, uid);
if tonumber(grabbed) == then
return 2;
end

local num=redis.call("get", stock)
if tonumber(num) <= 0 then
return 0;
else
redis.call("decr", stock);
redis.call("sadd", users, uid);
return 1;
end

相关命令:

  • EVAL
  • EVALSHA
  • SCRIPT LOAD - SCRIPT EXISTS
  • SCRIPT FLUSH
  • SCRIPT KILL
1
2
3
4
EVAL script numkeys key [key …] arg [arg …]

EVAL "return KEYS[1]" 1 key1 # key:从eval的第三个参数开始算起
EVAL "return ARGV[1]" 0 value1 # 附加参数

4. 持久化

4.1 RDB (Redis Database)

  • 将数据以快照(snapshot)形式保存在磁盘上 (dump.db)

  • 触发数据快照的三种机制:

    • save: 手动持久化,将阻塞服务器,save期间,不能处理其他命令,直到持久化完毕

    • bgsave: 后台异步进行快照操作。它会fork一个子进程负责处理

    • 自动触发:

1
2
3
4
5
6
7
8
9
save 3600 1     # 1h内,至少有一个key变化,触发持久化
save 300 10
save 60 10000

stop-writes-on-bgsave-error yes
rdbcompression yes
rdbchecksum yes
dbfilename dump.db
dir /data/redis/db

快照保存过程:

  • redis调用fork,产生一个子进程
  • 父进程继续处理client请求,子进程负责将fork时刻整个内存数据库快照写入临时文件。
  • 子进程完成写入临时文件后,用临时文件替换原来的快照文件,然后子进程退出。

​ 问题:每次快照持久化都是将内存数据完整写入到磁盘,如果数据量较大,读写操作较多,必然会引起磁盘IO问题。

优势:

  • RDB文件紧凑,全量备份,适合用于进行备份和灾难恢复
  • 生成RDB时,Redis主进程fork一个子进程来处理保存工作,主进程不需要进行任何磁盘IO操作
  • RDB恢复比AOF快

劣势:RDB数据保存子进程可能来不及保存数据,导致数据丢失

4.2 AOF (append only file)

  • 以日志形式记录每个写操作(增量保存),追加写,不修改原有记录
  • aof的问题:aof文件会越来越大。可通过bgrewriteaof命令,将内存中的数据以命令方式保存到临时文件,同时fork一个子进程来重写aof文件,最后替换原来的文件。
  • AOF和RDB同时开启,系统默认取AOF的数据
1
2
3
4
5
6
7
8
9
10
11
appendonly yes
appendfilename "appendonly.aof"

# 三选一
appendsync always
appendsync everysec
appendsync no # 完全依赖操作系统,性能最好,但持久化可能丢数据

# 自动bgrewriteaof
auto-aof-rewrite-percentage 100 # 大于64M到100%,即超过128M时开始重写
auto-aof-rewrite-size 64mb

优势:

  • 数据不容易丢失
  • 日志文件过大时,会出现后台重写,不会影响客户端读写
  • 日志文件以命令可读方式记录,容易查找命令记录来恢复数据

劣势:

  • AOF日志文件比RDB文件大
  • AOF开启后,写的QPS会降低

重写压缩:

AOF文件持续增长过大时,会fork一个新的进程来重新日志文件。Redis4.0后的重写,就是把rdb快照,以二进制附加在新的aof头部,替换已有的历史数据。

4.3 方案选择

Snapchat性能更高,但可能会引起一定程度的数据丢失

建议:

  • 更新频繁,一致性要求较高,AOF策略为主
  • 更新不频繁,可以容忍少量数据丢失或错误,Snapshot为主

5. 分布式锁

1
2
3
4
setnx lock 1
del lock

SET lock 1 NX 30 EX # 上锁 + 设置过期时间

锁释放问题:别人可以去释放你加的锁,你也亦然。

解决方案:

  • UUID:锁的值设置为uuid,只在获取到的锁的值等于你设置的uid时,才允许释放锁

  • Lua脚本:

    1
    2
    3
    4
    5
    if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1]);
    else
    return 0;
    end

6. 过期策略

6.1 过期键删除策略

  • 定时过期:过期立即删除。对内存友好,但会占用大量CPU资源去处理过期的键,影响缓存的响应时间和吞吐量
  • 惰性过期:只有当访问一个key时,才会判断它是否过期,过期则清除。对内存不友好,无用key占用了大量内存。
  • 定期过期:每隔一定时间,扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。

Redis 同时使用 惰性过期定期过期 两种策略。

6.2 设置和取消过期

EXPIRE

PERSIST

6.3 过期机制

1
2
3
4
5
6
7
8
9
10
redis-cli
> flushdb
> keys *
> exists name
> set name tom
> ttl name # -1, 永不过期
> expire name 5 # 5s后过期

> set age 20
> expireat age 1555506769

过期机制:redis采用 Lazy Expriation 方式,即在访问key时判断是否过期,如果过期,则进行过期处理。其次,每秒对volation keys进行抽样测试,如果有过期键,那对所有过期key处理。

7. 内存淘汰策略

MySQL中2000w数据,redis中只存20w数据,如何保证redis中的数据都是热数据?

  • 全局键空间选择性移除:
    • noeviction:内存不足,写入新数据,报错
    • allkeys-lru:内存不足,写入新数据,将移除最近最少使用的key (最常用)
    • allkeys-random: 内存不足,写入新数据,将随机删除一个key
  • 带TTL的键空间选择性移除:
    • volatile-lru:内存不足,写入新数据,在设置了过期时间的键空间中,移除最近最少使用的key
    • volatile-random:内存不足,写入新数据,在设置了过期时间的键空间中,随机移除一个key
    • volatile-ttl:内存不足,写入新数据,在设置了过期时间的键空间中,移除更早过期的key

8. 集群方案

8.1 主从模式

主从同步特点:

  • 一个master可拥有多个slave
  • master可读写,并将变化的数据sync给slave
  • slave只读,接收master的sync数据
  • 缺点:master只有一个,如果挂掉,无法对外提供写服务

配置 salve:

1
2
replicaof 192.168.1.200 6379
masterauth <master-password>
1
2
3
4
redis-cli
> info
> monitor
> info replication # 查看集群状态

8.2 Sentinel 模式

  • 哨兵模式建立在主从模式之上

  • 当master挂掉,sentinel会在salve中选择一个作为master,并修改它们的配置文件,其他slave节点的配置文件也会同步修改

  • 当master恢复后,它将不再是master,而是做为slave接收新master同步数据

  • 多sentinel配置时,形成一个sentinel小集群,sentinel之间也会自动监控

配置:

1
2
3
sentinel monitor mymaster 192.168.1.200 6379
sentinel auth-pass mymaster 123456
sentinel down-after-milliseconds mymaster 30000 # 默认30s

启动:

1
/usr/local/bin/redis-sentinel /usr/local/reids/sentinel.conf

8.3 Cluster模式

  • 多个主从模式节点网络互联,数据共享
  • 客户端可连接任意一个master节点进行读写
  • 不支持同时处理多个key (MSET/MGET), 因为redis需要把key均匀分布在各个节点上,高并发下同时创建key-value会降低性能并导致不可预测行为
  • 支持在线增加、删除节点
  • Redis 集群没有使用一致性hash,而是引入额哈希槽的概念。Redis集群有16384(2^14)个哈希槽,每个key通过CRC16校验后对16384取模来决定放置在哪个槽,集群的每个节点负责一部分hash槽。
  • 数据库无法选择,都在0上

配置:

1
2
3
cluster-enabled yes
cluster-config-file node_6379.conf
cluster-node-timeout 15000

集群命令:

1
2
3
4
5
6
7
8
9
10
11
12
# 增加节点
> CLUSTER MEET 192.168.1.201 6380
> CLUSTER NODES

# 更改节点身份, 节点改为slave
> CLUSTER REPLICATE a8fdc205a9f19cc1c7507a60c4f01b13d11d7fd0

# 删除节点
> CLUSTER FORGET 40bd001563085fc35165329ea1ff5c5ecbdbbeef

# 保存配置
> CLUSTER SAVECONFIG

9. 缓存异常

9.1 雪崩 (大量key集中过期)

场景:服务器重启或大量缓存同一时期失效时,大量的流量会冲击到数据库上,数据库会因承受不了而当机。即缓存层出现了错误,所有数据请求到达存储层,导则存储层无法响应

解决方案:

  • 构建多级缓存架构:nginx缓存 + redis缓存 + 其他缓存
  • 使用锁或队列:可保证问题不出现,但不适合高并发情况
  • 设置过期标志更新缓存:记录缓存数据是否过期(设置提前量),如果过期会触发通知另外的线程去后台更新实际key的缓存
  • 将缓存失效时间分散开:可通过随机数生成随机时间,这样保证key不在同一时间内过期。

9.2 穿透 (缓存空值)

场景:用户查询某条数据,但redis中没有,即缓存未命中;继续向持久层数据库查询,还是没有,即本次查询失败。当大量查询失败时,导则持久层数据库压力过大,即为缓存穿透

解决方案:

  • 缓存空值:即数据不存在,依旧设置一个默认值到缓存中,但该key的过期时间较短。简单应急方案
  • 设置白名单:使用bitmaps定义一个可访问的名单,名单id作为bitmaps的偏移量,每次访问和bitmaps中的id进行比较,如果id不存在,则不允许访问。每次访问都要查询,效率不高
  • 布隆过滤器(Bloom Filter):是一个二进制向量(位图)和一系列随机映射函数(哈希函数)。布隆过滤器科研检测一个元素是否在一个集合中,其优点是空间效率和查询时间远超过一般的算法,缺点是有一定的错误识别率和删除困难。实现:将所有可能存在的数据哈希到以恶搞足够大的bitmaps中,一个一定不存在的数据会被这个bitmaps拦截掉,从而避免了对底层存储系统的查询压力。
  • 实时监控:当发现Redis的命中率开始急速降低,需要排查访问对象和访问的数据,和运维人员配合,设置黑名单限制访问。

9.3 击穿 (热门key过期)

场景:某个key非常热点,高并发访问它时,该key突然失效,导则高并发请求直接访问持久数据库,就像在屏障上凿了一个洞

解决方案:

  • 预先设置热门数据:在redis高峰访问前,把一些热门数据提前存入redis中,并加大这些热门数据key的过期时长

  • 实时调整:现场监控哪些热门数据,实时调整可以的过期时长

  • 使用互斥锁:缓存失效时,不立即查询数据库,先获取锁setnx mutex lock,成功后,查询数据库并设置缓存,删除mutex锁。缺点:访问效率会被降低

10. Redis优化

10.1 内存管理

1
2
3
4
5
6
7
# HashMap成员数量,小于配置,按紧凑格式存储,内存开销少,任意一个超过,就使用真实的HashMap存储,内存占用大
hash-max-zipmap-entries 64 # 成员数量少
hash-max-zipmap-value 512 # 成员长度小

# List
list-max-ziplist-value 64
list-max-ziplist-entries 512

10.2 持久化

选择aof,每个实例不要超过2G

10.3 优化方向

  • 进行master-slave主从同步配置,在出现服务故障时可切换
  • 在master禁用数据持久化,在slave上配置数据持久化
  • Memory+swap不足。此时dump会挂死,最终会导致机器挡掉。64-128GB内存, SSD硬盘。
  • 当使用的Memory超过60%,会使用swap,内存碎片大
  • 当达到最大内存时,会清空带过期时间的key,即使该key未过期
  • redis和DB同步,先写DB,后写redis,内存写速度快

Redis使用建议:

1
2
3
4
5
6
7
key:
object-type:id:field
length 10~20

value:
string 不超过2K
set, sortedset 元素个数不超过5000

11. Lua 脚本

11.1 优点

  • 减少网络开销:可以将多个请求通过脚本的形式一次发送,减少网络时延。
  • 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他请求插入。因此在脚本运行过程中无需担心会出现竞态条件,无需使用事务。
  • 复用:客户端发送的脚本会永久存在redis中,这样其他客户端可以复用这一脚本,而不需要使用代码完成相同的逻辑。

11.2 常用命令

  • EVAL
  • EVALSHA
  • SCRIPT LOAD - SCRIPT EXISTS
  • SCRIPT FLUSH
  • SCRIPT KILL

11.2.1 EVAL

1
2
EVAL script numkeys key [key …] arg [arg …]
- numkeys: key 的数量,当没有key时,为0

示例:

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
127.0.0.1:6379> eval "return KEYS[1]" 2 key1 key2
"key1"

127.0.0.1:6379> eval "return ARGV[1]" 0 value1 value2
"value1"

127.0.0.1:6379> eval "return {KEYS[1], ARGV[1], KEYS[2], ARGV[2]}" 3 k1 k2 k3 v1 v2 v3
1) "k1"
2) "v1"
3) "k2"
4) "v2"

# 设置值和过期时间
127.0.0.1:6379> eval "redis.call('set', KEYS[1], ARGV[1]); redis.call('expire', KEYS[1], ARGV[2]); return 1" 1 name jack 15
(integer) 1
127.0.0.1:6379> get name
"jack"
127.0.0.1:6379> ttl name
(integer) 11

# redis.pcall 忽略错误
127.0.0.1:6379> lpush queue 1 2 3
(integer) 3
127.0.0.1:6379> type queue
list
127.0.0.1:6379> eval "redis.call('get', 'queue')" 0
(error) ERR Error running script (call to f_e5becba52dbf557c9a67e5d618be2cd3ccc70ea1): @user_script:1: WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> eval "redis.pcall('get', 'queue')" 0
(nil)

11.2.2 SCRIPT LOAD & EVALSHA

SCRIPT LOAD script: 不会执行脚本,而是将其进行 SHA1 求校验值,并将其永久存储在服务器脚本缓存中。

EVALSHA sha1sum :任何客户端,都可的调用该命令,来执行上面的脚本。

1
2
3
4
5
6
7
8
127.0.0.1:6379> script load "redis.call('set', KEYS[1], ARGV[1]); redis.call('expire', KEYS[1], ARGV[2]); return 1"
"cecc687421671f6065277c7801e02f5125d444f9"
127.0.0.1:6379> evalsha cecc687421671f6065277c7801e02f5125d444f9 1 name john 30
(integer) 1
127.0.0.1:6379> get name
"john"
127.0.0.1:6379> ttl name
(integer) 21

11.2.3 SCRIPT EXISTS

SCRIPT EXISTS sha1 [sha1 ...]: 校验给定的 sha1 对应的脚本,是否被保存在缓存中

1
2
3
4
5
127.0.0.1:6379> script exists cecc687421671f6065277c7801e02f5125d444f9
1) (integer) 1

127.0.0.1:6379> script exists cecc687421671f6065277c7801e02f5125d444f0
1) (integer) 0

11.2.4 SCRIPT FLUSH

SCRIPT FLUSH sync|sync: 清空服务器所有的脚本缓存

11.2.5 SCRIPT KILL

杀死当前正在运行的 Lua 脚本,当且仅当这个脚本没有执行过任何写操作时,这个命令才生效。 它的主要用于终止运行时间过长的脚本,比如一个因为 BUG 而发生无限 loop 的脚本等。

如果当前正在运行的脚本已经执行过写操作,那么SCRIPT KILL,也无法将它杀死,因为这是违反 Lua 脚本的原子性执行原则的。在这种情况下,唯一可行的办法是使用SHUTDOWN NOSAVE命令,通过停止整个 Redis 进程来停止脚本的运行,并防止不完整(half-written)的信息被写入数据库中。

11.3 实例

11.3.1 CompareAndSet

1
2
3
4
5
6
7
8
9
10
local key = KEYS[1]
local val = redis.call("GET", key)

if val == ARGV[1]
then
redis.call('SET', KEYS[1], ARGV[2])
return 1
else
return 0
end

执行:

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> set name jack
OK

# 注意:通过"," 来分割 keys 和 values,且","前后必须加空格
redis-cli --eval ./redis_CompareAndSet.lua name , tom sara
(integer) 0

redis-cli --eval ./redis_CompareAndSet.lua name , jack sara
(integer) 1

11.3.2 IP 访问频率控制

1
2
3
4
5
6
7
8
9
10
11
local visitCount = redis.call('incr', KEYS[1])

if visitCount == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end

if visitCount > tonumber(ARGV[2]) then
return 0
end

return 1

执行:

1
2
3
4
5
6
7
8
9
10
root@k8s-master:~/redis# redis-cli --eval ./redis_LimitIPVisit.lua ip:192.168.1.10 , 10 3
(integer) 1
root@k8s-master:~/redis# redis-cli --eval ./redis_LimitIPVisit.lua ip:192.168.1.10 , 10 3
(integer) 1
root@k8s-master:~/redis# redis-cli --eval ./redis_LimitIPVisit.lua ip:192.168.1.10 , 10 3
(integer) 1
root@k8s-master:~/redis# redis-cli --eval ./redis_LimitIPVisit.lua ip:192.168.1.10 , 10 3
(integer) 0
root@k8s-master:~/redis# redis-cli --eval ./redis_LimitIPVisit.lua ip:192.168.1.10 , 10 3
(integer) 0