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服务器会携带 ReplicationId 和 offset , 供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