Redis高并发高可用的分布式缓存

Redis分布式缓存

单节点Redis存在问题:

1
2
3
4
5
6
7
8
9
10
11
-数据丢失问题: 内存存储, 服务重启可能丢失数据
实现数据持久化

-并发能力问题: 高并发场景单节点无法支持
搭建主从集群, 实现读写分离

-故障恢复问题: 宕机后服务不可用, 需要自动故障恢复/持续可用
Redis哨兵, 实现健康监测和自动恢复

-存储能力问题: 基于内存, 存储能力难以满足海量数据要求
搭建分片集群, 利用插槽机制实现动态扩容

Redis持久化

RDB持久化

RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。

快照文件称为RDB文件,默认是保存在当前运行目录。

执行时机

RDB持久化在以下四种情况下会执行:

  • 执行save命令

  • 执行bgsave命令

    image-20220628070054027

  • Redis停机时

    Redis停机时会执行一次save命令,实现RDB持久化。

  • 触发RDB条件时

    1
    2
    3
    4
    # redis.conf 文件
    save 3600 1
    save 300 100
    save 60 10000

    3600秒内,如果至少有1个key被修改,则执行bgsave , 如果是save "" 则表示禁用RDB

其他配置

1
2
3
4
5
6
7
8
# bgsave报错后停止接收写操作
stop-writes-on-bgsave-error yes
# 是否压缩 ,建议不开启,压缩也会消耗cpu
rdbcompression yes
# RDB文件名称
dbfilename dump.rdb
# rdb文件保存的路径目录
dir ./

RDB操作原理

bgsave开始时会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入 RDB 文件。用新RDB文件替换旧的RDB文件

fork采用的是copy-on-write技术:

  • 当主进程执行读操作时,访问共享内存;

  • 当主进程执行写操作时,则会拷贝一份数据,执行写操作。避免影响子进程

    image-20220628073321541

缺点

  • RDB执行间隔时间长,两次RDB之间写入数据有丢失的风险
  • fork子进程、压缩、写出RDB文件都比较耗时

AOF持久化

AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。

image-20220628080321105

AOF配置

1
2
3
4
5
6
7
8
9
10
11
12
# 是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"

# AOF命令执行频率, 三选一
# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no

三种策略对比:

配置项 刷盘时机 优点 缺点
Always 同步刷盘 可靠性高,几乎不丢数据 性能影响大
everysec 每秒刷盘 性能适中 最多丢失1秒数据
no 操作系统控制 性能最好 可靠性较差,可能丢失大量数据

AOF文件重写

因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。

image-20220628080642059

1
2
如图,AOF原本有三个命令,但是set num 123 和 set num 666都是对num的操作,第二次会覆盖第一次的值,因此第一个命令记录下来没有意义。
所以重写命令后,AOF文件内容就是:mset name jack num 666

Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:

1
2
3
4
# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb

RDB与AOF对比

RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用。

RDB AOF
持久化方式 定时对整个内存做快照 记录每一次执行的命令
数据完整性 不完整, 两次备份之间会丢失 相对完整, 取决于刷盘fsync策略
文件大小 会有压缩, 文件体积小 记录命令, 文件体积很大
宕机恢复速度 很快
数据恢复优先级 低, 因为数据完整性不如AOF 高, 因为数据完整性更高
系统资源占用 高, 大量CPU和内存消耗 低, 主要是磁盘IO资源, 但AOF重写时会占用大量CPU和内存资源
使用场景 可以容忍数分钟的数据丢失, 追求更快的启动速度/异地容灾 对数据安全性要求较高常见

Redis主从

单机安装Redis

修改redis.conf文件

1
2
3
4
5
6
# 绑定地址,默认是127.0.0.1,会导致只能在本地访问。修改为0.0.0.0则可以在任意IP访问
bind 0.0.0.0
# 保护模式,关闭保护模式
protected-mode no
# 数据库数量,设置为1
databases 1

搭建集群

image-20220629003406235

集群包含三个节点, 一个主节点, 两个从节点:

IP PORT 角色
192.168.253.138 16379 master
192.168.253.136 16379 replica
192.168.253.137 16379 replica

修改redis.conf文件

1
2
3
4
5
6
7
8
# 开启RDB
save 3600 1
save 300 100
save 60 10000
# 关闭AOF
appendonly no
# 声明当前服务所在IP
replica-announce-ip 192.168.253.138

开启主从关系

现在三个实例还没有任何关系,要配置主从可以使用replicaof 或者slaveof(5.0以前)命令。

有临时和永久两种模式:

  • 修改配置文件(永久生效)

    在redis.conf中添加一行配置:slaveof <masterip> <masterport>

    修改从节点redis.conf文件

    1
    2
    3
    4
    # 开启主从
    salveof 192.168.253.138 16379
    # 主服务器密码
    masterauth xxxxxxxx
  • 使用redis-cli客户端连接到redis服务,执行slaveof命令(重启后失效):

    1
    replicaof <masterip> <masterport>

在5.0以后新增命令replicaof,与salveof效果一致。

查看主从状态

1
2
3
4
# 连接 192.168.253.138 16379
redis-cli -h 192.168.253.138 -p 16379
# 查看状态
info replication

image-20220629004316936

数据同步原理

主从同步由slave服务器发起, 总是请求部分同步(psync), 由master服务器判断全量还是部分同步

image-20220629041928011

主从同步参数

slave服务器会携带 ReplicationIdoffset , 供master服务器判断是否为第一次同步, 是否需要同步

  • Replication Id

    简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid

  • offset

    偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。

image-20220629042344127

主从同步流程

  • slave节点请求增量同步

  • master节点判断replid,发现不一致,拒绝增量同步

  • master将完整内存数据生成RDB,发送RDB到slave

  • slave清空本地数据,加载master的RDB

  • master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave

  • slave执行接收到的命令,保持与master之间的同步

repl_baklog大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于log做增量同步,只能再次全量同步。

主从同步配置优化

  • 在master中配置repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO。

  • Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO

  • 适当通过配置repl-backlog-size提高repl_baklog的大小,默认 1mb, 发现slave宕机时尽快实现故障恢复,尽可能避免全量同步

  • 限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,减少master压力

Redis哨兵

Redis提供了哨兵(Sentinel)机制实现主从集群的自动故障恢复

哨兵的作用及原理

哨兵作用

  • 监控

    Sentinel 会不断检查您的master和slave是否按预期工作

  • 自动故障恢复

    如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主

    需要将预设master提前配置密码(masterauth xxxxxx), 否则宕机修复后会重复请求当前master

  • 通知

    Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端

服务状态监控

Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:

  • 主观下线(sdown):如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线

  • 客观下线(odown):若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。

image-20220629073102612

选举机制

一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:

1
2
3
4
5
6
7
- 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点

- 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举

- 如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高

- 最后判断slave节点的运行id大小,越小优先级越高。

故障转移

当选中了其中一个slave为新的master后(例如slave1),故障的转移的步骤如下:

1
2
3
4
5
- sentinel给备选的slave1节点发送slaveof no one命令,注释redis.conf文件中的原有slaveof数据, 让该节点成为master

- sentinel给所有其它slave发送slaveof 192.168.253.137 16379 命令,修改redis.conf文件中的原有slaveof数据, 让这些slave成为新master的从节点,开始从新的master上同步数据。

- 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点

image-20220629073639351

搭建哨兵集群

搭建一个三节点形成的Sentinel集群,来监管之前的Redis主从集群:

image-20220629074853804

三个sentinel实例信息如下:

节点 IP PORT
s1 192.168.253.141 26379
s2 192.168.253.142 26379
s3 192.168.253.143 26379

修改sentinel.conf配置文件

