微服务开发与实战Day10 - Redis面试篇

一、Redis主从集群

1. 搭建主从集群

1.1 主从集群结构

单节点Redis的并发能力是有限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。

如图所示,集群中有一个master节点、两个slave节点(现在叫replica)。当我们通过Redis的Java客户端访问主从集群时,应该做好路由:

  • 如果是写操作,应该访问master节点,master会自动将数据同步给两个slave节点
  • 如果是读操作,建议访问各个slave节点,从而分担并发压力

1.2 搭建主从集群

步骤:

①将课前资料提供的docker-compose文件上传到root/redis文件夹(新建)下,构建主从集群:

文件内容如下:

version: "3.2"

services:
  r1:
    image: redis
    container_name: r1
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7001"]
  r2:
    image: redis
    container_name: r2
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7002"]
  r3:
    image: redis
    container_name: r3
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7003"]

②执行命令,运行集群:

cd redis
docker compose up -d

这里如果还没有加载redis镜像的,先把课前资料里的redis.tar上传到虚拟机root目录下,然后加载镜像,创建并运行容器。

③在宿主机通过os命令查看Redis进程:

ps -ef | grep redis

④配置主从关系

# Redis5.0以前
slaveof <masterip> <masterport>
# Redis5.0以后
replicaof <masterip> <masterport>

有临时和永久两种模式:

  • 永久生效:在redis.conf文件中利用slaveof命令指定master节点
  • 临时生效:直接利用redis-cli控制台输入slaveof命令,指定master节点

我们测试临时模式,首先连接r2,让其以r1为master:

docker exec -it r2 redis-cli -p 7002
slaveof 192.168.126.151 7001

查看节点信息

info replication

然后连接r3,让其以r1为master:

dcoker exec -it r3 redis-cli -p 7003
slaveof 192.168.126.151 7001

⑤测试

依次在r1、r2、r3节点上执行下面命令

set num 123

get num

只有r1节点上可以执行set命令(写操作),其他两个节点只能执行get命令(读操作)。实现了读写分离。

2. 主从同步原理

当主从第一次同步连接或端口重连时,从节点都会发送psync请求,尝试数据同步:

问题1:master如何得知slave是否时第一次来同步呢?

  • replicationID:简称replid,是数据集的标记,replid一致则是同一数据集。每一个master节点都有自己的唯一replid,slave则会继承master节点的replid
  • offset:偏移量,随着记录在repl_backlog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。

因此slave做数据同步,必须向master声明自己的replication id和offset,master才可以判断到达需要同步哪些数据。

由于我们在执行slaveof命令之前,所有redis节点都是master,有自己的replidoffset。当我们第一次执行slaveof命令,与master建立主从关系时,发送的replidoffset是自己的,与master肯定不一致。master判断发现slave发送来的replid与自己的不一致,说明这是一个全新的slave,就知道要做全量同步了。master会将自己的replidoffset都发送给这个slaveslave保存这些信息到本地。自此以后slavereplid就与master一致了。

因此,master判断一个节点是否是第一次同步的依据,就是看replid是否一致。

问题2:master怎么知道slave与自己的数据差异在哪里呢?

repl_baklog文件是一个固定大小的数组,只不过数组是环形的,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖。

repl_baklog中会记录Redis处理过的命令及offset,包括master当前的offset,和slave已经拷贝到的offset:

slave与master的offset之间的差异,就是slave需要增量拷贝的数据。

随着不断有数据写入,master的offset逐渐变大,slave也不断的拷贝,追赶master的offset:

直到数组被填满:

此时,如果有新的数据写入,就会覆盖数组中的旧数据。不过,旧的数据只要是绿色的,说明是已经被同步扫slave的数据,即便被覆盖了也没什么影响。因为未同步的仅仅是红色部分:

但是,如果slave出现网络阻塞,导致master的offset远远超过slave的offset:

如果master继续写入新数据,master的offset就会覆盖repl_backlog中旧的数据,直到slave现在的offset也覆盖:

棕色框中的红色部分,就是尚未同步,但是却已经被覆盖的数据。此时如果slave恢复,需要同步,却发现自己的offset没有了,无法完成增量同步了,只能做全量同步。

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

2.1 主从同步优化

可以从以下几个方面来优化Redis主从就集群:

  • 在master中配置repl_diskless_sync yes启用无磁盘复制,避免全量同步时的磁盘IO
  • Redis单节点的内存占用不要太大,减少RDB导致的过多磁盘IO
  • 适当提高repl_backlog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
  • 限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,减少master压力

总结

1. 简述全量同步和增量同步的区别?

  • 全量同步:master将完整内存数据生成RDB,发送RDB到slave
  • 增量同步:slave提交自己的offset到master,master获取repl_baklog中slave的offset之后的命令给slave

2. 什么时候执行全量同步?

  • slave节点第一次连接master节点时
  • slave节点断开时间太久,repl_baklog中的offset已经被覆盖时

3. 什么时候执行增量同步?

  • slave节点断开又恢复,并且在repl_baklog中能找到offset时

3. 哨兵原理

3.1 哨兵的作用

Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。哨兵的具体作用如下:

  • 监控:Sentinel会不断检查您的master和slave是否按预期工作
  • 自动故障切换:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
  • 通知:当集群发生故障转移时,Sentinel会将最新节点角色信息推送给Redis的客户端

3.2 服务状态监控

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

主观下线:如果某Sentinel节点发现某实例未在规定时间响应,则认为改实例主观下线。

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

选举新的master

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

  • 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点;
  • 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举(默认都是1);
  • 如果slave-priority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高;
  • 最后是判断slave节点的运行id大小,越小优先级越高

