0

0

深入浅析Redis中的sentinel故障转移

青灯夜游

青灯夜游

发布时间:2021-10-28 10:34:10

|

3074人浏览过

|

来源于掘金社区

转载

本篇文章带大家了解一下redis中的故障转移(sentinel),希望对大家有所帮助!

深入浅析Redis中的sentinel故障转移

当两台以上的Redis实例形成了主备关系,它们组成的集群就具备了一定的高可用性:当master发生故障的时候,slave可以成为新的master对外提供读写服务,这种运营机制成为failover。【相关推荐:Redis视频教程

那么谁来发现master的故障做failover决策?

一种方式是,保持一个daemo进程,监控着所有的master-slave节点,如下图所示:

1.png

一个Redis集群里面有一个master和两个slave,这个daemon进程监控着这三个节点。但daemon为单节点,本身可用性无法保证。需要引入多daemon,如下图所示:

2.png

多个daemon解决了可用性问题,但又出现了一致性问题,如何就某个master是否可用达成一致?例如上图两个daemon1和和master网络不通,daemon和master连接畅通,那此时mater节点是否需要failover那?

Redis的sentinel提供了一套多daemon间的交互机制,多个daemon间组成一个集群,成为sentinel集群,daemon节点也称为sentinel节点。如下图所示:

3.png

这些节点相互间通信、选举、协商,在master节点的故障发现failover决策上表现出一致性。

sentinel集群监视任意多个master以及master下的slave,自动将下线的master从其下的某个slave升级为新的master代替继续处理命令请求。

启动并初始化Sentinel

启动一个Sentinel可以使用命令:

./redis-sentinel ../sentinel.conf

或者命令:

./redis-server ../sentinel.conf --sentinel

当一个Sentinel启动时,它需要执行以下步骤:

初始化服务器

Sentinel本质上是运行在特殊模式下的Redis服务器,它和普通的Redis服务器执行的工作不同,初始化过程也不完全相同。如普通的Redis服务器初始化会载入RDB或者AOF文件来恢复数据,而Sentinel启动时不会载入,因为Sentinel并不使用数据库。

将普通Redis服务器使用的代码替换成Sentinel专用代码

将一部分普通Redis服务器使用的代码替换成Sentinel专用代码。如普通Redis服务器使用server.c/redisCommandTable作为服务器的命令表:

truct redisCommand redisCommandTable[] = {
    {"module",moduleCommand,-2,"as",0,NULL,0,0,0,0,0},
    {"get",getCommand,2,"rF",0,NULL,1,1,1,0,0},
    {"set",setCommand,-3,"wm",0,NULL,1,1,1,0,0},
    {"setnx",setnxCommand,3,"wmF",0,NULL,1,1,1,0,0},
    {"setex",setexCommand,4,"wm",0,NULL,1,1,1,0,0},
    {"psetex",psetexCommand,4,"wm",0,NULL,1,1,1,0,0},
    {"append",appendCommand,3,"wm",0,NULL,1,1,1,0,0},
    .....
    {"del",delCommand,-2,"w",0,NULL,1,-1,1,0,0},
    {"unlink",unlinkCommand,-2,"wF",0,NULL,1,-1,1,0,0},
    {"exists",existsCommand,-2,"rF",0,NULL,1,-1,1,0,0},
    {"setbit",setbitCommand,4,"wm",0,NULL,1,1,1,0,0},
    {"getbit",getbitCommand,3,"rF",0,NULL,1,1,1,0,0},
    {"bitfield",bitfieldCommand,-2,"wm",0,NULL,1,1,1,0,0},
    {"setrange",setrangeCommand,4,"wm",0,NULL,1,1,1,0,0},
    {"getrange",getrangeCommand,4,"r",0,NULL,1,1,1,0,0},
    {"substr",getrangeCommand,4,"r",0,NULL,1,1,1,0,0},
    {"incr",incrCommand,2,"wmF",0,NULL,1,1,1,0,0},
    {"decr",decrCommand,2,"wmF",0,NULL,1,1,1,0,0},
    {"mget",mgetCommand,-2,"rF",0,NULL,1,-1,1,0,0},
    {"rpush",rpushCommand,-3,"wmF",0,NULL,1,1,1,0,0},
    {"lpush",lpushCommand,-3,"wmF",0,NULL,1,1,1,0,0}
    ......
    }