1
vi /usr/local/src/redis-6.2.6/sentinel.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 指定sentinel实例服务端口
port 26379
# 配置后台守护进程运行
daemonize yes
# 记录日志
logfile "/var/log/sentinel.log"
# 执行目录
dir /tmp
# 绑定IP
sentinel announce-ip 192.168.253.141
# 指定监听redis-server的master节点信息
sentinel monitor mymaster 192.168.253.138 16379 2
# 指定redis-server服务密码
sentinel auth-pass mymaster xxxxxxxx
# 指定redis-server服务心跳停止多久时间后进行故障转移
sentinel down-after-milliseconds mymaster 5000
# 指定故障转移超时时间
sentinel failover-timeout mymaster 60000

sentinel monitor mymaster 192.168.253.138 16379 2:指定主节点信息

  • mymaster:主节点名称,自定义,任意写
  • 192.168.253.138 16379:主节点的ip和端口
  • 2:选举master时的quorum值

启动

1
$ /usr/local/bin/redis-sentinel /usr/local/src/redis-6.2.6/sentinel.conf

image-20220629082401357

image-20220629080906682

Redis分片集群

主从/哨兵机制可以解决高可用、高并发问题. 但仍然存在海量数据存储问题高并发写情况下的主从同步问题, 使用Cluster分片集群可以很好地解决问题

image-20220629222526876

分片集群特征:

  • 集群中有多个master,每个master保存不同数据

  • 每个master都可以有多个slave节点

  • master之间通过ping监测彼此健康状态

  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点

搭建分片集群

提供六台服务器, 安装Redis服务, 模拟分片集群

IP PORT 角色
192.168.253.136 16379 master
192.168.253.137 16379 master
192.168.253.138 16379 master
192.168.253.141 16379 slave
192.168.253.142 16379 slave
192.168.253.143 16379 slave

配置redis.con文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
port 16379
# 开启集群功能
cluster-enabled yes
# 集群的配置文件路径名称,不需要我们创建,由redis自己维护
cluster-config-file nodes.conf
# 节点心跳失败的超时时间
cluster-node-timeout 5000
# 持久化文件存放目录
dir /usr/local/src/redis-6.2.6/work
# 绑定地址
bind 0.0.0.0 -::1
# 让redis后台运行
daemonize yes
# 配置主节点访问密码
masterauth 123456
# 注册的实例ip
replica-announce-ip 192.168.253.xxx
# 保护模式
protected-mode no
# 数据库数量
databases 1
# 日志
logfile /var/log/redis.log

启动分片, 创建集群

我们需要执行命令来创建集群,在Redis5.0之前创建集群比较麻烦,5.0之后集群管理命令都集成到了redis-cli中。

  • Redis5.0之前

    Redis5.0之前集群命令都是用redis安装包下的src/redis-trib.rb来实现的。因为redis-trib.rb是有ruby语言编写的所以需要安装ruby环境。

    1
    2
    3
    # 安装依赖
    sudo apt install zlib ruby rubygems
    gem install redis

    然后通过命令来管理集群:

    1
    2
    3
    4
    # 进入redis的src目录
    cd /usr/local/src/redis-6.2.6/src
    # 创建集群
    ./redis-trib.rb create --replicas 1 192.168.253.136:16379 192.168.253.137:16379 192.168.253.138:16379 192.168.253.141:16379 192.168.253.142:16379 192.168.253.143:16379 -a 123456
  • Redis5.0以后

    集群管理以及集成到了redis-cli中,格式如下:

    1
    2
    3
    4
    # 重启redis, 读取配置
    sudo systemctl restart redis
    # 执行分片命令
    > redis-cli --cluster create --cluster-replicas 1 192.168.253.136:16379 192.168.253.137:16379 192.168.253.138:16379 192.168.253.141:16379 192.168.253.142:16379 192.168.253.143:16379 -a 123456

    命令说明:

    • redis-cli --cluster或者./redis-trib.rb:代表集群操作命令
    • create:代表是创建集群
    • --replicas 1或者--cluster-replicas 1 :指定集群中每个master的副本个数为1,此时节点总数 ÷ (replicas + 1) 得到的就是master的数量。因此节点列表中的前n个就是master,其它节点都是slave节点,随机分配到不同master