如何实现故障转移

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

  • sentinel给备选的slave1节点发送slaveof no one命令,让该节点成为master;
  • sentinel给所有其它slave发送slaveof 192.168.126.151 7002命令,让这些slave成为新master的从节点,开始从新的master上同步数据;
  • 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点。

4. 哨兵集群

4.1 搭建哨兵集群

①停掉之前的redis集群(注意要进入redis目录下)

docker compose down

②然后,把课前资料提供的sentinel.conf文件,在虚拟机的/root/redis目录下新建三个文件夹:s1、s2、s3,然后把sentinel.conf文件分别拷贝一份到3个文件夹中

内容如下:

sentinel announce-ip "192.168.126.151"
sentinel monitor hmaster 192.168.126.151 7001 2
sentinel down-after-milliseconds hmaster 5000
sentinel failover-timeout hmaster 60000

③修改docker-compose.yaml文件

version: "3.2"

services:
  r1:
    image: redis
    container_name: r1
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7001"]
  r2:
    image: redis
    container_name: r2
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7002", "--slaveof", "192.168.126.151", "7001"]
  r3:
    image: redis
    container_name: r3
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7003", "--slaveof", "192.168.126.151", "7001"]
  s1:
    image: redis
    container_name: s1
    volumes:
      - /root/redis/s1:/etc/redis
    network_mode: "host"
    entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27001"]
  s2:
    image: redis
    container_name: s2
    volumes:
      - /root/redis/s2:/etc/redis
    network_mode: "host"
    entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27002"]
  s3:
    image: redis
    container_name: s3
    volumes:
      - /root/redis/s3:/etc/redis
    network_mode: "host"
    entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27003"]

④启动集群

docker compose up -d

⑤查看哨兵运行日志

docker logs -f s1

⑥模拟master节点宕机

docker stop r1
# 或
# 连接7001这个master节点,通过sleep模拟服务宕机,60秒后自动恢复
docker exec -it r1 redis-cli -p 7001 DEBUG sleep 60

r2成为了新的master

启动r1

docker start r1

查看r2的信息

4.2 RedisTemplate连接哨兵集群

步骤:

①引入依赖,就是SpringDataRedis的依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

②配置哨兵地址。连接哨兵集群与传统单点模式不同,不再需要设置每一个redis的地址,而是直接指定哨兵地址。

spring:
  redis:
    sentinel:
      master: hmaster # 集群名
      nodes: # 哨兵地址列表
        - 192.168.150.101:27001
        - 192.168.150.101:27002
        - 192.168.150.101:27003

③配置读写分离。让java客户端将写请求发送到master节点,读请求发送到slave节点。定义一个bean即可:

@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
    return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}

这个bean中配置的就是读写策略,包括四种:

  • MASTER:从主节点读取
  • MASTER_PREFERRED:优先从master节点读取,master不可用才读取slave
  • REPLICA:从slave节点读取
  • REPLICA_PREFERRED:优先从slave节点读取,所有的slave都不可用才读取master

二、Redis分片集群

主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:

  • 海量数据存储问题
  • 高并发写的问题

使用分片集群可以解决上述问题,分片集群特征:

  • 集群中有多个master,每个master保存不同数据
  • 每个master都可以有多个slave节点
  • master之间通过ping监测彼此的健康状态
  • 客户端请求可以访问集群任意节点,最终都会被转发到数据所在节点

1. 搭建分片集群

Redis分片集群最少也需要3个master节点,由于我们的机器性能有限,我们只给每个master配置1个slave,形成最小的分片集群:

计划部署的节点信息如下:

容器名角色IP映射端口
r1master192.168.126.1517001
r2master192.168.126.1517002
r3master192.168.126.1517003
r4slave192.168.126.1517004
r5slave192.168.126.1517005
r6slave192.168.126.1517006

步骤:

①停掉之前的几个容器

cd redis
docker compose down

②在虚拟机的root目录下新建一个redis-cluster目录,然后在其中新建一个docker-compose.yaml文件,内容如下:

version: "3.2"

services:
  r1:
    image: redis
    container_name: r1
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7001", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
  r2:
    image: redis
    container_name: r2
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7002", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
  r3:
    image: redis
    container_name: r3
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7003", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
  r4:
    image: redis
    container_name: r4
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7004", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
  r5:
    image: redis
    container_name: r5
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7005", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
  r6:
    image: redis
    container_name: r6
    network_mode: "host"
    entrypoint: ["redis-server", "--port", "7006", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]

③启动集群

cd redis-cluster
docker compose up -d

④查看启动的进程

ps -ef | grep redis

可以发现每个redis节点都可以以cluster模式运行,不过节点与节点之间并未建立连接。

⑤创建集群(注意改为自己的虚拟机IP)

# 进入任意节点容器
docker exec -it r1 bash
# 然后,执行命令
redis-cli --cluster create --cluster-replicas 1 \
192.168.126.151:7001 192.168.126.151:7002 192.168.126.151:7003 \
192.168.126.151:7004 192.168.126.151:7005 192.168.126.151:7006

⑥查询集群状态

redis-cli -p 7001 cluster nodes

2. 散列插槽

在Redis集群中,共有16384个hash slots,集群中的每一个master节点都会分配一定数量的hash slots:

Redis数据不是与节点绑定,而是与插槽slot绑定。当我们读写数据时,Redis基于CRC16 算法对key做hash运算,将得到的结果与16384取余,就计算出了这个key的slot值。然后到slot所在的Redis节点执行读写操作。

Redis在计算key的hash值不一定是根据整个key计算,分两种情况:

  • 当key中不包含{}时,根据{}之间的字符串计算hash slot
  • 当key中不包含{}时,则根据整个key字符串计算hash slot