Sentinel使用sentinel.c/sentinelcmds作为服务器列表,如下所示:

struct redisCommand sentinelcmds[] = {
    {"ping",pingCommand,1,"",0,NULL,0,0,0,0,0},
    {"sentinel",sentinelCommand,-2,"",0,NULL,0,0,0,0,0},
    {"subscribe",subscribeCommand,-2,"",0,NULL,0,0,0,0,0},
    {"unsubscribe",unsubscribeCommand,-1,"",0,NULL,0,0,0,0,0},
    {"psubscribe",psubscribeCommand,-2,"",0,NULL,0,0,0,0,0},
    {"punsubscribe",punsubscribeCommand,-1,"",0,NULL,0,0,0,0,0},
    {"publish",sentinelPublishCommand,3,"",0,NULL,0,0,0,0,0},
    {"info",sentinelInfoCommand,-1,"",0,NULL,0,0,0,0,0},
    {"role",sentinelRoleCommand,1,"l",0,NULL,0,0,0,0,0},
    {"client",clientCommand,-2,"rs",0,NULL,0,0,0,0,0},
    {"shutdown",shutdownCommand,-1,"",0,NULL,0,0,0,0,0},
    {"auth",authCommand,2,"sltF",0,NULL,0,0,0,0,0}
}

初始化Sentinel状态

服务器会初始化一个sentinel.c/sentinelState结构(保存服务器中所有和Sentinel功能有关的状态)。

struct sentinelState {
 
    char myid[CONFIG_RUN_ID_SIZE+1]; /* This sentinel ID. */
    
    //当前纪元,用于实现故障转移
    uint64_t current_epoch;         /* Current epoch. */
    
    //监视的主服务器
    //字典的键是主服务器的名字
    //字典的值则是一个指向sentinelRedisInstances结构的指针
    dict *masters;      /* Dictionary of master sentinelRedisInstances.
                           Key is the instance name, value is the
                           sentinelRedisInstance structure pointer. */
    //是否进入tilt模式
    int tilt;           /* Are we in TILT mode? */
    
    //目前正在执行的脚本数量
    int running_scripts;    /* Number of scripts in execution right now. */
    
    //进入tilt模式的时间
    mstime_t tilt_start_time;       /* When TITL started. */
    
    //最后一次执行时间处理器的时间
    mstime_t previous_time;         /* Last time we ran the time handler. */
    
    // 一个FIFO队列,包含了所有需要执行的用户脚本
    list *scripts_queue;            /* Queue of user scripts to execute. */
    
    char *announce_ip;  /* IP addr that is gossiped to other sentinels if
                           not NULL. */
    int announce_port;  /* Port that is gossiped to other sentinels if
                           non zero. */
    unsigned long simfailure_flags; /* Failures simulation. */
    int deny_scripts_reconfig; /* Allow SENTINEL SET ... to change script
                                  paths at runtime? */
}

根据给定的配置文件,初始化Sentinel的监视主服务器列表

对Sentinel状态的初始化将引发对masters字典的初始化,而master字典的初始化是根据被载入的Sentinel配置文件来进行的。

字典的key是监视主服务器的名字,字典的值则是被监控主服务器对应的sentinel.c/sentinelRedisInstance结构。

sentinelRedisInstance结构部分属性如下:

typedef struct sentinelRedisInstance {
    //标识值,记录了实例的类型,以及该实例的当前状态
    int flags;      /* See SRI_... defines */
    
    //实例的名字
    //主服务器的名字由用户在配置文件中设置
    //从服务器以及Sentinel的名字由Sentinel自动设置
    //格式为ip:port,例如“127.0.0.1:26379”
    char *name;     /* Master name from the point of view of this sentinel. */
    
    //实例运行的ID
    char *runid;    /* Run ID of this instance, or unique ID if is a Sentinel.*/
    
    //配置纪元,用于实现故障转移
    uint64_t config_epoch;  /* Configuration epoch. */
    
    //实例的地址
    sentinelAddr *addr; /* Master host. */
    
    //sentinel down-after-milliseconds选项设定的值
    //实例无响应多少毫秒之后才会被判断为主观下线(subjectively down)
    mstime_t down_after_period; /* Consider it down after that period. */
    
    //sentinel monitor <master-name> <ip> <redis-port> <quorum>选项中的quorum
    //判断这个实例为客观下线(objective down)所需的支持投票的数量
    unsigned int quorum;/* Number of sentinels that need to agree on failure. */  
    //sentinel parallel-syncs <master-name> <numreplicas> 选项的numreplicas值
    //在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量
    int parallel_syncs; /* How many slaves to reconfigure at same time. */
    
    //sentinel failover-timeout <master-name> <milliseconds>选项的值
    //刷新故障迁移状态的最大时限
    mstime_t failover_timeout;      /* Max time to refresh failover state. */
}

例如启动Sentinel时,配置了如下的配置文件:

# sentinel monitor <master-name> <ip> <redis-port> <quorum>
sentinel monitor master1 127.0.0.1 6379 2

# sentinel down-after-milliseconds <master-name> <milliseconds>
sentinel down-after-milliseconds master1 30000

# sentinel parallel-syncs <master-name> <numreplicas>
sentinel parallel-syncs master1 1

# sentinel failover-timeout <master-name> <milliseconds>
sentinel failover-timeout master1 900000

则Sentinel则会为主服务器master1创建如下图所示的实例结构:

4.png

Sentinel状态以及masters字典的机构如下:

5.png

创建连向主服务器的网络连接

创建连向被监视主服务器的网络连接,Sentinel将成为主服务器的客户端,向主服务器发送命令并从命令回复获取信息。

Sentinel会创建两个连向主服务器的异步网络连接:

  • 命令连接,用于向主服务器发送命令并接收命令回复
  • 订阅连接,订阅主服务器的_sentinel_:hello频道

6.png

Sentinel发送信息和获取信息

  • Sentinel默认会以每十秒一次的频率,通过命令连接向被监视的master和slave发送INFO命令

    通过master的回复可获取master本身信息,包括run_id域记录的服务器运行ID,以及role域记录的服务器角色。另外还会获取到master下的所有的从服务器信息,包括slave的ip地址和port端口号。Sentinel无需用户提供从服务器的地址信息,由master返回的slave的ip地址和port端口号,可以自动发现slave。

    当Sentinel发现master有新的slave出现时,Sentinel会为这个新的slave创建相应的实例外,Sentinel还会创建到slave的命令连接和订阅连接。

    根据slave的INFO命令的回复,Sentinel会提取如下信息:

    1.slave的运行ID run_id

    2.slave的角色role

    3.master的ip地址和port端口

    4.master和slave的连接状态master_link_status

    5.slave的优先级slave_priority

    6.slave的复制偏移量slave_repl_offset

  • Sentinel在默认情况下会以每两秒一次的频率,通过命令连接向所有被监视的master和slave的_sentinel_:hello频道发送一条信息

    发送以下格式的命令:

     PUBLISH _sentinel_:hello   "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"

以上命令相关参数意义:

参数 意义
s_ip Sentinel的ip地址
s_port Sentinel的端口号
s_runid Sentinel的运行ID
s_runid Sentinel的运行ID
m_name 主服务器的名字
m_ip 主服务器的IP地址
m_port 主服务器的端口号
m_epoch 主服务器当前的配置纪元
  • Sentinel与master或者slave建立订阅连接之后,Sentinel就会通过订阅连接发送对_sentinel_:hello频道的订阅,订阅会持续到Sentinel与服务器的连接断开为止

命令如下所示:

SUBSCRIBE sentinel:hello

Typeface
Typeface

AI创意内容创作助手

下载

7.png

如上图所示,对于每个与Sentinel连接的服务器 ,Sentinel既可以通过命令连接向服务器频道_sentinel_:hello频道发送信息,又通过订阅连接从服务器的_sentinel_:hello频道接收信息。

  • sentinel间会相互感知,新加入的sentinel会向master的_sentinel_:hello频道发布一条消息,包括自己的消息,其它该频道订阅者sentinel会发现新的sentinel。随后新的sentinel和其它sentinel会创建长连接。