image-20220629221724823

查看集群状态

1
2
$ redis-cli -c -h 192.168.253.136 -p 16379 -a 123456
> cluster info

image-20220629221844279

1
$ redis-cli -c -h 192.168.253.136 -p 16379 -a 123456 cluster nodes

image-20220630023630378

错误处理

1
2
# 报错 (error) CLUSTERDOWN Hash slot not served
redis-cli -a 123456 --cluster fix 192.168.253.136:16379

关闭集群

1
printf '%s\n' 136 137 138 141 142 143 | xargs -I{} -t redis-cli -h 192.168.253.{} -p 16379 -a 123456 shutdown

image-20220629224002093

散列插槽

运行原理

Redis会把每一个master节点映射到0~16383共16384个插槽hash slot上,查看集群信息时就能看到:

image-20220630024330272

数据key不是与节点绑定,而是与插槽绑定。redis会根据key的有效部分计算插槽值,分两种情况:

  • key中包含”{}“,且“{}”中至少包含1个字符,“{}”中的部分是有效部分
  • key中不包含“{}”,整个key都是有效部分

实例

例如:key是num,那么就根据num计算,如果是{order}num,则根据order计算。计算方式是利用CRC16算法得到一个hash值,然后对16384取余,得到的结果就是slot值。

image-20220630024613851

总结

Redis如何判断某个key应该在哪个实例?

1
2
3
- 将16384个插槽分配到不同的实例
- 根据key的有效部分计算哈希值,对16384取余
- 余数作为插槽,寻找插槽所在实例即可

如何将同一类数据固定的保存在同一个Redis实例?

1
使用相同的有效部分,例如key都以{typeId}为前缀

集群伸缩

集群控制通过使用redis-cli --cluster 命令执行, 具体参数查看help文档

1
$ redis-cli --cluster help
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
Cluster Manager Commands:
create host1:port1 ... hostN:portN
--cluster-replicas <arg>
check host:port
--cluster-search-multiple-owners
info host:port
fix host:port
--cluster-search-multiple-owners
--cluster-fix-with-unreachable-masters
reshard host:port
--cluster-from <arg>
--cluster-to <arg>
--cluster-slots <arg>
--cluster-yes
--cluster-timeout <arg>
--cluster-pipeline <arg>
--cluster-replace
rebalance host:port
--cluster-weight <node1=w1...nodeN=wN>
--cluster-use-empty-masters
--cluster-timeout <arg>
--cluster-simulate
--cluster-pipeline <arg>
--cluster-threshold <arg>
--cluster-replace
add-node new_host:new_port existing_host:existing_port
--cluster-slave
--cluster-master-id <arg>
del-node host:port node_id
call host:port command arg arg .. arg
--cluster-only-masters
--cluster-only-replicas
set-timeout host:port milliseconds
import host:port
--cluster-from <arg>
--cluster-from-user <arg>
--cluster-from-pass <arg>
--cluster-from-askpass
--cluster-copy
--cluster-replace
backup host:port backup_directory
help

For check, fix, reshard, del-node, set-timeout you can specify the host and port of any working node in the cluster.

Cluster Manager Options:
--cluster-yes Automatic yes to cluster commands prompts

删除节点

1
$ redis-cli -p 16379 -a 123456 --cluster del-node 192.168.253.136:16379 3926c0d86253cd6ee7c347c1ee3bff6fa6ea2286

image-20220630103113197

添加节点

1
2
$ redis-cli -h 192.168.253.138 -p 16379 -a 123456 --cluster add-node 192.168.253.136:16379 192.168.253.138:16379
# 不添加--cluster-slave 选项, 则添加节点136默认为master节点, 且未分配slot插槽

image-20220630103516343

1
2
# 分配插槽
$ redis-cli -h 192.168.253.138 -p 16379 -a 123456 --cluster reshard 192.168.253.141:16379

image-20220630104244715

故障转移

master节点宕机

当节点宕机后, cluster集群会自动执行故障转移