例如,key是num,那么就根据num计算,如果是{itcast}num,则根据itcast计算。

示例:先于7001建立连接:

连接集群时,要加-c参数

# 进入容器
docker exec -it r1 bash
# 进入redis-cli
redis-cli -c -p 7001
# 测试
set user jack

总结

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

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

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

  • Redis计算key的插槽值时会判断key中是否包含{},如果有则基于{}内的字符计算插槽
  • 数据的key中可以加入{类型},例如key都以{typeId}为前缀,这样同类型数据计算的插槽一定相同。

3. 故障转移

分片集群的节点之间会互相通过ping的方式做心跳检测,超时未回应的节点会被标记为下线状态。当发现master下线时,会将这个master的某个slave提升为master。

我们先打开一个控制台窗口(进入redis-cluster目录),利用命令监测集群状态:

watch docker exec -it r1 redis-cli -p 7001 cluster nodes

命令前面的watch可以每隔一段时间刷新执行结果,方便我们实时监控集群状态变化。

接着,利用命令让某个master节点休眠。比如这里我们让7002节点休眠,打开一个新的ssh控制台,输入下面的命令:

docker exec -it r2 redis-cli -p 7002 DEBUG sleep 30

可以观察到,集群发现7002宕机,标记为下线。

过了一段时间后,7002原本的slave7004变成了master:

而7002被标记为slave,而且其master正好是7004,主从地位互换。

4. Java客户端连接分片集群

RedisTemplate底层同样基于lettuce实现了分片集群的支持,而使用的步骤与哨兵模式基本一致,分为以下三步:

①引入Spring Data Redis的starter依赖(它会包括RedisTemplate和Lettuce相关的依赖)

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

②配置分片集群地址;

spring:
  redis:
    cluster:
      nodes:
        - 192.168.150.101:7001
        - 192.168.150.101:7002
        - 192.168.150.101:7003
        - 192.168.150.101:8001
        - 192.168.150.101:8002
        - 192.168.150.101:8003

③配置读写分离

@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
    return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}

三、Redis数据结构

常用的Redis数据类型有5种,分别是:String、List、Set、SortedSet、Hash。

还有一些高级数据类型,比如Bitmap、HyperLogLog、GEO等,其底层都是基于上述5种基本数据类型。因此,在Redis的源码中,其实只有5种数据类型。

1. RedisObject

Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象,源码如下:

整个结构体并不包含真实的数据,仅仅是对象头信息,内存占用的大小为4+4+24+32+64=128bit。也就是16字节,然后指针ptr指向的才是真实数据存储的内存地址,所以RedisObject的内存开销也是很大的。

Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含12种不同类型:

编号

编码方式

说明

0

OBJ_ENCODING_RAW

raw编码动态字符串

1

OBJ_ENCODING_INT

long类型的整数的字符串

2

OBJ_ENCODING_HT

hash表(也叫dict)

3

OBJ_ENCODING_ZIPMAP

已废弃

4

OBJ_ENCODING_LINKEDLIST

双端链表

5

OBJ_ENCODING_ZIPLIST

压缩列表

6

OBJ_ENCODING_INTSET

整数集合

7

OBJ_ENCODING_SKIPLIST

跳表

8

OBJ_ENCODING_EMBSTR

embstr编码的动态字符串

9

OBJ_ENCODING_QUICKLIST

快速列表

10

OBJ_ENCODING_STREAM

Stream流

11

OBJ_ENCODING_LISTPACK

紧凑列表

Redis中的5种不同的数据类型采用的底层数据结构和编码方式如下:

数据类型

编码方式

STRING

intembstrraw

LIST

LinkedList 和 ZipList(3.2以前)、QuickList(3.2以后)

SET

intsetHT

ZSET

ZipList(7.0以前)、Listpack(7.0以后)、HTSkipList

HASH

ZipList(7.0以前)、Listpack(7.0以后)、HT

2. SkipList

SkipList(跳表)首先是链表,但与传统链表相比有几点差异:

  • 元素按照升序排序存储
  • 节点可能包含多个指针,指针跨度不同

SkipList的特点:

  • 跳跃表时一个有序的双向链表;
  • 每个节点都可以包含多层指针,层数是1到32之间的随机数;
  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
  • 增删改查效率与红黑树基本一致,实现却更简单,但空间复杂度更高。

传统链表只有指向前后元素的指针,因此只能顺序依次访问,如果查找的元素在链表中间,查询的效率会比较低。而SkipList则不同,它内部包含跨度不同的多级指针,可以让我们跳跃查找链表中间的元素,效率非常高。跳表最多允许32级指针,最多允许存储2^32个元素

其结构如图:

我们可以看到1号元素就有指向3、5、10的多个指针,查询时就可以跳跃查找。例如我们要找大小为14的元素,查找的流程是这样的:

  • 首先找元素1节点最高级指针,也就是4级指针,起始元素大小为1,指针跨度为9,可以判断出目标元素大小为10。由于14比10大,肯定要从10这个元素向下接着找;
  • 找到10这个元素,发现10这个元素的最高级指针跨度为5,判断出目标元素大小为15,大于14,需要判断下级指针;
  • 10这个元素的2级指针跨度为3,判断出目标元素为13,小于14,因此要基于元素13接着找;
  • 13的下级指针跨度为1,因此目标元素是14,刚好与目标一致,找到。

这种多级指针的查询方式避免了传统链表的逐个遍历导致的查询效率下降问题。在对有序数据做随机查询和排序时效率非常高。

跳表的结构体如下:

typedef struct zskiplist {
    // 头尾节点指针
    struct zskiplistNode *header, *tail;
    // 节点数量
    unsigned long length;
    // 最大的索引层级
    int level;
} zskiplist;

可以看到SkipList主要属性是header和tail,也就是头尾指针,因此它是支持双向遍历的。

跳表中节点的结构体如下:

typedef struct zskiplistNode {
    sds ele; // 节点存储的字符串
    double score;// 节点分数,排序、查找用
    struct zskiplistNode *backward; // 前一个节点指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 下一个节点指针
        unsigned long span; // 索引跨度
    } level[]; // 多级索引数组
} zskiplistNode;

每个节点中都包含ele和score两个属性,其中score是得分,也就是节点排序的依据。ele则是节点存储的字符串数据指针。

其内存结构如下:

3. SortedSet

SortedSet数据结构的特点是:

  • 每组数据都包含score和member
  • member唯一
  • 可根据score排序

Redsi源码中zset,也就是SortedSet的结构体如下:

typedef struct zset {
    dict *dict; // dict,底层就是HashTable
    zskiplist *zsl; // 跳表
} zset;

其内存结构如图:

面试题:Redis的SortedSet底层的数据结构是怎样的?

答:SortedSet是有序集合,底层的存储的每个数据都包含element和score两个值。score是得分,element则是字符串值。SortedSet会根据每个element的score值排序,形成有序集合。

它支持的操作很多,比如:

  • 根据element查询score值 -> 哈希表
  • 按照score值升序或降序查询element -> 跳表

要实现根据element查询对应score值,就必须实现element与score之间的键值映射。SortedSet底层是基于HashTable来实现的。

要实现对score值排序,并且查询效率还高,就需要有一种高效的有序数据结构,SortedSet是基于跳表实现的。

加分项:因为SortedSet底层需要用到两种数据结构,对内存占用比较高。因此Redis底层会对SortedSet中的元素大小做判断,如果元素大小小于128且每个元素都小于64字节,SortedSet底层会采用ZipList,也就是压缩列表来代替HashTable和SkipList

不过,ZipList存在连锁更新问题,因此在Redsi7.0版本以后,ZipList又被替换成ListPack(紧凑列表)

四、Redis内存回收

Redis之所以性能强,最主要的原因就是基于内存存储。然而单结点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。

我们可以通过修改redis.conf文件,添加下面的配置来配置Redis的最大内存:

maxmemory 1gb

当内存达到上限,就无法存储更多的数据了。因此,Redis内部会有两套内存回收的策略:

  • 内存过期策略
  • 内存淘汰策略

1. 过期KEY处理

Redis提供了expire命令,给key设置TTL(存活时间):

可以发现,当key的TTL到期以后,再次访问name返回的是nil,说明这个key已经不存在了,对应的内存也得到释放,从而起到内存回收的目的。

思考:

1. Redis是如何知道一个key是否过期呢?

Redis的本身是键值型数据库,其所有数据都存在一个redisDB的结构体中,其中包含两个哈希表:

  • dict:保存Redis中所有的键值对
  • expires:保存Redis中所有的设置了过期时间的KEY及其到期时间(写入时间+TTl)

要判断一个KEY是否过期,只需要到记录过期时间的Dict中根据KEY查询即可。

2. 是不是TTL到期就立刻删除了呢?

Redis并不会实时监测key的过期时间,在key过期后立刻删除。而是采用两种延迟删除的策略:

  • 惰性删除:当有命令需要操作一个key的时候,检查改key的存活时间,如果已经过期才执行删除。
  • 周期删除:通过一个定时任务,周期性的抽样部分有TLL的key,如果过期执行删除。

周期删除的定时任务执行周期有两种:

SLOW模式:默认执行频率为每秒10次,但每次执行时长不能超过25ms,受server.hz参数影响。

  • ①执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms。
  • ②执行清理耗时不超过一次执行周期的25%,即25ms。
  • ③逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期。
  • ④如果没有达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束。

FAST模式:频率不固定,跟随Redis內部IO事件循环执行。两次任务之间间隔不低于2ms,执行时长不超过1ms。

  • ①执行频率受beforeSleep()调用频率影响,但两次FAST模式间隔不低于2ms。
  • ②执行清理耗时不超过1ms。
  • ③逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期。
  • ④如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束。

2. 内存淘汰策略

内存淘汰:就是当Redis内存使用达到设置的阈值时,Redis主动挑选部分key删除以释放更多内存的流程。

Redis会在每次处理客户端命令时都会对内存使用情况做判断,如果必要则执行内存淘汰。内存淘汰的策略有:

  • noeviction:不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。
  • volatile-ttl:对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
  • allkeys-random:对全体key,随机进行淘汰。也就是直接从db->dict中随机挑选
  • volatile-random:对设置了TTL的key,随机进行淘汰。也就是从db->expires中随机挑选
  • allkeys-lru:对全体key,基于LRU(最近最久未使用)算法进行淘汰
  • volatile-lru:对设置了TTL的key,基于LRU算法进行淘汰
  • allkeys-lfu:对全体key,基于LFU(最少使用)算法进行淘汰
  • volatile-lfu:对设置了TTL的key,基于LFU算法进行淘汰。

比较容易混淆的两个:

  • LRU(Least Recently Used):最近最少使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高;
  • LRU(Least Frequently Used):最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。

Redis怎么知道某个KEY的最近一次访问时间或者是访问频率呢?

其中的lru就是记录最近一次访问时间和频率的。当然,你可以选择LRU和LFU时的记录方式不同:

  • LRU:以秒为单位记录最近一次访问时间,长度24bit
  • LFU:高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数