相互连接的各个Sentinel可以进行信息交换。Sentinel为master创建的实例结构中的sentinels字典保存了除Sentinel本身之外,所有同样监视这个主服务器的其它Sentinel信息。

前面也讲到sentinel会为slave创建实例(在master实例的slaves字典中)。现在我们也知道通过sentinel相互信息交换,也创建了其它sentinel的实例(在master实例的sentinels字典中)。我们将一个sentinel中保存的实例结构大概情况理一下,如下图所示:

8.png

从上图可以看到slave和sentinel字典的键由其ip地址和port端口组成,格式为ip:port,其字典的值为其对应的sentinelRedisInstance实例。

master的故障发现

主观不可用

默认情况下Sentinel会以每秒一次的频率向所有与它创建了命令连接的master(包括master、slave、其它Sentinel)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。

PING命令回复分为下面两种情况:

  • 有效回复:实例返回 +PONG、-LOADING、-MASTERDOWN三种回复的一种

  • 无效回复:除上面有效回复外的其它回复或者在指定时限内没有任何返回

Sentinel配置文件中的设置down-after-milliseconds毫秒时效内(各个sentinel可能配置的不相同),连续向Sentinel返回无效回复,那么sentinel将此实例置为主观下线状态,在sentinel中维护的该实例flags属性中打开SRI_S_DOWN标识,例如master如下所示:

9.png

客观不可用

在sentinel发现主观不可用状态后,它会将“主观不可用状态”发给其它sentinel进行确认,当确认的sentinel节点数>=quorum,则判定该master为客观不可用,随后进入failover流程。

上面说到将主观不可用状态发给其它sentinel使用如下命令:

SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>

各个参数的意义如下:

  • ip:被sentinel判断为主观下线的主服务器的ip地址
  • port: 被sentinel判断为主观下线的主服务器的port地址
  • current_epoch:sentinel的配置纪元,用于选举领头Sentinel
  • runid:可以为*号或者Sentinel的运行ID,*号代表检测主服务器客观下线状态。Sentinel的运行ID用于选举领头Sentinel

接受到以上命令的sentinel会反回一条包含三个参数的Multi Bulk回复

1)<down_state> 目标sentinel对该master检查结果,1:master已下线 2:master未下线

2)<leader_runid> 两种情况,*表示仅用于检测master下线状态 ,否则表示局部领头Sentinel的运行ID(选举领头Sentinel)

3)<leader_epoch> 当leader_runid为时,leader_epoch始终为0。不为时则表示目标Sentinel的局部领头Sentinel的配置纪元(用于选举领头Sentinel)

其中节点数量限制quorum为sentinel配置文件中配置的

sentinel monitor <master-name> <ip> <redis-port> <quorum>

quorum选项,不同的sentinel配置的可能不相同。

当sentinel认为master为客观下线状态,则会将master属性中的flags的SRI_O_DOWN标识打开,例如master如下图所示:

10.png

选举Sentinel Leader

当一台master宕机时,可能多个sentinel节点同时发现并通过交互确认相互的“主观不可用状态”,同时达到“客观不可用状态”,同时打算发起failover。但最终只能有一个sentinel节点作为failover发起者,那么就需要选举出Sentinel Leader,需要开始一个Sentinel Leader选举过程。

Redis的Sentinel机制采用类似于Raft协议实现这个选举算法:

1.sentinelState的epoch变量类似于raft协议中的term(选举回合)。

2.每一个确认了master“客观不可用”的sentinel节点都会向周围广播自己的参选请求(SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <run_id> ,current_epoch为自己的配置纪元,run_id为自己的运行ID)

3.每一个接收到参选请求的sentinel节点如果还没接收到其它参选请求,它就将本回合的意向置为首个参选sentinel并回复它(先到先得);如果已经在本回合表过意向了,则拒绝其它参选,并将已有意向回复(如上所介绍的三个参数的Multi Bulk回复,down_state为1,leader_runid为首次接收到的发起参选请求的源sentinel的运行ID,leader_epoch为首次接收到的发起参选请求的源sentinel的配置纪元)