1
2
3
4
# 监听分片节点状态
$ watch redis-cli -h 192.168.253.141 -p 16379 -a 123456 cluster nodes
# 对137 master节点宕机
$ redis-cli -h 192.168.253.137 -p 16379 -a 123456 shutdown
  • 节点疑似宕机

    image-20220630123906382

  • 节点确定下线, 下属slave 143节点提升为master节点

    image-20220630124134801

重启宕机节点

1
2
# 137节点重启
$ sudo systemctl restart redis
  • 重启后137节点变为slave

    image-20220630124546291

恢复master节点身份

手动故障转移, 利用cluster failover命令可以手动让集群中的某个master宕机,切换到执行cluster failover命令的这个slave节点,实现无感知的数据迁移。其流程如下:

image-20220630124810931

1
$ redis-cli -h 192.168.253.137 -p 16379 -a 123456 cluster failover

image-20220630124856261

1
2
3
4
手动的Failover支持三种不同模式:
- 缺省:默认的流程,如图1~6歩
- force:省略了对offset的一致性校验
- takeover:直接执行第5歩,忽略数据一致性、忽略master状态和其它master的意见

多级缓存

多层缓存框架:

image-20220630155850718

OpenResty

OpenResty® 是一个基于 Nginx的高性能 Web 平台,用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。具备下列特点:

  • 具备Nginx的完整功能

  • 基于Lua语言进行扩展,集成了大量精良的 Lua 库、第三方模块

  • 允许使用Lua自定义业务逻辑、自定义库

1
官方网站: https://openresty.org/cn/

安装及使用介绍请查看本文档集合 Nging中的Lua操作简介 - ngx_lua模块 - OpenResty文档

实现请求接口访问路由

  • Nginx代理服务器配置

    修改nginx/nginx.conf

    1
    $ vi /etc/nginx/nginx.conf
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    http {
    include mime.types;
    default_type application/octet-stream;
    sendfile on;
    keepalive_timeout 65;
    # 反向代理
    upstream nginx-cluster{
    hash $request_uri; # 采取hash策略, 确保同一URI缓存命中
    server 192.168.253.136;
    server 192.168.253.137;
    }
    server {
    listen 80;
    server_name localhost;

    location / {
    root html;
    index index.html index.htm;
    }
    location /api {
    proxy_pass http://nginx-cluster;
    }
    }
    }
  • Nginx负载均衡服务器配置

    OpenResty监听请求

    1
    $ vi /usr/local/openresty/nginx/nginx.conf
    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
    http {
    include mime.types;
    default_type application/octet-stream;
    sendfile on;
    keepalive_timeout 65;
    # 加载lua 模块  
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";  
    # 加载c模块 
    lua_package_cpath "/usr/local/openresty/lualib/?.so;;";

    # 共享字典,也就是本地缓存,名称叫做:item_cache,大小150m
    lua_shared_dict item_cache 150m;
    server {
    listen 80;
    server_name localhost;

    location / {
    root html;
    index index.html index.htm;
    }
    location ~ /api/item(\s+) {
    # 响应类型,这里返回json
          default_type application/json;
      # 响应数据由 lua/item.lua这个文件来决定
          content_by_lua_file lua/item.lua;
      }
    }
    }

编写Lua脚本

在负载均衡服务器上编写Lua脚本实现业务

1.预先封装通用函数
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
-- vi /usr/local/openresty/lualib/common.lua
-- 导入redis
local redis = require('resty.redis')
-- 初始化redis
local red = redis:new()
red:set_timeouts(1000, 1000, 1000)

-- 关闭redis连接的工具方法,其实是放入连接池
local function close_redis(red)
local pool_max_idle_time = 10000 -- 连接的空闲时间,单位是毫秒
local pool_size = 100 --连接池大小
local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)
if not ok then
ngx.log(ngx.ERR, "放入redis连接池失败: ", err)
end
end