LFU的访问次数之所以叫作逻辑访问次数,是因为并不是每次key被访问都计数,而是通过运算:

  • ①生成[0~1)之间的随机数R
  • ②计算 1/(旧次数 * lfu_log_factor + 1),记录为P,lfu_log_factor默认为10
  • ③如果R < P,则计数器 + 1,且最大不超过255
  • ④访问次数会随时间衰减,距离上一次访问时间每隔lfu_decay_time分钟(默认为1),计数器-1

显然,LFU的基于访问频率的统计更符合我们的淘汰目标,因此官方推荐使用LFU算法。

问题:Redis中的KEY可能有百万甚至更多,每个KEY都有自己访问时间或逻辑访问次数,我们要找出时间最早的或者访问次数最小的,难道要把Redis中所有数据排序?

答:Redis的内存淘汰是在每次执行命令时处理的,如果每次执行命令都先对全量数据做内存排序,那命令的执行时长肯定会非常长,这是不现实的。

Redis采取的是抽样法,即每次抽样一定数量(maxnenory_smples)的key,然后基于内存策略做排序,找出淘汰优先级最高的,删除这个key。这就导致Redis的算法并不是真正的LRU,而是一种基于抽样的近似LRU算法。

不过,在Redis3.0以后改进了这个算法,引入了一个淘汰候选池,抽样的key要与候选池中的key比较淘汰优先级,优先级更高的才会被放入候选池。然后在候选池中找出优先级最高的淘汰掉,这就使算法的结果更接近于真正的LRU算法了。特别是在抽样值较高的情况下(例如10),可以达到与真正的LRU接近的效果。

在图表中看到三种颜色的点形成三个不同的带,每个点就是一个加入的KEY。

  • 浅灰色带是被驱逐的对象
  • 灰色带是没有被驱逐的对象
  • 绿色带是被添加的对象

3. 总结

1. Redis如何判断KEY是否过期呢?

答:在Redis中会有两个Dict,也就是HashTable,其中一个记录KEY-VALUE键值对,另一个记录KEY和过期时间。要判断一个KEY是否过期,只需要到记录过期时间的Dict中根据KEY查询即可。

2. Redis何时删除过期KEY?如何删除?

答:Redis的过期KEY处理有两种策略,分别是惰性删除和周期删除。

惰性删除是指在每次用户访问某个KEY时,判断KEY的过期时间;如果过期则删除;如果未过期则忽略。

周期删除有两种模式:

  • SLOW模式:通过一个定时任务,定期的抽样部分带有TTL的KEY,判断其是否过期。默认情况下定时任务的执行频率是每秒10次,但每次执行不能超过25毫秒。如果执行抽样后发现时间还有剩余,并且过期KEY的比例过高,则会多次抽样。
  • FAST模式:在Redis每次处理NIO事件之前,都会抽样部分带有TTL的KEY,判断是否过期,因此执行频率较高。但是每次执行时长不能超过1ms,如果时间充足并且过期KEY比例过高,也会多次抽样。

3. 当Redis内存不足时会怎么做?

答:这取决于配置的内存淘汰策略,Redis支持很多种内存淘汰策略,例如LRU、LFU、Random。但默认的策略是直接拒绝新的写入请求。而如果设置了其他策略,则会在每次执行命令后判断占用内存是否达到阈值。如果达到阈值则会基于配置的淘汰策略尝试进行内存淘汰,直到占用内存小于阈值为止。

4. 那你能聊聊LRU和LFU吗?

答:LRU是最近最久未使用。Redis的key都是RedisObject,当启用LRU算法后,Redis会在key的头信息中使用24个bit记录每个key的最近一次使用的时间lru。每次需要内存淘汰时,就会抽样一部分KEY,找出其中空闲时间最长的,也就是now - lru结果最大的,然后将其删除,如果内存依然不足,就重复这个过程。

5. 逻辑访问次数是如何计算的?

答:由于记录访问次数的只有8bit,即便是无符号数,最大值只有255,不可能记录真实的访问次数。因此Redis统计的其实就是逻辑访问次数。这其中只有一个计算公式,会根据当前的访问次数做计算,结果要么是次数+1,要么是次数不变。但随着当前访问次数越大,+1的概率也会越低,并且最大值不超过255。

除此之外,逻辑访问次数还有一个衰减周期,默认为1分钟,即每隔1分钟逻辑访问次数会-1。这样逻辑访问次数就能基本反映出一个key的访问热度了。

五、Redis缓存问题

1. 缓存一致性

Cache Aside:有缓存调用者自己维护数据库与缓存的一致性。即:

  • 查询时:命中则直接返回,未命中则查询数据库并写入缓存
  • 更新时:更新数据库并删除缓存,查询时自然会更新缓存

Read/Write Through:数据库自己维护一份缓存,底层实现对调用者透明。底层实现:

  • 查询时:命中则直接返回,未命中则查询数据库并写入缓存
  • 更新时:判断缓存是否存在,不存在直接更新数据库。存在则更新缓存,同步更新数据流

Write Behind Caching:读写操作都直接操作缓存,由线程异步的将缓存数据同步到数据库。

目前企业中使用最多的就是Cache Aside模式,因为实现起来非常简单。但缺点也很明显,就是无法保证数据库与缓存的强一致性。分析如下:

Cache Aside的写操作是要在更新数据库的同时删除缓存,那为什么不选择更新数据库的同时更新缓存,而是删除呢?原因很简单,假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新。采用删除方案也就是延迟更新,什么时候有人查询了,什么时候更新。

存在两种更新策略:①先更新数据库再删除缓存;②先删除缓存再更新数据库。