4.每个发起参选请求的sentinel节点如果收到超过一半的意向同意某个参选sentinel(可能是自己),则确定该sentinel为leader。如果本回合持续了足够长时间未选出leader,则开启下一个回合

leader sentinel 确定之后,leader sentinel从master所有的slave中依据一定规则选取一个作为新的master

故障转移failover

在选举出Sentinel Leader之后,sentinel leader对已下线master执行故障转移:

  • sentinel leader对已下线的master的所有slave中,选出一个状态良好、数据完整的slave,然后向这个slave发送:SLAVEOF no one 命令,将这个slave转换为master。

    我们来看下新的master是怎么挑选出来的?Sentinel leader会将已下线的所有slave保存到一个列表,然后按照以下规则过滤筛选:

  • 优先级最高的slave,redis.conf配置中replica-priority选项来标识,默认为100,replica-priority较低的优先级越高。0为特殊优先级,标志为不能升级为master。

  • 如果存在多个优先级相等的slave,则会选择复制偏移量(offset)最大的slave(数据更加完整)

  • 如果存在多个优先级相等,最大复制偏移量最大的slave,则选择运行ID最小的slave

选出需要升级为新的master的slave后,Sentinel Leader会向这个slave发送SLAVEOF no one 命令。之后Sentinel会以每秒一次频率(平时是十秒一次)向被升级slave发送INFO,当回复的role由slave变为master时Sentinel Leader就会知道已升级为master。

  • sentinel leader 向已下线的master属下的slave发送SLAVEOF命令(SLAVEOF <new_master_ip> <new_master_port>),去复制新的master

  • 将旧的master设置为新的master的slave,并继续对其监视,当其重新上线时Sentinel会执行命令让其成为新的master的slave。

总结

Sentinel是Redis高可用的解决方案,Sentinel集群的节点数需要>=3.

默认每十秒Sentinel对master和slave执行info,用于发现master变更信息,主从关系以及发现新的slave节点。

默认每两秒sentinel通过命令连接向所有被监视的master和slave的_sentinel_:hello频道发送一条信息,来和其它sentinel交互信息

默认每一秒sentinel向master,slave,其它sentinel发送PING命令来判断对方是否下线

Sentinel Leader是按照一定规则选举出来的。

由Sentinel Leader进行故障转移操作,选举出新的master来替代已下线的master。

更多编程相关知识,请访问:编程入门!!

热门AI工具

更多
DeepSeek
DeepSeek

幻方量化公司旗下的开源大模型平台

豆包大模型
豆包大模型

字节跳动自主研发的一系列大型语言模型

WorkBuddy
WorkBuddy

腾讯云推出的AI原生桌面智能体工作台

腾讯元宝
腾讯元宝

腾讯混元平台推出的AI助手

文心一言
文心一言

文心一言是百度开发的AI聊天机器人,通过对话可以生成各种形式的内容。

讯飞写作
讯飞写作

基于讯飞星火大模型的AI写作工具,可以快速生成新闻稿件、品宣文案、工作总结、心得体会等各种文文稿

即梦AI
即梦AI

一站式AI创作平台,免费AI图片和视频生成。

ChatGPT
ChatGPT

最最强大的AI聊天机器人程序,ChatGPT不单是聊天机器人,还能进行撰写邮件、视频脚本、文案、翻译、代码等任务。

相关专题

更多
C++多线程并发控制与线程安全设计实践
C++多线程并发控制与线程安全设计实践

本专题围绕 C++ 在高性能系统开发中的并发控制技术展开,系统讲解多线程编程模型与线程安全设计方法。内容包括互斥锁、读写锁、条件变量、原子操作以及线程池实现机制,同时结合实际案例分析并发竞争、死锁避免与性能优化策略。通过实践讲解,帮助开发者掌握构建稳定高效并发系统的关键技术。

2

2026.03.16

TypeScript类型系统进阶与大型前端项目实践
TypeScript类型系统进阶与大型前端项目实践

本专题围绕 TypeScript 在大型前端项目中的应用展开,深入讲解类型系统设计与工程化开发方法。内容包括泛型与高级类型、类型推断机制、声明文件编写、模块化结构设计以及代码规范管理。通过真实项目案例分析,帮助开发者构建类型安全、结构清晰、易维护的前端工程体系,提高团队协作效率与代码质量。