-- 查询redis的方法 ip和port是redis地址,key是查询的key
local function read_redis(ip, port, key)
-- 获取一个连接
local ok, err = red:connect(ip, port)
if not ok then
ngx.log(ngx.ERR, "连接redis失败 : ", err)
return nil
end
-- 查询redis
local resp, err = red:get(key)
-- 查询失败处理
if not resp then
ngx.log(ngx.ERR, "查询Redis失败: ", err, ", key = " , key)
end
--得到的数据为空处理
if resp == ngx.null then
resp = nil
ngx.log(ngx.ERR, "查询Redis数据为空, key = ", key)
end
close_redis(red)
return resp
end

-- 封装函数,发送http请求,并解析响应
local function read_http(path, params)
local resp = ngx.location.capture(path,{
method = ngx.HTTP_GET,
args = params,
})
if not resp then
-- 记录错误信息,返回404
ngx.log(ngx.ERR, "http请求查询失败, path: ", path , ", args: ", args)
ngx.exit(404)
end
return resp.body
end
-- 将方法导出
local _M = {
close_redis = close_redis,
read_redis = read_redis,
read_http = read_http
}
return _M
2.实现服务Lua脚本

查询顺序: Nginx本地缓存->Redis缓存->服务缓存->数据库

1
vi /usr/local/openresty/lualib/item.lua
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
-- 引入cjson模块
local cjson = require "cjson"
-- 引入自定义common工具模块,返回值是common中返回的 _M
local common = require("common")
local read_http = common.read_http
local read_redis = common.read_redis
-- 导入共享词典,本地缓存
local item_cache = ngx.shared.item_cache

-- 封装查询函数
function read_data(key, expire, path, params)
-- 查询本地缓存
local val = item_cache:get(key)
if not val then
ngx.log(ngx.ERR, "本地缓存查询失败,尝试查询Redis, key: ", key)
-- 查询Redis缓存
local val = read_redis("127.0.0.1", 6379, key)
-- 判断查询结果
if not val then
ngx.log(ngx.ERR, "redis查询失败,尝试查询http, key: ", key)
-- redis查询失败,去查询http
val = read_http(path, params)
end
end
-- 查询成功,把数据写入本地缓存
item_cache:set(key, val, expire)
-- 返回数据
return val
end

-- 获取路径参数id
local id = ngx.var[1]

-- 查询商品信息
local itemJSON = read_data("item:id:" .. id, 1800, "/item/" .. id, nil)
-- 查询库存信息
local stockJSON = read_data("item:stock:id:" .. id, 60, "/item/stock/" .. id, nil)

-- JSON转化为lua的table
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)

-- 组合数据
item.stock = stock.stock
item.sold = stock.sold

-- 把item序列化为json 返回结果
ngx.say(cjson.encode(item))

缓存预热

可以通过服务代码编写, 在项目启动时执行, 将统计热点数据存入Redis缓存,

也可以通过编写Lua脚本预热数据

缓存同步

缓存同步策略

缓存数据同步的常见方式有三种:

  • 设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新

    • 优势:简单、方便

    • 缺点:时效性差,缓存过期之前可能不一致

    • 场景:更新频率较低,时效性要求低的业务

  • 同步双写:在修改数据库的同时,直接修改缓存

    • 优势:时效性强,缓存与数据库强一致

    • 缺点:有代码侵入,耦合度高;

    • 场景:对一致性、时效性要求较高的缓存数据

  • 异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据

    • 优势:低耦合,可以同时通知多个缓存服务
    • 缺点:时效性一般,可能存在中间不一致状态
    • 场景:时效性要求一般,有多个服务需要同步

实现方式

  • 基于MQ的异步通知流程

    image-20220630195602535

  • 基于Canal的异步通知流程

    Canal- 伪装为数据库slave节点, 依据监听binlog, 实现修改数据后同步缓存的动作

image-20220630195703580

Canal

**Canal **,译意为水道/管道/沟渠,canal是阿里巴巴旗下的一款开源项目,基于Java开发。基于数据库增量日志解析,提供增量数据订阅&消费。GitHub的地址:https://github.com/alibaba/canal

Canal是基于mysql的主从同步来实现的,MySQL主从同步的原理如下:

1
2
3
- MySQL master 将数据变更写入二进制日志( binary log),其中记录的数据叫做binary log events
- MySQL slave 将 master 的 binary log events拷贝到它的中继日志(relay log)
- MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据

Canal就是把自己伪装成MySQL的一个slave节点,从而监听master的binary log变化。再把得到的变化信息通知给Canal的客户端,进而完成对其它数据库的同步。

image-20220630203608645

目标数据库配置
  • 配置文件修改

    1
    2
    3
    4
    5
    6
    7
    8
    # $ sudo vi /etc/mysql/mysql.conf.d/mysqld.cnf
    server-id = 100
    log_bin = /var/log/mysql/mysql-bin.log
    expire_logs_days = 10
    max_binlog_size = 100M
    #binlog_do_db = include_database_name
    binlog_do_db = redis_project
    # $ sudo service mysql restart

    配置解读:

    • log-bin=/var/lib/mysql/mysql-bin.log:设置binary log文件的存放地址和文件名
    • binlog-do-db=redis_project:指定对哪个database记录binary log events
  • 创建Canal专用用户, 设定权限

    1
    2
    3
    create user canal@'%' IDENTIFIED by 'canal';
    GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%' identified by 'canal';
    FLUSH PRIVILEGES;
  • 查看master状态

    1
    show master status;

    image-20220630204741687

安装Canal
1
2
3
# 安装Java环境
$ sudo apt install openjdk-8-jdk
$ sudo update-alternatives --config java # 切换为java 8
1
2
3
4
$ wget https://github.com/alibaba/canal/releases/download/canal-1.1.5/canal.deployer-1.1.5.tar.gz 
$ tar -zxvf canal.deployer-1.1.5.tar.gz
$ tar zxvf canal.deployer-1.1.5.tar.gz
$ mv canal/ /usr/local/src/
配置Canal
1
2
3
# $ vi /usr/local/src/canal/conf/canal.properties
canal.ip =192.168.253.134
canal.destinations = example
1
2
3
4
5
6
7
8
# $ vi /usr/local/src/canal/conf/example/instance.properties
canal.instance.gtidon=false
canal.instance.master.address=192.168.253.134:3306
canal.instance.tsdb.enable=true
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.instance.connectionCharset = UTF-8
canal.instance.filter.regex=redis_project\\..*

canal.instance.filter.regex=:要监听的表名称. 表名称监听支持的语法:

1
2
3
4
5
6
7
8
mysql 数据解析关注的表,Perl正则表达式.
多个正则之间以逗号(,)分隔,转义符需要双斜杠(\\)
常见例子:
1. 所有表:.* or .*\\..*
2. canal schema下所有表: canal\\..*
3. canal下的以canal打头的表:canal\\.canal.*
4. canal schema下的一张表:canal.test1
5. 多个规则组合使用然后以逗号隔开:canal\\..*,mysql.test1,mysql.test2
运行Canal
1
2
$ cd /usr/local/src/canal
$ ./bin/startup.sh
查看运行状态

image-20220630220358545

image-20220630220415716

代码处理
1
$ composer require xingwenge/canal_php
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
<?php

use xingwenge\canal_php\CanalClient;
use xingwenge\canal_php\CanalConnectorFactory;
use xingwenge\canal_php\Fmt;

require_once 'vendor/autoload.php';

try {
$client = CanalConnectorFactory::createClient(CanalClient::TYPE_SOCKET_CLUE);

$client->connect("192.168.253.134", 11111);
$client->checkValid();
$client->subscribe("1001", "example", ".*\\..*");

while (true) {
$message = $client->get(100);
if ($entries = $message->getEntries()) {
foreach ($entries as $entry) {
var_dump($entries);
Fmt::println($entry);
}
break;
}

sleep(1);
}

$client->disConnect();
} catch (\Exception $e) {
echo $e->getMessage(), PHP_EOL;
}
修改数据库数据显示

image-20220630220542533

最终方案流程

image-20220630221530358