策略1:先更新数据库再删除缓存:

                        正常情况                                                                 异常情况

异常情况说明:

  • 线程1查询缓存未命中,于是去查询数据库,查询到旧数据
  • 线程1将数据写入缓存之前,线程2来了,更新数据库,删除缓存
  • 线程1执行写入缓存的操作,写入旧数据

策略2:先删除缓存再更新数据库:

                        正常情况                                                                        异常情况

异常情况说明:

  • 线程1删除缓存后,还没来得及更新数据库,
  • 此时,线程2来查询,发现缓存为命中,于是查询数据库,写入缓存。由于此时数据库尚未更新,查询的是旧数据。也就是说刚才的删除白删了,缓存又变成旧数据了。
  • 然后线程1更新数据库,此时数据库是新数据,缓存是旧数据。

综上,添加缓存的目的是为了提高系统性能,而你要付出的代价就是缓存与数据库的强一致性。如果你要求数据库与缓存的强一致,那就需要加锁避免并行读写。但这降低了性能,与缓存的目标背道而驰。

因此,不管任何缓存同步方案最终的目的都是尽可能保证一致性,降低发生不一致的概率。我们采用先更新数据库再删除缓存的方案,已经将这种概率降到足够低,目的已经达到了。

同时,我们还要给缓存加上过期时间,一旦发生缓存不一致,当缓存过期后会重新加载,数据最终还是能保证一致。这就可以作为一个兜底方案。

总结

缓存一致性策略的最佳实践方案:

1. 低一致性需求:使用Redis的key过期清理方案

2. 高一致性需求:主动更新,并以超时剔除作为兜底方案

  • 读操作:缓存命中则直接返回。缓存未命中则查询数据库,并写入缓存,设定超时时间。
  • 写操作:先写数据库,然后再删除缓存。要确保数据库与缓存操作的原子性

2. 缓存穿透

缓存穿透是指客户端请求的数据再数据库中根本不存在,从而导致请求穿透缓存,直接打到数据库的问题。

常见的解决方案有两种:

2.1 缓存空值

当发现请求的数据既不存在于缓存,也不存在于数据库时,将空值缓存到Redis,避免频繁查询数据库

  • 优点:实现简单,维护方便
  • 缺点:额外的内存消耗

2.2 布隆过滤

布隆过滤是一种数据统计的算法,用于简述一个元素是否存在于一个集合中。但是布隆过滤无需存储元素到集合,而是把元素映射到一个很长的二进制数位上。

  • 首先需要一个很长很长的二进制数,默认每一位都是0
  • 然后需要N个不同算法的哈希函数
  • 将集合中的元素根据N个哈希函数做运算,得到N个数字,然后将每个数字对应的bit位标记为1
  • 要判断某个元素是否存在,只需要把元素按照上述方式运算,判断对应的bit位是否是1即可

例如,现在N=3:

  • hello经过运算得到3个角标:1、5、12
  • world经过运算得到3个角标:8、17、21
  • java经过运算得到3个角标:17、25、28

则需要将每个元素对应角标位置置为1:

此时,我们要判断元素是否存在,只需要再次基于N个hash函数做运算,得到N个角标,判断每个角标的位置是不是1:

  • 主要全是1,就证明元素存在
  • 任意位置为0,就证明元素一定不存在

假如某个元素本身并不存在,也没添加到布隆过滤器过。但是由于存在hash碰撞的可能性,这就会出现这个元素计算出的角标已经被其他元素置为1的情况。那么这个元素也会被误判为已经存在。

因此,布隆过滤器的判断存在误差:

  • 当布隆过滤器认为元素不存在时,它肯定不存在
  • 当布隆过滤器认为元素存在时,它可能存在,也可能不存在

当bit数组越大、Hash函数N越复杂,N越大时,这个误判的概率也就越低。由于采用bit数组来标示数据,即便4,294,967,296个bit位,也只占用512mb的空间。

  • 优点:内存占用较少,没有多余key
  • 缺点:实现复杂,存在误判可能

3. 缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  • 给不同Key的TTL添加随机值,这样KEY的过期时间不同,不会大量KEY同时过期
  • 利用Redis集群提高服务的可用性,避免缓存服务宕机
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存,比如先查询本地缓存,本地缓存未命中再查询Redis,Redis未命中再查询数据库。即便Redis宕机,也还有本地缓存可以抗压力。

4. 缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务比较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

由于我们采用的是Cache Aside模式,当缓存失效时需要下次查询时才会更新缓存。当某个key缓存失效时,如果这个key是热点key,并发访问量比较高,就会在一瞬间涌入大量请求,都发现缓存未命中,于是都会去查询数据库,尝试重建缓存。可能一瞬间就把数据库压垮了。

如上图所示:

  • 线程1发现缓存未命中,准备查询数据库,重建缓存,但是因为数据比较复杂,导致查询数据库耗时较久
  • 在这个过程中,一下子来了3个新的线程,就都会发现缓存未命中,都去查询数据库
  • 数据库压力激增

常见的解决方案有两种:互斥锁和逻辑过期

互斥锁:给重建缓存逻辑加锁,避免多线程同时执行。

逻辑过期:热点key不要设置过期时间,在活动结束后手动删除。

解决方案优点缺点
互斥锁
  • 没有额外的内存消耗
  • 保证一致性
  • 实现简单
  • 线程需要等待,性能受影响
  • 可能有死锁风
逻辑过期
  • 线程无需等待,性能较好
  • 不保证一致性
  • 有额外内存消耗
  • 实现复杂

5. 总结

1. 如何保证缓存的双写一致性?

