From cbfa18a9e5400520cabea5982ccb65384aa23598 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Tue, 26 May 2026 10:44:04 +0800 Subject: [PATCH] vault backup: 2026-05-26 10:44:04 --- hhs/Redis/00-Redis架构与线程模型.md | 461 ++++++++++++++++++++++++++++ hhs/Redis/README.md | 6 + 2 files changed, 467 insertions(+) create mode 100644 hhs/Redis/00-Redis架构与线程模型.md diff --git a/hhs/Redis/00-Redis架构与线程模型.md b/hhs/Redis/00-Redis架构与线程模型.md new file mode 100644 index 0000000..81112e4 --- /dev/null +++ b/hhs/Redis/00-Redis架构与线程模型.md @@ -0,0 +1,461 @@ +--- +tags: [Redis, 缓存, 架构, 线程模型, IO多路复用] +create time: 2026-05-26 10:33 +--- + +# Redis 架构与线程模型 + +## 概述 + +Redis 是一个**基于内存的数据结构服务器**(data structure server),支持 String、Hash、List、Set、Sorted Set 等多种结构,并提供持久化、发布订阅、Lua 脚本、Stream 等高级能力。 + +本文聚焦于一个核心问题:**Redis 的内部架构长什么样?它是怎么做到单线程还这么快的?** + +> [!question] 单线程 → 慢?这是直觉误区 +> 提到"单线程",大多数人的第一反应是"性能差"。但 Redis 单实例可达 **10 万+ QPS**,延迟在微秒级。为什么?因为它的瓶颈从来不在 CPU,而在**网络 IO 和内存**。单线程反而省去了锁竞争和上下文切换的开销——这是 Redis 架构设计中最反直觉、也最关键的一个洞察。 + +## 整体架构 + +先从进程视角看 Redis 的内部模块组成: + +```mermaid +flowchart TB + subgraph Redis进程 + direction TB + NET["网络层
epoll/kqueue + IO多路复用"] + EVENT["事件循环
单线程处理所有命令"] + ENGINE["存储引擎
内存数据结构"] + BIO["后台线程池
BIO lazyfree / AOF fsync"] + RDB_CHILD["子进程
BGSAVE / AOF rewrite"] + end + + CLIENT["客户端"] -->|"TCP 连接"| NET + NET --> EVENT + EVENT --> ENGINE + ENGINE --> BIO + ENGINE -->|"fork()"| RDB_CHILD + RDB_CHILD -->|"持久化文件落盘"| DISK["磁盘"] + + style EVENT fill:#f9d,stroke:#96c + style ENGINE fill:#dfd,stroke:#6c6 + style NET fill:#bbf,stroke:#66c +``` + +> [!tip] 一句话理解 Redis +> Redis = **单线程事件循环** + **IO 多路复用** + **纯内存操作**。这三个要素共同造就了它的高性能。 + +## 为什么单线程能这么快? + +这是面试高频问题,但大多数回答只停留在"避免了锁"。让我们从根上拆解。 + +### 快的根本原因 + +| 因素 | 说明 | +|------|------| +| **纯内存操作** | 数据全部驻留在内存中,读写延迟 ~100ns 级,比磁盘快 10 万倍 | +| **单线程无锁** | 不存在线程安全问题,无需加锁/解锁,省去上下文切换开销 | +| **IO 多路复用** | 一个线程同时监听成千上万个连接(epoll),不会被某个慢连接阻塞 | +| **高效数据结构** | SDS、ziplist、quicklist、skiplist 等结构针对内存和性能做了极致优化 | +| **通信协议简单** | RESP(Redis Serialization Protocol)结构极简,解析无状态、无复杂序列化,开销远低于 JSON/XML 类协议 | + +> [!question] 那为什么 MySQL 不用单线程? +> 因为瓶颈不同。MySQL 的查询可能涉及磁盘 IO(随机读写)、复杂 SQL 解析、事务锁管理——这些操作耗时从微秒到毫秒不等,单线程会让 CPU 大量空转等待 IO。而 Redis 操作都在内存中完成,单个命令执行时间在亚微秒级,CPU 根本不会闲着。 +> +> 简单记:**瓶颈在 IO → 多线程有意义;瓶颈不在 IO → 单线程更简单高效。** + +### 单线程的局限 + +单线程也意味着 Redis 的单个实例**无法利用多核 CPU**。如果你的命令执行时间过长(如 `KEYS *`、大集合的 `SORT`),会阻塞后续所有请求——这就是为什么 Redis 社区反复强调**避免慢命令**。 + +```mermaid +flowchart LR + CMD1["正常命令
SET key val
1us"] --> QUEUE["命令队列"] + CMD2["正常命令
GET key
1us"] --> QUEUE + CMD3["慢命令
KEYS *
500ms"] --> QUEUE + QUEUE --> EXEC["单线程串行执行"] + + CMD3 -.->|"阻塞后面所有命令"| BLOCK["后续请求全部等待 500ms"] + + style CMD3 fill:#fdd,stroke:#c00 + style BLOCK fill:#fdd,stroke:#c00 +``` + +## 网络模型:IO 多路复用 + +Redis 单线程能同时处理上万连接的秘密在于 **IO 多路复用**(IO Multiplexing)。 + +### 传统阻塞 IO vs IO 多路复用 + +```mermaid +sequenceDiagram + participant C1 as "客户端1" + participant C2 as "客户端2" + participant C3 as "客户端3" + participant S as "服务端线程" + + Note over S: "传统阻塞IO 一个连接一个线程" + par 三个线程分别阻塞等待 + C1->>S: "读请求" + S-->>C1: "阻塞等待数据..." + C2->>S: "读请求" + S-->>C2: "阻塞等待数据..." + C3->>S: "读请求" + S-->>C3: "阻塞等待数据..." + end +``` + +```mermaid +sequenceDiagram + participant C1 as "客户端1" + participant C2 as "客户端2" + participant C3 as "客户端3" + participant EP as "epoll" + participant S as "单线程事件循环" + + Note over S: "IO多路复用 一个线程监控所有连接" + C1->>EP: "注册监听" + C2->>EP: "注册监听" + C3->>EP: "注册监听" + EP->>S: "通知 C2 有数据可读" + S->>C2: "读取并处理" + EP->>S: "通知 C1 有数据可读" + S->>C1: "读取并处理" +``` + +> [!info] epoll 的核心思想 +> epoll 是 Linux 内核提供的 IO 多路复用机制。简单来说:不是"一个线程傻等一个连接",而是"一个线程同时盯住成千上万个连接,谁有数据来了就处理谁"。Redis 用 `ae_epoll.c` 封装了这套机制。 +> +> 类比:你是一个餐厅服务员——传统模式是你站在 A 桌旁等他看完菜单(阻塞),而 IO 多路复用是你同时巡视所有桌,谁举手了就去服务(事件驱动)。 + +Redis 对不同平台做了适配: + +| 平台 | 底层实现 | 说明 | +|------|---------|------| +| Linux | `epoll` | 最常用,性能最优 | +| macOS / BSD | `kqueue` | FreeBSD / macOS 原生机制 | +| Solaris | `evport` | Solaris 事件端口 | +| 其他 | `select` | 通用回退方案,连接数受限(FD_SETSIZE,默认 1024) | + +### ae 事件库 + +Redis 自己实现了一个轻量级事件库(`ae.c`),将 IO 事件和定时事件统一抽象: + +```mermaid +flowchart TD + AE["ae 事件库
ae.c"] --> IOMULTI["IO 多路复用适配层"] + IOMULTI --> EP["epoll - Linux"] + IOMULTI --> KQ["kqueue - macOS BSD"] + IOMULTI --> EV["evport - Solaris"] + IOMULTI --> SEL["select - 通用回退"] + + AE --> TIMER["定时事件
serverCron 等"] + AE --> IO["文件事件
客户端连接读写"] + + style AE fill:#bbf,stroke:#66c +``` + +> [!tip] 事件循环的核心逻辑(伪代码) +> ``` +> while (server.running) { +> // 1. beforeSleep 钩子:每轮循环开始前的"预处理" +> beforeSleep(); // 处理过期 key 惰性删除、AOF 缓冲刷新等 +> // 2. aeProcessEvents 内部依次完成: +> // a. epoll_wait 阻塞等待(直到有 IO 事件或超时) +> // b. 遍历就绪事件,执行对应的命令读取/响应写出 +> aeProcessEvents(); +> // 3. 处理到期的定时事件(如 serverCron,每 100ms 一次) +> processTimeEvents(); +> } +> ``` +> +> 这就是 Redis 口中的「**主线程事件循环**」——一个永不停歇的 `while` 循环,依次处理 IO 事件和定时事件。 + +> [!info] beforeSleep——容易被忽略的关键钩子 +> `beforeSleep()` 在每轮事件循环进入阻塞等待(`aeProcessEvents`)之前被调用,承担了多项"顺手"的清理工作: +> +> | beforeSleep 中的任务 | 说明 | +> |----------------------|------| +> | 过期 key 惰性删除 | 执行命令时发现 key 过期了,在这里统一回收 | +> | AOF 缓冲区刷新 | 如果 AOF 配置为 `everysec`,这里尝试将缓冲区写入内核 | +> | 处理被阻塞的客户端 | 有 key 变更时唤醒 `BLPOP` 等阻塞命令的客户端 | +> | 集群 failover 辅助 | Cluster 模式下处理故障转移相关的清理 | +> +> 简单说,`beforeSleep` 是事件循环"睡着前"的收尾动作,保证每轮循环结束后状态干净。 + +## RESP 通信协议 + +客户端与 Redis 之间的通信基于 **RESP**(Redis Serialization Protocol),这也是它高效的原因之一。 + +> [!question] 为什么不用 HTTP? +> HTTP 是为浏览器设计的文本协议,头部冗余大、解析复杂、无状态但需要反复握手。而 RESP 的设计目标是:**简单、无状态、可逐行流式解析**——正好匹配 Redis "命令 → 响应"的交互模型。 + +RESP2(Redis 5.0 及之前)有 5 种数据类型: + +| 前缀 | 类型 | 示例 | 说明 | +|------|------|------|------| +| `+` | 简单字符串 | `+OK\r\n` | 状态回复 | +| `-` | 错误 | `-ERR unknown command\r\n` | 错误回复 | +| `:` | 整数 | `:1000\r\n` | 整数回复(如 INCR 结果) | +| `$` | 批量字符串 | `$5\r\nhello\r\n` | 二进制安全,支持最大 512MB | +| `*` | 数组 | `*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n` | 命令和多元素结果均用此格式 | + +一个 `SET name hello` 命令在 RESP2 中的编码: + +``` +*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nhello\r\n +``` + +> [!info] RESP3(Redis 6.0+) +> RESP3 在 RESP2 基础上新增了 Map、Set、布尔、浮点数、Null 等原生类型,以及 Attribute、Push 消息等。客户端启用 `HELLO 3` 后可使用 RESP3,返回值语义更丰富(如 `HGETALL` 直接返回 Map 而非扁平数组)。但协议格式依然保持流式解析的特性。 + +## Redis 6.0+ 多线程 IO + +> [!question] 既然单线程够用,为什么 Redis 6.0 又搞了多线程? +> 单线程事件循环处理命令确实很快,但**网络 IO 读写**(从 socket 读取请求、向 socket 写回响应)随着并发连接数增长,逐渐成为瓶颈。尤其是当响应数据量很大时(如 `LRANGE` 返回几千个元素),write 系统调用的耗时会显著拖慢事件循环。 + +Redis 6.0(2020 年发布)引入了 **IO 多线程**(Threaded IO),但请注意: + +> [!warning] 关键区分:IO 多线程 ≠ 命令多线程 +> Redis 6.0 的多线程**只用于网络 IO 的读写**,命令的**解析和执行仍然是单线程**。这保证了数据操作的线程安全性,无需加锁。 + +### IO 多线程工作流程 + +```mermaid +sequenceDiagram + participant M as "主线程" + participant T1 as "IO线程1" + participant T2 as "IO线程2" + participant T3 as "IO线程3" + + M->>M: "1 接受新连接,放入待处理队列" + M->>T1: "2 分配连接,让IO线程从socket读取请求" + M->>T2: "2 分配连接" + M->>T3: "2 分配连接" + Note over T1,T3: "并行从各自负责的连接中读取数据" + T1-->>M: "3 返回读取到的命令" + T2-->>M: "3 返回读取到的命令" + T3-->>M: "3 返回读取到的命令" + M->>M: "4 主线程串行执行所有命令" + M->>T1: "5 分配响应,让IO线程写回socket" + M->>T2: "5 分配响应" + M->>T3: "5 分配响应" + Note over T1,T3: "并行向各自负责的连接写回响应" +``` + +> [!tip] 一个形象的比喻 +> 想象一个厨师(主线程): +> - **6.0 之前**:厨师自己洗菜(读 IO)、自己炒菜(执行命令)、自己装盘(写 IO) +> - **6.0 之后**:雇了几个帮厨(IO 线程)帮忙洗菜和装盘,但炒菜(执行命令)还是厨师一个人 +> +> 炒菜仍然是瓶颈,但洗菜装盘并行化后,整体吞吐量提升了。 + +### 开启方式 + +```conf +# redis.conf +io-threads 4 # IO 线程数(建议 CPU 核心数的一半,最少 2) +io-threads-do-reads yes # 是否对读操作也使用多线程(默认 no) +``` + +| 配置 | 说明 | +|------|------| +| `io-threads` | 线程数,包含主线程自身。设 4 表示 1 个主线程 + 3 个 IO 线程 | +| `io-threads-do-reads` | 默认只对**写响应**做多线程;设为 `yes` 后读请求也并行化 | + +> [!question] 什么时候该开 IO 多线程? +> 以下条件**同时满足**时才值得开启: +> 1. 并发连接数很高(数千以上) +> 2. 单次响应数据量较大(大 Key 读取、大范围查询) +> 3. CPU 核心数充足(至少 4 核) +> +> 如果你的场景是大量小 Key 的 GET/SET(典型缓存场景),瓶颈在命令执行本身而非网络 IO,IO 多线程的收益可能并不明显。 + +## 后台线程:BIO + +除了 IO 线程,Redis 还维护了一组 **BIO(Background IO)后台线程**,专门执行那些不适合在主线程中做的耗时操作,避免阻塞事件循环。 + +```mermaid +flowchart TD + MAIN["主线程事件循环"] -->|"提交任务"| QUEUE["BIO 任务队列"] + QUEUE --> T1["BIO 线程 1
文件关闭"] + QUEUE --> T2["BIO 线程 2
AOF fsync"] + QUEUE --> T3["BIO 线程 3
lazyfree 异步删除"] + + style MAIN fill:#f9d,stroke:#96c + style T1 fill:#bbf,stroke:#66c + style T2 fill:#bbf,stroke:#66c + style T3 fill:#bbf,stroke:#66c +``` + +BIO 线程池在 Redis 启动时就已创建(默认 3 个线程),各自负责不同类型的任务: + +| BIO 线程 | 职责 | 触发场景 | +|----------|------|---------| +| `close` | 异步关闭文件描述符 | AOF 重写完成后关闭旧文件 | +| `fsync` | 异步刷盘 | `appendfsync everysec` 模式下每秒调用 | +| `lazyfree` | 异步释放大对象内存 | `UNLINK`、`FLUSHDB ASYNC`、过期 Key 惰性删除 | + +> [!question] `DEL` 和 `UNLINK` 有什么区别? +> - `DEL`:在**主线程**中同步释放内存。如果 Key 是一个包含 100 万元素的 Hash,主线程会被阻塞几毫秒甚至更久 +> - `UNLINK`:仅在主线程中断开 Key 的引用(O(1)),实际内存释放交给 BIO 的 `lazyfree` 线程异步完成——主线程几乎不受影响 +> +> 生产环境中,删除大 Key **务必使用 `UNLINK`**。 + +## 连接管理 + +每个客户端连接在 Redis 内部都对应一个 `redisClient` 结构体(存储连接状态、当前数据库、输入/输出缓冲区等),连接数直接影响内存占用和事件循环效率。 + +```mermaid +flowchart LR + C1["客户端1"] -->|"TCP"| EP["epoll 就绪队列"] + C2["客户端2"] -->|"TCP"| EP + CN["客户端N"] -->|"TCP"| EP + EP -->|"事件循环处理"| MAIN["主线程"] + + style EP fill:#bbf,stroke:#66c + style MAIN fill:#f9d,stroke:#96c +``` + +### 关键配置 + +| 配置项 | 默认值 | 说明 | +|--------|--------|------| +| `maxclients` | 10000 | 最大同时连接数,超过后新连接被拒绝 | +| `tcp-backlog` | 511 | 内核 TCP 连接队列长度,需与系统 `somaxconn` 对齐 | +| `timeout` | 0(不超时) | 客户端空闲多久后断开,0 表示不主动断开 | +| `tcp-keepalive` | 300 | TCP 心跳探测间隔(秒),及时发现死连接 | + +> [!warning] 连接数暴涨的隐患 +> 连接数过多不仅消耗内存(每个连接约占 ~1KB 结构体 + 输出缓冲区),更重要的是 **epoll 事件通知量增大**,每轮循环需要遍历更多就绪文件描述符,主线程在 IO 上的时间占比上升,直接影响命令处理吞吐量。 +> +> 生产建议:在客户端使用**连接池**(如 go-redis 的 `PoolSize`),而非每次请求新建连接。 + +## 完整线程模型总览 + +把所有线程类型放在一起看: + +```mermaid +flowchart TB + subgraph Redis进程 + direction TB + subgraph "单线程:事件循环" + EVENT["主线程
命令解析 + 命令执行 + 定时任务"] + end + subgraph "IO 多线程 (Redis 6.0+)" + IOT1["IO 线程 1
网络读写"] + IOT2["IO 线程 2
网络读写"] + IOTN["IO 线程 N..."] + end + subgraph "BIO 后台线程" + BIO1["close 线程"] + BIO2["fsync 线程"] + BIO3["lazyfree 线程"] + end + subgraph "子进程 (fork)" + FORK["RDB BGSAVE
AOF Rewrite"] + end + end + + CLIENT["客户端"] --> IOT1 + IOT1 -->|"读取命令"| EVENT + EVENT -->|"写响应"| IOT2 + EVENT -->|"提交异步任务"| BIO1 + EVENT -->|"触发持久化"| FORK + + style EVENT fill:#f9d,stroke:#96c + style IOT1 fill:#bbf,stroke:#66c + style IOT2 fill:#bbf,stroke:#66c + style IOTN fill:#bbf,stroke:#66c + style BIO1 fill:#dfd,stroke:#6c6 + style BIO2 fill:#dfd,stroke:#6c6 + style BIO3 fill:#dfd,stroke:#6c6 + style FORK fill:#ffd,stroke:#c90 +``` + +> [!tip] 记忆口诀 +> - **主线程**:命令执行的"唯一话事人",保证了无锁设计 +> - **IO 线程**:主线程的"快递员",只负责搬数据不负责处理 +> - **BIO 线程**:主线程的"后勤兵",处理脏活累活(关文件、刷盘、删大 Key) +> - **子进程**:持久化的"外包队",fork 出来做 RDB 快照和 AOF 重写 + +## serverCron 定时任务 + +主线程事件循环中,除了处理客户端命令,还会周期性执行 `serverCron`(默认每秒执行 `hz` 次,默认 `hz=10`,即每 100ms 一次)。这是 Redis 内部的"心跳": + +| 任务 | 频率 | 说明 | +|------|------|------| +| 过期 Key 扫描 | 每次 serverCron | 随机抽样检查,惰性删除已过期的 Key | +| 增量 rehash | 每次 serverCron | 对正在扩容的 Hash 表做渐进式迁移 | +| AOF 缓冲区刷新 | 每次 serverCron | 检查 aof_buf 是否需要写入/同步 | +| RDB / AOF 持久化检查 | 每次 serverCron | 检查是否满足 BGSAVE / BGREWRITEAOF 触发条件 | +| 集群心跳 | 每次 serverCron | Cluster 模式下发送 Gossip 消息 | +| 统计信息更新 | 每次 serverCron | 更新 INFO 中的 QPS、内存使用等指标 | +| 客户端超时断开 | 每次 serverCron | 检查 idle 超时的客户端连接 | + +> [!question] 为什么 `hz` 不能调太高? +> `serverCron` 在**主线程**中执行,如果频率过高(如 `hz=1000`),定时任务本身会占用更多 CPU 时间片,反而影响命令处理吞吐量。官方建议保持默认 `hz=10`,除非你有明确的调优需求。 + +## fork 子进程与 COW 内存风险 + +Redis 的 RDB 持久化和 AOF 重写都依赖 `fork()` 创建子进程。`fork()` 本身是 O(1) 的(Linux 使用 Copy-On-Write),但**后续的内存增长可能非常危险**。 + +```mermaid +flowchart LR + P["父进程
10GB 数据"] -->|"fork()"| C["子进程
共享同一份物理页"] + C -->|"写入触发 COW"| NEW["复制物理页
额外消耗内存"] + + style P fill:#f9d,stroke:#96c + style C fill:#ffd,stroke:#c90 + style NEW fill:#fdd,stroke:#c00 +``` + +> [!warning] fork 后内存可能翻倍 +> 子进程刚 fork 时与父进程共享物理内存页(COW)。但如果父进程在子进程做 RDB/AOF 期间**持续写入大量数据**,每次写操作都会触发 COW——被修改的内存页会被复制一份。 +> +> 最坏情况:**fork 瞬间实际可用内存需要为当前数据量的 2 倍**。如果你的 Redis 实例用了 10GB,系统剩余内存不足 10GB,fork 后可能触发 OOM Killer,导致 Redis 进程被系统杀死。 +> +> 生产建议: +> - `maxmemory` 设为物理内存的 **60%~70%**,预留 fork 空间 +> - 监控 `latest_fork_usec` 指标,超过 1ms 说明数据量大、fork 开销高 +> - 写入密集时避免同时触发 RDB 和 AOF rewrite + +## Redis 7.0+ 架构演进 + +Redis 7.0(2022 年发布)在持久化和线程模型方面带来了重要改进: + +### Multi-Part AOF + +Redis 7.0 重新设计了 AOF 机制,将单一 AOF 文件拆分为多个部分: + +```mermaid +flowchart LR + BASE["base AOF
RDB 格式全量快照"] -->|"append"| INCR["incr AOF
增量命令日志"] + INCR -->|"rewrite 时合并"| NEW_BASE["新 base AOF"] + + style BASE fill:#dfd,stroke:#6c6 + style INCR fill:#bbf,stroke:#66c +``` + +| 特性 | 6.x 及之前 | 7.0+ | +|------|-----------|------| +| AOF 文件结构 | 单文件,rewrite 时写临时文件再 rename | base(RDB)+ incr(AOF 增量),独立管理 | +| rewrite 风险 | rewrite 期间出错可能损坏原 AOF | base 和 incr 分离,rewrite 失败不影响已有数据 | +| 加载速度 | 全量回放 AOF 命令 | 先加载 base(RDB 快),再回放 incr(少量日志) | + +### IO 线程模型改进 + +Redis 7.0 对 IO 线程做了性能优化: + +- **更精细的负载均衡**:主线程根据各连接的待处理数据量分配 IO 线程,避免某些线程"吃不饱" +- **减少锁竞争**:IO 线程与主线程之间的队列同步机制优化,降低临界区长度 + +> [!tip] 版本选择建议 +> 如果是新项目,直接使用 **Redis 7.2+**(稳定版)。Multi-Part AOF 解决了老版 AOF rewrite 的数据安全问题,IO 线程优化也让高并发场景下的表现更稳定。 + +## 关联笔记 + +- [[hhs/Redis/01-安装与部署]] — 安装与配置文件详解 +- [[hhs/Redis/04-RDB持久化]] — fork 子进程做快照的具体过程 +- [[hhs/Redis/05-AOF持久化]] — 主线程与 BIO fsync 线程的协作 +- [[hhs/Redis/09-高级特性]] — 慢命令排查与 Lua 脚本的原子性 +- [[hhs/Redis/11-运维与性能调优]] — `hz` 调优、慢查询日志、大 Key 治理 diff --git a/hhs/Redis/README.md b/hhs/Redis/README.md index 76ceb36..18adba0 100644 --- a/hhs/Redis/README.md +++ b/hhs/Redis/README.md @@ -17,6 +17,12 @@ create time: 2026-05-15 18:10 ## 知识体系 +### 〇、架构总览 + +| # | 主题 | 链接 | +|---|------|------| +| 0.1 | 架构与线程模型 | [[hhs/Redis/00-Redis架构与线程模型]] — 单线程事件循环、IO 多路复用、BIO 后台线程、Redis 6.0 多线程 IO | + ### 一、入门基础 | # | 主题 | 链接 |