90

2026.03.13

Python异步编程与Asyncio高并发应用实践
Python异步编程与Asyncio高并发应用实践

本专题围绕 Python 异步编程模型展开,深入讲解 Asyncio 框架的核心原理与应用实践。内容包括事件循环机制、协程任务调度、异步 IO 处理以及并发任务管理策略。通过构建高并发网络请求与异步数据处理案例,帮助开发者掌握 Python 在高并发场景中的高效开发方法,并提升系统资源利用率与整体运行性能。

136

2026.03.12

C# ASP.NET Core微服务架构与API网关实践
C# ASP.NET Core微服务架构与API网关实践

本专题围绕 C# 在现代后端架构中的微服务实践展开,系统讲解基于 ASP.NET Core 构建可扩展服务体系的核心方法。内容涵盖服务拆分策略、RESTful API 设计、服务间通信、API 网关统一入口管理以及服务治理机制。通过真实项目案例,帮助开发者掌握构建高可用微服务系统的关键技术,提高系统的可扩展性与维护效率。

380

2026.03.11

Go高并发任务调度与Goroutine池化实践
Go高并发任务调度与Goroutine池化实践

本专题围绕 Go 语言在高并发任务处理场景中的实践展开,系统讲解 Goroutine 调度模型、Channel 通信机制以及并发控制策略。内容包括任务队列设计、Goroutine 池化管理、资源限制控制以及并发任务的性能优化方法。通过实际案例演示,帮助开发者构建稳定高效的 Go 并发任务处理系统,提高系统在高负载环境下的处理能力与稳定性。

64

2026.03.10

Kotlin Android模块化架构与组件化开发实践
Kotlin Android模块化架构与组件化开发实践

本专题围绕 Kotlin 在 Android 应用开发中的架构实践展开,重点讲解模块化设计与组件化开发的实现思路。内容包括项目模块拆分策略、公共组件封装、依赖管理优化、路由通信机制以及大型项目的工程化管理方法。通过真实项目案例分析,帮助开发者构建结构清晰、易扩展且维护成本低的 Android 应用架构体系,提升团队协作效率与项目迭代速度。

111

2026.03.09

JavaScript浏览器渲染机制与前端性能优化实践
JavaScript浏览器渲染机制与前端性能优化实践

本专题围绕 JavaScript 在浏览器中的执行与渲染机制展开,系统讲解 DOM 构建、CSSOM 解析、重排与重绘原理,以及关键渲染路径优化方法。内容涵盖事件循环机制、异步任务调度、资源加载优化、代码拆分与懒加载等性能优化策略。通过真实前端项目案例,帮助开发者理解浏览器底层工作原理,并掌握提升网页加载速度与交互体验的实用技巧。

113

2026.03.06

Rust内存安全机制与所有权模型深度实践
Rust内存安全机制与所有权模型深度实践

本专题围绕 Rust 语言核心特性展开,深入讲解所有权机制、借用规则、生命周期管理以及智能指针等关键概念。通过系统级开发案例,分析内存安全保障原理与零成本抽象优势,并结合并发场景讲解 Send 与 Sync 特性实现机制。帮助开发者真正理解 Rust 的设计哲学,掌握在高性能与安全性并重场景中的工程实践能力。

245

2026.03.05

PHP高性能API设计与Laravel服务架构实践
PHP高性能API设计与Laravel服务架构实践

本专题围绕 PHP 在现代 Web 后端开发中的高性能实践展开,重点讲解基于 Laravel 框架构建可扩展 API 服务的核心方法。内容涵盖路由与中间件机制、服务容器与依赖注入、接口版本管理、缓存策略设计以及队列异步处理方案。同时结合高并发场景,深入分析性能瓶颈定位与优化思路,帮助开发者构建稳定、高效、易维护的 PHP 后端服务体系。

723

2026.03.04

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
进程与SOCKET
进程与SOCKET

共6课时 | 0.4万人学习

Redis+MySQL数据库面试教程
Redis+MySQL数据库面试教程

共72课时 | 7.2万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2026 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号