答:缓存的双写(写数据库和缓存)一致性很难保证强一致,只能尽可能降低不一致的概率,确保最终一致。我们项目中采用的是Cache Aside模式。简单来说,就是在更新数据库之后删除缓存;在查询时先查询缓存,如果未命中则查询数据库并写入缓存。同时我们会给缓存设置过期时间作为兜底方案,如果真的出现了不一致的情况,也可以通过缓存过期来保证最终一致。

追问:为什么不采用延迟双删机制?

答:延迟双删的第一次删除并没有实际意义,第二次采用延迟删除主要是解决数据库主从同步的延迟问题,我认为这是数据库主从的一致性问题,与缓存同步无关。既然主节点数据已经更新,Redis的缓存理应更新。而且延迟双删会增加缓存业务复杂度,也没能完全避免缓存一致性问题,投入回报比太低。

2. 如何解决缓存穿透问题?

答:缓存穿透也可以说是穿透攻击,具体来说是因为请求访问到了数据库不存在的值,这样缓存无法命中,必然访问数据库。如果高并发地访问这样的接口,会给数据库带来巨大压力。

我们项目中都是基于布隆过滤器来解决缓存穿透问题的,当缓存未命中时基于布隆过滤器判断数据是否存在,如果不存在则不去访问数据库。当然,也可以使用缓存空值发方式解决,不管这种方案比较浪费内存。

3. 如何解决缓存雪崩问题?

答:缓存雪崩的常见原因有两个,第一是因为大量key同时过期。针对这个问题我们可以给缓存key设置不同的TTL值,避免key同时过期。

第二个原因是Redis宕机导致缓存不可用。针对这个问题我们可以利用集群提高Redis的可用性。也可以添加多级缓存,当Redis宕机时还有本地缓存可用。

4. 如何解决缓存击穿问题?

答:缓存击穿往往是由热点Key引起的,当热点Key过期时,大量请求涌入同时查询,发现缓存未命中都会去访问数据库,导致数据库压力激增。解决这个问题的主要思想就是避免多线程并发去重建缓存,因此解决方案有两种。

第一种是基于互斥锁。当发现缓存未命中时需要先获取互斥锁,再重建缓存,缓存重建完成释放锁。这样就可以保证缓存重建同一时刻只会有一个线程执行。不过这种做法会导致缓存重建时性能下降严重。

第二种时基于逻辑过期。也就是不给热点Key设置过期时间,而是给数据添加一个过期时间的字段。这样热点Key就不会过期,缓存中永远有数据。查询到数据时基于其中的过期时间判断Key是否过期,如果过期开启独立新线程异步的重建缓存,而查询请求先返回旧数据即可。当然,这个过程也要加互斥锁,但由于重建缓存是异步的,而且获取锁失败也无需等待,而是返回旧数据,这样性能几乎不受影响。

需要注意的是,无论是采用哪种方式,在获取互斥锁之后一定要再次判断缓存是否命中,做double check,因为当你获取锁成功时,可能在你之前有其他线程已经重建缓存了。

资料文档:Docs

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/717582.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Vector | Graph:蚂蚁首个开源Graph RAG框架设计解读

作者&#xff1a;范志东 检索增强生成&#xff08;RAG&#xff1a;Retrieval Augmented Generation&#xff09;技术旨在把信息检索与大模型结合&#xff0c;以缓解大模型推理“幻觉”的问题。近来关于RAG的研究如火如荼&#xff0c;支持RAG的开源框架也层出不穷&#xff0c;并…

AI导航网

文章目录 1、[AI导航网](https://www.ainav.cn/) 1、AI导航网 https://www.ainav.cn/

GenICam标准(二)

系列文章目录 GenICam标准&#xff08;一&#xff09; GenICam标准&#xff08;二&#xff09; GenICam标准&#xff08;三&#xff09; GenICam标准&#xff08;四&#xff09; GenICam标准&#xff08;五&#xff09; GenICam标准&#xff08;六&#xff09; 文章目录 系列文…

【蜂窝物联】物联网智能控制器助力各种自动化控制领域科学管控

【蜂窝物联】4G远程温湿度传感器科学管理利器&#xff0c;应用无处不在 2024-06-17 14:09 发布于&#xff1a;福建省 随着信息化的不断推进&#xff0c;对各行各业都是一次现代化升级的契机&#xff0c;比如工厂的温湿度监测工作&#xff0c;完全可以由无线温湿度监控方案…

【Spine学习10】之 创建新骨骼时,自动绑定图片和插槽的快捷方式

两天没更新了。 遇到一些难解的难题 用的版本是破解版 不知道为啥现在的教程非常地快 明明有些细节很重要还略过讲 所以创建骨骼这里 基本创建是都会 可是骨骼一多 实际工作中的重命名也太麻烦了 。 这就需要学习快捷创建方式&#xff1a; <将对应图片自动绑定到新骨骼上并…

Vue55-TodoList案例-本地存储

一、TodoList案例-本地存储 此时&#xff0c;修改对象里面的属性&#xff0c;watch监视不到&#xff01; 需要深度监视&#xff0c;就不能用简写形式&#xff01; 二、jeecg-boot中的本地存储 jeecg-boot中&#xff0c;浏览器的本地存储&#xff0c;存储的是token&#xff01;…

TC3xx A\B SWAP机制的小细节(1)

目录 1.汽车OTA背景 1.1 汽车为什么需要OTA 1.2 汽车OTA概念 2. MCU的硬件A\B Swap机制 3.小结 1.汽车OTA背景 1.1 汽车为什么需要OTA 谈到英飞凌TC3xx的A\B SWAP硬件机制&#xff0c;我们首先要搞懂它的应用场景--OTA。 在手机或者电脑上&#xff0c;我们几乎每天都可…

JavaEE进阶----SpringBoot快速入门

文章目录 前言一、了解Maven1.1 Maven功能- 项⽬构建- 管理依赖 1.2Maven仓库 二、第一个SpringBoot项目总结 前言 Spring Boot是一个用于构建快速、简单和可扩展的生产级应用程序的框架。它基于Spring框架&#xff0c;提供了开发微服务和独立的应用程序所需的一切。 一、了解…

一、开发环境安装 Avalonia

1、概述 官网中是这么介绍Avalonia的&#xff0c;Avalonia是一个强大的框架&#xff0c;使开发人员能够使用.NET创建跨平台应用程序。它使用自己的渲染引擎绘制UI控件&#xff0c;确保在Windows、macOS、Linux、Android、iOS和WebAssembly等不同平台上具有一致的外观和行为。这…

2024年8款最受欢迎的开源看板系统

开源看板系统有哪些&#xff1f;本文将盘点国内外主流的8款看板系统&#xff1a;PingCode、Kanboard、Worktile、Wekan、OpenProject、TAIga、Focalboard。 今天想和大家探讨的是开源看板系统。作为一个热衷于项目管理和效率提升的爱好者&#xff0c;我在这方面也是小有研究。开…

基于DPU的云原生裸金属服务快速部署及存储解决方案

1. 背景介绍 1.1. 业务背景 在云原生技术迅速发展的当下&#xff0c;容器技术因其轻量级、可移植性和快速部署的特性而成为应用部署的主流选择&#xff0c;但裸金属服务器依然有其独特的价值和应用场景&#xff0c;是云原生架构中不可或缺的一部分。 裸金属服务器是一种高级…

抛光粉尘可爆性检测 打磨粉尘喷砂粉尘爆炸下限测试

抛光粉尘可爆性检测 抛光粉尘的可爆性检测是一种安全性能测试&#xff0c;用于确定加工过程中产生的粉尘在特定条件下是否会爆炸&#xff0c;从而对生产安全构成威胁。如果粉尘具有可爆性&#xff0c;那么在生产环境中就需要采取相应的防爆措施。粉尘爆炸的条件通常包括粉尘本身…

【2024最新华为OD-C/D卷试题汇总】[支持在线评测] 字符串筛选排序(100分) - 三语言AC题解(Python/Java/Cpp)

&#x1f36d; 大家好这里是清隆学长 &#xff0c;一枚热爱算法的程序员 ✨ 本系列打算持续跟新华为OD-C/D卷的三语言AC题解 &#x1f4bb; ACM银牌&#x1f948;| 多次AK大厂笔试 &#xff5c; 编程一对一辅导 &#x1f44f; 感谢大家的订阅➕ 和 喜欢&#x1f497; &#x1f…

【深度学习】智能手写数字识别系统

文章目录 一&#xff0e;实验课题背景说明1.1实验目的1.2实验环境1.2.1安装PyTorch1.2.2安装其他必要的库 二&#xff0e;模型说明2.1模型概述2.2模型结构 三&#xff0e;数据说明3.1 输入数据3.1.1输入数据特征3.1.2输入数据维度3.1.3输入数据预处理 3.2 数据格式3.2.1输出数据…

qt 实现模拟实际物体带速度的移动(水平、垂直、斜角度)——————附带完整代码

文章目录 0 效果1 原理1.1 图片旋转1.2 物体按照现实中的实际距离带真实速度移动 2 完整实现2.1 将车辆按钮封装为一个类&#xff1a;2.2 调用方法 3 完整代码参考 0 效果 实现后的效果如下 可以显示属性&#xff08;继承自QToolButton&#xff09;: 鼠标悬浮显示文字 按钮…

Bagging与Boosting的应用与优势

Hi~&#xff01;这里是奋斗的小羊&#xff0c;很荣幸您能阅读我的文章&#xff0c;诚请评论指点&#xff0c;欢迎欢迎 ~~ &#x1f4a5;&#x1f4a5;个人主页&#xff1a;奋斗的小羊 &#x1f4a5;&#x1f4a5;所属专栏&#xff1a;C语言 &#x1f680;本系列文章为个人学习…

Excel 常用技巧(四)

Microsoft Excel 是微软为 Windows、macOS、Android 和 iOS 开发的电子表格软件&#xff0c;可以用来制作电子表格、完成许多复杂的数据运算&#xff0c;进行数据的分析和预测&#xff0c;并且具有强大的制作图表的功能。由于 Excel 具有十分友好的人机界面和强大的计算功能&am…

【Python高级编程】Pickle实现AI算法训练的权重数据的保存

任务描述 代码实现 import pickle import time import os import numpy as np# 模拟耗时的权重计算过程 def calculate_weights():print("开始计算权重...")time.sleep(5) # 模拟耗时操作&#xff0c;暂停5秒以模拟计算过程weights np.random.rand(10, 10) # 随机…

python实践笔记(三): 异常处理和文件操作

1. 写在前面 最近在重构之前的后端代码&#xff0c;借着这个机会又重新补充了关于python的一些知识&#xff0c; 学习到了一些高效编写代码的方法和心得&#xff0c;比如构建大项目来讲&#xff0c;要明确捕捉异常机制的重要性&#xff0c; 学会使用try...except..finally&…

小区噪音监测管理系统设计

一、引言 随着城市化进程的加快&#xff0c;小区居民对于居住环境的要求日益提高。其中&#xff0c;噪音污染已成为影响居民生活质量的重要因素。因此&#xff0c;设计一套小区噪音监测管理系统&#xff0c;对于提升居民的生活品质和小区管理效率具有重要意义。本文将详细阐述…