土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】高可用设计模式:冗余、故障转移与仲裁

文章导航

分类入口
architecture
标签入口
#high-availability#active-passive#active-active#split-brain#fencing#failover

目录

一个支付系统的数据库主节点在凌晨 3:17 发生磁盘故障,备节点检测到心跳超时并在 8 秒内完成切换,对外服务中断时间控制在 10 秒以内。另一个电商平台的数据库也发生了类似故障,但因为备节点和主节点同时认为自己是主——脑裂(Split-Brain)——两个节点各自接受写入,导致订单数据出现不可调和的冲突,恢复用了 6 小时。

两个系统都声称自己是”高可用架构”。差别在于:前者的高可用设计经过了故障检测、仲裁机制和数据一致性三个层面的完整考量;后者只做了冗余部署,缺少仲裁和 Fencing 机制。

高可用(High Availability,HA)不是一个功能,而是一组设计模式的组合。冗余提供了故障发生后的恢复能力,但冗余本身不等于高可用——如何检测故障、如何决定切换、切换过程中如何保证数据一致、如何避免脑裂,这些问题的答案决定了一个系统到底能做到几个 9。

本文从度量体系开始,逐层拆解冗余模型、故障检测、切换机制、脑裂防护、网络层切换、数据一致性,最后给出工程案例和模式选型对比。


一、停机的代价:为什么需要高可用

停机的代价可以用三个维度来衡量:直接经济损失、用户信任损失、合规风险。

直接经济损失。 2017 年 Amazon 的 S3 故障持续约 4 小时,影响了大量依赖 S3 的互联网服务,据估算造成超过 1.5 亿美元的直接经济损失。对于在线交易系统,每分钟停机可能意味着数万笔交易无法完成。一个日均交易额 1 亿元的支付系统,如果每分钟处理约 7 万元交易,停机 30 分钟的直接交易损失就超过 200 万元。

用户信任损失。 用户对服务可用性的容忍度在过去十年里持续降低。移动互联网时代,一个 App 打不开,用户 3 秒内就会切换到竞争对手。信任一旦失去,恢复的成本远高于技术修复本身的成本。

合规风险。 金融、医疗、电信行业有明确的可用性要求。中国人民银行对支付清算系统的可用性要求是 99.99%,欧盟的 DORA(Digital Operational Resilience Act)对金融机构的 ICT 系统可用性有法律层面的约束。

这些代价不是线性的。从 99.9% 提升到 99.99% 的工程成本可能是 10 倍,但从 99.9% 掉到 99% 的业务损失可能是 100 倍。高可用设计的目标不是”永不停机”——那在物理上不可能——而是把停机时间控制在业务可以承受的范围内。


二、可用性度量:MTBF、MTTR 与”几个 9”

基本概念

高可用的定量描述依赖三个核心指标:

MTBF(Mean Time Between Failures,平均故障间隔时间): 系统两次故障之间的平均正常运行时间。MTBF 越大,说明系统越稳定、故障频率越低。

MTTR(Mean Time To Repair,平均修复时间): 从故障发生到服务恢复的平均时间。MTTR 包含故障检测时间、诊断时间、修复时间和恢复验证时间。

MTTF(Mean Time To Failure,平均无故障时间): 对于不可修复的组件(如硬盘),使用 MTTF 而非 MTBF。对于可修复的系统,MTBF ≈ MTTF + MTTR。

可用性(Availability)的计算公式:

A = MTBF / (MTBF + MTTR)

这个公式揭示了一个关键洞察:提升可用性有两条路径——增大 MTBF(减少故障发生的频率)或者减小 MTTR(缩短故障恢复的时间)。高可用设计在两个方向上同时发力,但实践中,缩短 MTTR 的投入产出比往往更高。

原因很简单:MTBF 的提升受限于硬件可靠性、软件缺陷率等难以根本消除的因素;而 MTTR 可以通过自动化故障检测、自动化切换、预配置备节点等工程手段大幅降低。

“几个 9”的含义

工业界用”几个 9”来描述可用性等级。下面的表格列出了不同等级的含义:

可用性等级 百分比 每年允许停机时间 每月允许停机时间 每周允许停机时间 典型场景
2 个 9 99% 3.65 天 7.31 小时 1.68 小时 内部管理系统
3 个 9 99.9% 8.77 小时 43.83 分钟 10.08 分钟 一般业务系统
3.5 个 9 99.95% 4.38 小时 21.92 分钟 5.04 分钟 电商平台
4 个 9 99.99% 52.60 分钟 4.38 分钟 1.01 分钟 支付系统、核心交易
4.5 个 9 99.995% 26.30 分钟 2.19 分钟 30.24 秒 电信核心网
5 个 9 99.999% 5.26 分钟 26.30 秒 6.05 秒 航空管制、医疗设备

从这张表可以看出几个关键信息:

第一,每提升一个 9,允许的停机时间缩短约 10 倍。 从 3 个 9 到 4 个 9,允许停机时间从每年 8.77 小时降到 52.60 分钟。

第二,4 个 9 意味着每个月的停机窗口只有约 4 分钟。 这意味着故障检测加切换的总时间必须控制在分钟级别以内——人工介入几乎不可能,必须依赖自动化。

第三,5 个 9 意味着每年只允许 5 分钟停机。 这个级别的可用性通常需要全冗余硬件、无单点故障设计、自动化故障检测和切换、以及完善的灾备方案。

可用性的计算陷阱

可用性计算有几个常见的误区:

串联系统的可用性。 如果一个请求链路经过 A、B、C 三个服务,每个服务的可用性都是 99.9%,那么整条链路的可用性是:

A_total = 0.999 × 0.999 × 0.999 = 0.997 ≈ 99.7%

三个 3 个 9 的服务串联,整体只有不到 3 个 9。这就是为什么微服务架构下,单个服务的可用性标准往往需要高于系统整体的目标。

并联系统的可用性。 如果同一个服务部署了两个相互独立的副本,任一副本可用即服务可用,那么:

A_parallel = 1 - (1 - A)^2 = 1 - (0.001)^2 = 1 - 0.000001 = 99.9999%

两个 3 个 9 的节点并联,理论可用性达到 6 个 9。但这有一个前提——两个节点的故障必须是独立的。如果共用同一个电源、同一个机架、同一个交换机,那么一个硬件故障可能同时击倒两个节点,独立性假设就不成立了。

计划内停机算不算? 不同组织的口径不同。有些组织把计划内维护窗口排除在可用性计算之外,有些则全部计入。SLA(Service Level Agreement)里必须明确这一点,否则同样是”4 个 9”,实际含义可能完全不同。


三、冗余模型:从 Active-Passive 到 N+M

冗余(Redundancy)是高可用的基础。没有冗余就没有接管资源。冗余模式的选择直接影响成本、复杂度和切换速度。

Active-Passive(主备模式)

Active-Passive 也叫主备模式(Master-Slave 或 Primary-Standby)。系统中有一个主节点(Active)处理所有请求,一个或多个备节点(Passive)处于等待状态,当主节点故障时,备节点接管服务。

graph TB
    Client[客户端] --> VIP[VIP 虚拟 IP]
    VIP --> Active[主节点 Active]
    Active -.->|数据同步| Standby[备节点 Passive]
    Active -.->|心跳检测| Standby
    
    subgraph 正常状态
        Active
        Standby
    end
    
    style Active fill:#4CAF50,color:#fff
    style Standby fill:#9E9E9E,color:#fff
    style VIP fill:#2196F3,color:#fff

工作机制:

  1. 主节点承担所有读写请求。
  2. 数据通过同步或异步复制传输到备节点。
  3. 心跳机制持续检测主节点状态。
  4. 主节点故障时,备节点提升为新的主节点,VIP 漂移到新主节点。

优点:

缺点:

典型使用场景: 传统数据库主备(MySQL 主从、PostgreSQL 流复制)、Redis Sentinel 模式、单体应用的 HA 部署。

Active-Active(双活模式)

Active-Active 也叫双活或多活(Multi-Active)。所有节点同时处理请求,没有”备”的概念。

工作机制:

  1. 多个节点同时接受读写请求。
  2. 请求通过负载均衡器分发到各节点。
  3. 节点间需要实时数据同步机制来保证一致性。
  4. 任一节点故障时,负载均衡器将其从服务池中移除,流量自动分发到剩余节点。

优点:

缺点:

典型使用场景: 无状态应用集群、DNS 轮询、分布式缓存集群、跨数据中心部署。

关于”真双活”的说明: 严格意义上的数据库双活(两个节点同时写入同一份数据且保证强一致性)在工程上极其复杂。很多声称”双活”的方案,实际上是”读双活、写单活”,或者”按分片双活”——不同的数据分片有各自的主节点,分布在不同数据中心,整体看起来是双活,但对每一个分片来说仍然是 Active-Passive。

N+1 冗余

N+1 冗余的含义是:N 个工作节点加 1 个备用节点。正常情况下 N 个节点处理请求,备用节点待命。任一工作节点故障时,备用节点接管。

和 Active-Passive 的区别: Active-Passive 通常是 1+1(一主一备),N+1 是多主一备。N+1 的备用节点是”共享”的——它不是某个特定工作节点的备份,而是整个集群的备份。这意味着备用资源的比例更低,成本效率更高。

示例: 一个服务需要 5 个节点才能承载峰值流量。1+1 方案需要部署 10 个节点(5 个主,5 个备),资源利用率 50%。N+1 方案只需要 6 个节点(5 个工作节点,1 个备用),资源利用率 83%。

限制: N+1 假设同一时间最多只有一个节点故障。如果两个工作节点同时故障,备用节点只能接管其中一个。对于故障概率较高或对可用性要求极高的场景,需要 N+2 或更高的冗余度。

N+M 冗余

N+M 冗余是 N+1 的泛化:N 个工作节点加 M 个备用节点。M 的选择取决于对同时故障数量的容忍度。

M 值的确定方法:

典型使用场景: 大规模集群部署(如 Kubernetes 中的 ReplicaSet,设置副本数 = 需要数 + 冗余数)、数据中心级别的服务器冗余、网络设备冗余。

冗余模型对比

维度 Active-Passive Active-Active N+1 N+M
资源利用率 50%(1+1 场景) 接近 100% N/(N+1) N/(N+M)
切换速度 秒级到十秒级 毫秒到秒级 秒级到十秒级 秒级到十秒级
数据一致性 较好(单写入点) 复杂(多写入点) 较好 较好
架构复杂度
容忍同时故障数 1 取决于剩余容量 1 M
典型成本比 2x 1.2x-1.5x 1.2x 1+(M/N)x

四、故障检测与切换机制

冗余只是高可用的前提。有了备用节点,还需要回答:主节点是否故障了?确认故障后谁来接管?接管过程怎么执行?

心跳检测(Heartbeat Detection)

心跳检测是最基础的故障检测方式。备节点(或监控节点)定期向主节点发送探测包,如果连续 N 次未收到响应,则判定主节点故障。

心跳检测的配置通常涉及三个参数:

以 Keepalived 为例,心跳检测的配置如下:

vrrp_instance VI_1 {
    state MASTER
    interface eth0
    virtual_router_id 51
    priority 100
    advert_int 1          # 心跳间隔:1 秒

    authentication {
        auth_type PASS
        auth_pass mypassword
    }

    virtual_ipaddress {
        192.168.1.100/24   # VIP 地址
    }

    track_script {
        chk_service        # 关联服务检查脚本
    }
}

vrrp_script chk_service {
    script "/etc/keepalived/check_service.sh"
    interval 2             # 每 2 秒执行一次检查
    weight -20             # 检查失败时降低优先级 20
    fall 3                 # 连续 3 次失败判定为故障
    rise 2                 # 连续 2 次成功判定为恢复
}

对应的服务检查脚本:

#!/bin/bash
# /etc/keepalived/check_service.sh
# 检查 MySQL 是否可用

MYSQL_HOST="127.0.0.1"
MYSQL_PORT=3306
MYSQL_USER="monitor"
MYSQL_PASS="monitor_pass"

# 尝试执行简单查询
mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASS" \
    -e "SELECT 1" > /dev/null 2>&1

if [ $? -ne 0 ]; then
    # MySQL 不可用,返回非零退出码
    exit 1
fi

# 检查复制延迟(可选)
SLAVE_LAG=$(mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASS" \
    -e "SHOW SLAVE STATUS\G" 2>/dev/null | grep "Seconds_Behind_Master" | awk '{print $2}')

if [ -n "$SLAVE_LAG" ] && [ "$SLAVE_LAG" -gt 30 ]; then
    # 复制延迟超过 30 秒,判定为不健康
    exit 1
fi

exit 0

心跳检测的局限性: 心跳检测只能检测”节点是否可达”,不能检测”服务是否正常”。一个节点可能网络可达但数据库进程已挂,也可能数据库进程在运行但不能正常响应查询。因此生产环境使用应用级别的健康检查——如上面的脚本实际执行 SQL 查询来验证。

仲裁投票(Quorum Voting)

心跳检测有一个根本问题:如果主节点和备节点之间的网络中断了,但主节点本身是正常的怎么办?备节点会认为主节点故障并尝试接管,结果两个节点同时提供服务——脑裂。

仲裁投票(Quorum)机制的核心思想是:不依赖单一节点的判断,而是让多个节点共同投票决定故障。只有获得多数票(Quorum)的节点组才能继续提供服务。

Quorum 的计算: 对于一个由 N 个节点组成的集群,Quorum = floor(N/2) + 1。例如,3 节点集群的 Quorum 是 2,5 节点集群的 Quorum 是 3。

工作机制:

  1. 每个节点持续与其他所有节点保持心跳。
  2. 当某个节点检测到其他节点不可达时,它会统计自己能联系到的节点数。
  3. 如果能联系到的节点数(含自己)≥ Quorum,则认为自己在”多数派”一侧,继续提供服务。
  4. 如果能联系到的节点数 < Quorum,则认为自己在”少数派”一侧,主动停止服务。
class QuorumChecker:
    """仲裁检查器:判断当前节点是否在多数派一侧"""

    def __init__(self, cluster_nodes: list[str], self_node: str):
        self.cluster_nodes = cluster_nodes
        self.self_node = self_node
        self.quorum_size = len(cluster_nodes) // 2 + 1

    def check_quorum(self, reachable_nodes: set[str]) -> bool:
        """检查当前可达节点数是否满足 Quorum 要求"""
        # 加上自己
        reachable_count = len(reachable_nodes) + 1
        has_quorum = reachable_count >= self.quorum_size

        if not has_quorum:
            self._step_down()
        return has_quorum

    def _step_down(self):
        """不满足 Quorum,主动降级"""
        print(f"节点 {self.self_node} 不在多数派一侧,"
              f"可达节点数不足 {self.quorum_size},主动停止服务")
        # 停止接受写请求
        # 释放 VIP
        # 记录日志


# 示例:3 节点集群
checker = QuorumChecker(
    cluster_nodes=["node-1", "node-2", "node-3"],
    self_node="node-1"
)

# 场景 1:node-1 能联系到 node-2,不能联系到 node-3
# 可达节点数 = 2(node-1 + node-2),Quorum = 2,满足
result = checker.check_quorum(reachable_nodes={"node-2"})
print(f"Quorum 检查结果: {result}")  # True

# 场景 2:node-1 不能联系到 node-2 和 node-3
# 可达节点数 = 1(仅 node-1),Quorum = 2,不满足
result = checker.check_quorum(reachable_nodes=set())
print(f"Quorum 检查结果: {result}")  # False

Quorum 与节点数的关系: 3 节点容忍 1 个故障(Quorum=2),5 节点容忍 2 个故障(Quorum=3),7 节点容忍 3 个故障(Quorum=4)。偶数节点没有额外优势——4 节点的 Quorum 是 3,只容忍 1 个故障,和 3 节点相同。因此 Quorum 集群通常使用奇数个节点。

Witness 节点(见证节点)

当集群只有 2 个节点时(最常见的 Active-Passive 部署),无法使用 Quorum 投票——两个节点各持一票,网络分区时无法分出多数派。解决方案是引入第三方 Witness(见证)节点。

Witness 节点不承载业务数据,只参与投票。它的作用是在两个业务节点无法互相通信时,充当仲裁者:哪个业务节点能联系到 Witness,哪个就获得继续服务的权利。

# Corosync 集群配置示例(含 Witness 节点)
totem {
    version: 2
    secauth: on
    cluster_name: payment-db-cluster
    transport: udpu
}

nodelist {
    node {
        ring0_addr: 10.0.1.10    # 主节点
        name: db-primary
        nodeid: 1
    }
    node {
        ring0_addr: 10.0.1.11    # 备节点
        name: db-standby
        nodeid: 2
    }
    node {
        ring0_addr: 10.0.2.10    # Witness 节点(不同子网)
        name: witness
        nodeid: 3
    }
}

quorum {
    provider: corosync_votequorum
    expected_votes: 3
    two_node: 0                   # 不使用 two_node 模式
}

Witness 节点的部署原则:

故障检测延迟分析

从故障发生到完成切换的总时间,决定了系统的实际 MTTR。这个过程可以分解为四个阶段:

阶段 典型耗时 影响因素
故障发生到心跳超时 3-15 秒 心跳间隔 × 重试次数
仲裁投票完成 1-5 秒 集群规模、网络延迟
角色切换(VIP 漂移、服务启动) 1-10 秒 服务启动时间、ARP 广播
客户端连接恢复 1-30 秒 连接池超时、重试策略
总计 6-60 秒

以一个典型的 Keepalived + MySQL 主备架构为例:

总切换时间:约 10-36 秒。这个时间对于 4 个 9 的可用性来说足够了(每月允许 4.38 分钟停机),但对于需要秒级切换的场景,需要更积极的配置。


五、脑裂问题与 Fencing

什么是脑裂

脑裂(Split-Brain)是分布式系统中最危险的故障之一。它发生在以下场景:

集群中的节点之间的通信中断(网络分区),但各节点本身运行正常。每一侧的节点都认为对方已经故障,于是各自提升为主节点,独立接受写请求。当网络恢复后,两侧的数据已经分叉,无法自动合并。

sequenceDiagram
    participant C1 as 客户端 A
    participant N1 as 节点 1(原主)
    participant N2 as 节点 2(备)
    participant C2 as 客户端 B

    Note over N1,N2: 正常状态:节点 1 为主,节点 2 为备
    C1->>N1: 写入订单 #1001
    N1->>N2: 同步数据

    Note over N1,N2: 网络分区发生
    N1--xN2: 心跳超时
    N2--xN1: 心跳超时

    Note over N2: 节点 2 判定节点 1 故障,自我提升为主
    Note over N1: 节点 1 仍在正常运行

    C1->>N1: 写入订单 #1002(金额 100)
    C2->>N2: 写入订单 #1002(金额 200)

    Note over N1,N2: 网络恢复
    Note over N1,N2: 订单 #1002 出现冲突:<br/>节点 1 记录金额 100<br/>节点 2 记录金额 200

脑裂的危害:

脑裂的根因

脑裂的根因可以归结为一句话:无法区分”节点故障”和”网络分区”。

从观察者的视角看,“对方没有响应心跳”有两种可能的解释:

  1. 对方确实挂了(节点故障),应该切换。
  2. 对方没挂,只是网络断了(网络分区),不应该切换。

如果不能区分这两种情况就贸然切换,就可能产生脑裂。

STONITH:确保故障节点不再运行

STONITH(Shoot The Other Node In The Head)的策略很直接:在切换之前,先确保原来的主节点已经被彻底关闭。即使不确定它是真的故障了还是只是网络不通,直接把它关机(或者重启),然后再执行切换。

STONITH 的实现方式:

方式 机制 适用环境
IPMI/BMC 通过带外管理接口发送关机指令 物理服务器
PDU(电源分配单元) 远程切断目标服务器的电源 数据中心
虚拟化 API 调用 vSphere/KVM API 关闭虚拟机 虚拟化环境
云 API 调用 AWS/Azure/GCP API 停止实例 云环境
SBD(Storage-Based Death) 通过共享存储上的”毒药丸”机制触发自杀 共享存储集群

以 Pacemaker 集群的 STONITH 配置为例:

# 配置 IPMI STONITH 设备
pcs stonith create ipmi-fencing fence_ipmilan \
    ipaddr="10.0.1.200" \
    login="admin" \
    passwd="admin_pass" \
    pcmk_host_list="db-primary" \
    pcmk_host_check="static-list" \
    power_wait="5"

# 配置 STONITH 策略
pcs property set stonith-enabled=true
pcs property set stonith-action=reboot
pcs property set stonith-timeout=60s

# 验证 STONITH 配置
pcs stonith show
pcs stonith fence db-primary --off

在云环境中,STONITH 通过云 API 实现——调用 ec2.stop_instances(Force=True) 或等价的 API 强制停止目标实例,然后通过 describe_instances 验证实例确实已停止。

STONITH 的争议:

STONITH 是一个”宁可误杀,不可放过”的策略。它的好处是彻底消除脑裂风险——原主节点被物理关机了,不可能再接受写入。但它的代价也很明显:

Quorum-Based 方案

Quorum 方案在前一节已经讨论过。它的核心是”少数派自杀”:网络分区发生后,不在多数派一侧的节点主动停止服务。这样可以保证在任何时刻,最多只有一组节点在提供服务。

Quorum 方案和 STONITH 并不互斥。生产环境通常同时使用:Quorum 负责”谁有权成为主”的决策,STONITH 负责”确保旧主已经停止”的执行。

# Pacemaker 集群同时启用 Quorum 和 STONITH
pcs property set no-quorum-policy=stop      # 失去 Quorum 时停止所有资源
pcs property set stonith-enabled=true        # 启用 STONITH

# 配置 Quorum 策略
# last_man_standing: 集群缩小时自动调整 expected_votes
# wait_for_all: 启动时等待所有节点加入
cat > /etc/corosync/corosync.conf.d/quorum.conf << 'EOF'
quorum {
    provider: corosync_votequorum
    expected_votes: 3
    wait_for_all: 1
    last_man_standing: 1
    last_man_standing_window: 10000
}
EOF

Fencing 策略的选择

方案 防脑裂效果 实施复杂度 适用场景
STONITH(IPMI) 物理服务器、数据中心
STONITH(云 API) 云环境
SBD(存储毒药丸) 共享存储集群
Quorum + 少数派自杀 中强 3 节点及以上集群
Witness 节点 2 节点集群
不做 Fencing 不推荐用于生产环境

六、VIP 漂移 vs DNS 切换

故障检测完成、仲裁投票通过之后,最后一步是让客户端流量切换到新的主节点。两种主流方式是 VIP 漂移和 DNS 切换。

VIP 漂移(VRRP/Keepalived)

VIP(Virtual IP,虚拟 IP)漂移的原理是:一个虚拟 IP 地址不绑定到任何物理网卡,而是由集群管理软件动态绑定到当前的主节点。当主节点故障时,VIP 被迁移到新的主节点,客户端继续使用同一个 IP 地址访问服务。

VRRP(Virtual Router Redundancy Protocol,虚拟路由冗余协议)是 VIP 漂移的标准协议。Keepalived 是 VRRP 的主流开源实现。

VIP 漂移的过程:

  1. 主节点持有 VIP,并通过 VRRP 协议定期向备节点发送通告。
  2. 备节点监听 VRRP 通告。如果超过设定时间未收到通告,备节点判定主节点故障。
  3. 备节点接管 VIP:将 VIP 绑定到自己的网卡上。
  4. 备节点发送免费 ARP(Gratuitous ARP)广播,通知局域网内所有设备更新 ARP 缓存——“这个 IP 地址现在对应的 MAC 地址是我的”。
  5. 后续发往 VIP 的数据包被路由到新的主节点。

Keepalived 完整配置示例:

# /etc/keepalived/keepalived.conf(主节点)
global_defs {
    router_id db_primary
    vrrp_skip_check_adv_addr
    vrrp_garp_interval 0
    vrrp_gna_interval 0
}

vrrp_script chk_mysql {
    script "/etc/keepalived/check_mysql.sh"
    interval 2
    weight -30
    fall 3
    rise 2
}

vrrp_instance VI_DB {
    state MASTER
    interface eth0
    virtual_router_id 100
    priority 100
    advert_int 1
    nopreempt                    # 不抢占:故障恢复后不自动切回

    unicast_src_ip 10.0.1.10    # 使用单播替代组播
    unicast_peer {
        10.0.1.11
    }

    authentication {
        auth_type PASS
        auth_pass ha_secret_2024
    }

    virtual_ipaddress {
        10.0.1.100/24 dev eth0   # VIP 地址
    }

    track_script {
        chk_mysql
    }

    notify_master "/etc/keepalived/notify.sh master"
    notify_backup "/etc/keepalived/notify.sh backup"
    notify_fault  "/etc/keepalived/notify.sh fault"
}

备节点的配置与主节点基本相同,关键差异是 state BACKUPpriority 90(低于主节点的 100)。当主节点心跳超时,备节点自动接管 VIP。nopreempt 参数确保原主节点恢复后不会自动抢回 VIP,避免不必要的二次切换。

VIP 漂移的优缺点:

优点 缺点
切换速度快(通常 1-3 秒) 仅限同一二层网络(同一子网)
对客户端完全透明 跨数据中心不可用
不依赖 DNS 缓存 云环境支持有限(需要 Elastic IP)
实现成熟,经过大量生产验证 免费 ARP 可能被网络设备限速

DNS 切换

DNS 切换的原理是:客户端通过域名访问服务,域名解析到主节点的 IP 地址。当主节点故障时,修改 DNS 记录,将域名指向新主节点的 IP。

DNS 切换的过程:

  1. 正常状态下,DNS A 记录指向主节点 IP。
  2. 故障检测系统发现主节点不可用。
  3. 通过 DNS API 修改 A 记录,指向备节点 IP。
  4. 等待 DNS 缓存过期(TTL),客户端逐步切换到新节点。
import boto3


def dns_failover(
    hosted_zone_id: str,
    record_name: str,
    new_ip: str,
    ttl: int = 30
):
    """通过 Route53 API 执行 DNS 切换"""
    client = boto3.client("route53")

    response = client.change_resource_record_sets(
        HostedZoneId=hosted_zone_id,
        ChangeBatch={
            "Comment": f"Failover to {new_ip}",
            "Changes": [
                {
                    "Action": "UPSERT",
                    "ResourceRecordSet": {
                        "Name": record_name,
                        "Type": "A",
                        "TTL": ttl,
                        "ResourceRecords": [
                            {"Value": new_ip}
                        ]
                    }
                }
            ]
        }
    )
    return response["ChangeInfo"]["Status"]

云厂商(如 AWS Route53、阿里云 DNS)还支持基于健康检查的自动 DNS 故障转移:配置两条 Failover 类型的记录(PRIMARY 和 SECONDARY),当 PRIMARY 健康检查失败时,DNS 自动返回 SECONDARY 的 IP。

TTL 与切换延迟:

DNS 切换的最大问题是 TTL(Time To Live)。DNS 记录的 TTL 决定了客户端和中间 DNS 服务器缓存该记录的时间。即使你在 1 秒内修改了 DNS 记录,客户端可能在 TTL 过期之前仍然使用旧的 IP 地址。

TTL 设置 切换延迟 DNS 查询负载 适用场景
300 秒(5 分钟) 最长 5 分钟 对切换时间不敏感的场景
60 秒 最长 1 分钟 一般业务系统
30 秒 最长 30 秒 中高 需要较快切换的场景
10 秒 最长 10 秒 对切换时间敏感的场景

注意:TTL 只是建议值,某些客户端和中间 DNS 服务器可能不遵守 TTL,缓存时间更长。Java 的默认 DNS 缓存在某些 JVM 版本中是永久的,需要通过 JVM 参数 -Dsun.net.inetaddr.ttl=30java.security.Security.setProperty("networkaddress.cache.ttl", "30") 显式配置。

VIP 漂移 vs DNS 切换对比

维度 VIP 漂移(VRRP/Keepalived) DNS 切换
切换速度 1-3 秒 30 秒到数分钟(取决于 TTL)
网络范围 同一二层网络 跨任意网络
跨数据中心 不支持 支持
客户端改造 无需 无需(但需注意 DNS 缓存)
云环境兼容 部分支持(需要 Elastic IP 或等价物) 完全支持
实现复杂度
可靠性 高(不依赖缓存) 中(受 TTL 和 DNS 缓存行为影响)

选择建议: 同一数据中心内优先使用 VIP 漂移;跨数据中心使用 DNS 切换或 Anycast;也可两者结合——数据中心内部用 VIP,数据中心之间用 DNS。


七、主备切换中的数据一致性

故障切换不仅是”谁来接管服务”的问题,还有”切换后数据是否完整”的问题。核心在于数据复制模式的选择。

同步复制 vs 异步复制

同步复制(Synchronous Replication): 主节点收到写请求后,必须等待备节点确认收到并持久化了数据,才向客户端返回成功。

客户端 → 主节点(写入本地) → 备节点(写入并确认) → 主节点(返回成功给客户端)

优点:切换时不丢数据——备节点拥有和主节点完全一致的数据。

缺点:每次写入都要等待备节点的确认,写入延迟增加(增加一个网络往返时间)。如果备节点不可用,主节点的写入也会被阻塞(或超时失败),可用性反而降低。

异步复制(Asynchronous Replication): 主节点收到写请求后,写入本地即向客户端返回成功,然后异步将数据发送给备节点。

客户端 → 主节点(写入本地,返回成功给客户端) → 备节点(异步接收数据)

优点:写入延迟低,主节点不依赖备节点的可用性。

缺点:切换时可能丢数据——主节点已经确认但尚未同步到备节点的数据会丢失。

半同步复制(Semi-Synchronous Replication): MySQL 的半同步复制是一种折中方案。主节点等待至少一个备节点确认收到了 binlog 事件(写入 relay log),但不需要等待备节点执行(apply)该事件。

-- MySQL 半同步复制配置
-- 主节点
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
SET GLOBAL rpl_semi_sync_master_timeout = 5000;  -- 5 秒超时
SET GLOBAL rpl_semi_sync_master_wait_for_slave_count = 1;
SET GLOBAL rpl_semi_sync_master_wait_point = 'AFTER_SYNC';

-- 备节点
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;
STOP SLAVE IO_THREAD;
START SLAVE IO_THREAD;

半同步复制的超时机制:如果在 rpl_semi_sync_master_timeout 时间内没有收到备节点确认,主节点自动降级为异步复制。这意味着在备节点延迟或网络抖动时,系统不会因为等待备节点而阻塞,但数据丢失的风险也回来了。

RPO 与 RTO

RPO(Recovery Point Objective,恢复点目标): 可以容忍丢失多少数据。RPO = 0 意味着不能丢失任何数据,需要同步复制。RPO = 5 分钟意味着可以容忍最多丢失 5 分钟的数据。

RTO(Recovery Time Objective,恢复时间目标): 系统从故障到恢复服务的最长可接受时间。RTO = 30 秒意味着必须在 30 秒内恢复服务。

RPO 和 RTO 之间存在权衡:

配置 RPO RTO 写入性能影响
同步复制 + 自动切换 0(零数据丢失) 秒级 高(延迟增加)
半同步复制 + 自动切换 接近 0 秒级
异步复制 + 自动切换 秒级到分钟级 秒级
异步复制 + 人工切换 秒级到分钟级 分钟到小时级
定期备份 + 人工恢复 小时到天级 小时级

切换过程中的数据安全

即使使用了同步复制,切换过程本身也可能引入数据一致性问题。以下是几个需要注意的场景:

场景一:正在执行的事务。 主节点故障时,正在执行但尚未提交的事务会被丢弃。这是正确的行为——未提交的事务本来就不应该被持久化。但客户端可能已经发送了写请求并在等待响应,需要在应用层处理超时和重试。

场景二:已提交但未同步的事务。 在异步复制模式下,主节点崩溃时可能有已经提交的事务尚未同步到备节点。这些事务对应的数据会丢失。应用层需要能处理这种情况——例如,通过幂等设计确保重试不会产生副作用。

场景三:切换后的数据不一致窗口。 即使使用半同步复制,备节点在被提升为主时,可能还有已经收到但尚未 apply 的 relay log 事件。新主节点需要先 apply 完所有 relay log,才能开始接受新的写入。这个 apply 过程可能需要几秒到几十秒。

# MySQL 备节点提升为主时的数据一致性检查脚本
#!/bin/bash

# 1. 停止 IO 线程(停止从旧主接收新数据)
mysql -e "STOP SLAVE IO_THREAD;"

# 2. 等待 SQL 线程 apply 完所有 relay log
echo "等待 relay log apply 完成..."
while true; do
    EXEC_POS=$(mysql -e "SHOW SLAVE STATUS\G" | grep "Exec_Master_Log_Pos" | awk '{print $2}')
    READ_POS=$(mysql -e "SHOW SLAVE STATUS\G" | grep "Read_Master_Log_Pos" | awk '{print $2}')

    if [ "$EXEC_POS" = "$READ_POS" ]; then
        echo "Relay log 已全部 apply 完毕"
        break
    fi
    echo "等待中... Exec_Pos=$EXEC_POS, Read_Pos=$READ_POS"
    sleep 1
done

# 3. 停止复制,清除复制配置
mysql -e "STOP SLAVE; RESET SLAVE ALL;"

# 4. 设置为可写
mysql -e "SET GLOBAL read_only = OFF; SET GLOBAL super_read_only = OFF;"

# 5. 记录当前 binlog 位置(供其他备节点 change master)
mysql -e "SHOW MASTER STATUS\G"

echo "提升完成,当前节点已成为新主节点"

复制拓扑与一致性保证

不同的复制拓扑对数据一致性有不同的影响:

链式复制(A → B → C): 主节点 A 同步到 B,B 同步到 C。如果 A 故障,B 提升为主,C 的数据可能落后于 B。需要 C 先追赶到 B 的位置才能继续复制。

星型复制(A → B, A → C): 主节点 A 同时同步到 B 和 C。如果 A 故障,B 和 C 的数据位置可能不同。需要选择数据最新的节点作为新主。

组复制(Group Replication): MySQL Group Replication 和 Galera Cluster 使用基于 Paxos 的协议,所有节点在同一个一致性组内。写入需要获得多数节点的确认,任何节点故障都不影响数据一致性。


八、工程案例:某支付系统数据库高可用架构

以下是一个真实的支付系统数据库高可用架构案例。该系统日均处理约 5000 万笔交易,峰值 TPS 约 2 万,可用性要求 99.99%。

技术选型

组件 技术 说明
数据库 PostgreSQL 15 支持流复制、逻辑复制
HA 管理 Patroni 3.x 基于 DCS 的 PostgreSQL HA 管理
分布式协调 etcd 3.5 存储集群状态,提供 Leader 选举
连接代理 PgBouncer 连接池,减少数据库连接开销
VIP 管理 自定义脚本 + 云 API 通过回调脚本管理 Elastic IP
监控 Prometheus + Grafana 集群状态监控和告警

架构概览

graph TB
    App[应用服务集群] --> PgB_W[PgBouncer 写入]
    App --> PgB_R[PgBouncer 只读]

    PgB_W --> VIP_W[写入 VIP]
    PgB_R --> LB_R[只读负载均衡]

    VIP_W --> Primary[PostgreSQL Primary]
    LB_R --> Sync[PostgreSQL Sync Standby]
    LB_R --> Async[PostgreSQL Async Standby]

    Primary -->|同步复制| Sync
    Primary -->|异步复制| Async

    Patroni_1[Patroni Agent] --> Primary
    Patroni_2[Patroni Agent] --> Sync
    Patroni_3[Patroni Agent] --> Async

    Patroni_1 --> etcd[etcd 集群 3 节点]
    Patroni_2 --> etcd
    Patroni_3 --> etcd

    style Primary fill:#4CAF50,color:#fff
    style Sync fill:#2196F3,color:#fff
    style Async fill:#FF9800,color:#fff
    style etcd fill:#9C27B0,color:#fff

Patroni 配置

# /etc/patroni/patroni.yml
scope: payment-db-cluster
name: pg-node-1

restapi:
  listen: 0.0.0.0:8008
  connect_address: 10.0.1.10:8008

etcd3:
  hosts:
    - 10.0.2.10:2379
    - 10.0.2.11:2379
    - 10.0.2.12:2379

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576    # 1MB:超过此复制延迟的备节点不参与选举
    master_start_timeout: 300
    synchronous_mode: true               # 启用同步复制
    synchronous_mode_strict: false        # 没有同步备节点时允许降级为异步

    postgresql:
      use_pg_rewind: true
      use_slots: true
      parameters:
        max_connections: 500
        shared_buffers: 8GB
        wal_level: replica
        max_wal_senders: 10
        max_replication_slots: 10
        hot_standby: "on"
        wal_log_hints: "on"
        synchronous_commit: "on"          # 同步提交
        synchronous_standby_names: "ANY 1 (*)"  # 任意一个备节点确认即可

  initdb:
    - encoding: UTF8
    - data-checksums

  pg_hba:
    - host replication replicator 10.0.1.0/24 md5
    - host all all 10.0.1.0/24 md5

postgresql:
  listen: 0.0.0.0:5432
  connect_address: 10.0.1.10:5432
  data_dir: /var/lib/postgresql/15/main
  bin_dir: /usr/lib/postgresql/15/bin

  authentication:
    superuser:
      username: postgres
      password: "pg_super_pass"
    replication:
      username: replicator
      password: "repl_pass"

  callbacks:
    on_start: /etc/patroni/callbacks/on_start.sh
    on_stop: /etc/patroni/callbacks/on_stop.sh
    on_role_change: /etc/patroni/callbacks/on_role_change.sh

tags:
  nofailover: false
  noloadbalancer: false
  clonefrom: false

回调脚本:角色变更时更新 VIP

#!/bin/bash
# /etc/patroni/callbacks/on_role_change.sh

ACTION=$1
ROLE=$2
CLUSTER=$3

LOG_FILE="/var/log/patroni/callbacks.log"
ELASTIC_IP="eipalloc-0123456789abcdef0"
INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)

log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') [$ACTION] [$ROLE] $1" >> $LOG_FILE
}

case $ROLE in
    master|primary)
        log "本节点提升为 Primary,开始绑定 Elastic IP"

        # 绑定 Elastic IP 到当前实例
        aws ec2 associate-address \
            --instance-id "$INSTANCE_ID" \
            --allocation-id "$ELASTIC_IP" \
            --allow-reassociation

        if [ $? -eq 0 ]; then
            log "Elastic IP 绑定成功"
        else
            log "Elastic IP 绑定失败"
            exit 1
        fi

        # 通知监控系统
        curl -s -X POST "https://alert.internal/api/events" \
            -H "Content-Type: application/json" \
            -d "{
                \"event\": \"db_failover\",
                \"cluster\": \"$CLUSTER\",
                \"new_primary\": \"$INSTANCE_ID\",
                \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
            }"
        ;;

    replica|standby)
        log "本节点降级为 Replica"
        ;;
esac

Patroni 集群管理操作

# 查看集群状态
patronictl -c /etc/patroni/patroni.yml list

# 输出示例:
# + Cluster: payment-db-cluster -----+---------+---------+----+-----------+
# | Member    | Host       | Role    | State   | TL | Lag in MB |
# +-----------+------------+---------+---------+----+-----------+
# | pg-node-1 | 10.0.1.10  | Leader  | running |  5 |           |
# | pg-node-2 | 10.0.1.11  | Sync    | running |  5 |         0 |
# | pg-node-3 | 10.0.1.12  | Replica | running |  5 |       0.1 |
# +-----------+------------+---------+---------+----+-----------+

# 手动切换(Switchover,计划内)
patronictl -c /etc/patroni/patroni.yml switchover --master pg-node-1 --candidate pg-node-2 --force

# 紧急切换(Failover)
patronictl -c /etc/patroni/patroni.yml failover --candidate pg-node-2 --force

# 维护窗口暂停/恢复自动故障转移
patronictl -c /etc/patroni/patroni.yml pause
patronictl -c /etc/patroni/patroni.yml resume

关键设计决策说明

为什么选择 Patroni 而不是手动搭建 Keepalived + PostgreSQL?

Patroni 把故障检测、Leader 选举、复制管理、VIP 切换整合在一起,避免了手动编排多个组件时的一致性问题。手动方案中,Keepalived 可能已经切换了 VIP,但 PostgreSQL 的 promote 还没完成——客户端连接到新的 VIP,但数据库还不能写入。Patroni 通过 DCS(分布式协调服务,如 etcd)统一管理状态,确保角色切换和 VIP 切换的顺序正确。

为什么使用 synchronous_mode 但不使用 synchronous_mode_strict?

synchronous_mode: true 确保写入获得同步备节点确认(RPO = 0)。synchronous_mode_strict: false 允许同步备节点不可用时降级为异步复制,在数据安全和可用性之间做权衡。

为什么 maximum_lag_on_failover 设置为 1MB? 该参数控制复制延迟超过多少的备节点不参与选举。1MB 确保只有数据足够新的节点能成为 Leader,在数据安全和可用性之间取得平衡。

实测切换指标

在该支付系统的实际测试中,Patroni 自动故障转移的各阶段耗时如下:

阶段 耗时
故障发生到 Patroni 检测 约 10 秒(loop_wait 配置)
etcd Leader 选举 1-2 秒
PostgreSQL promote 1-3 秒
Elastic IP 重新绑定 2-5 秒
PgBouncer 连接恢复 3-5 秒
端到端总切换时间 约 17-25 秒

这个切换时间意味着每次故障转移造成约 20 秒的写入不可用。按每月一次故障转移计算,月度停机约 20 秒,远低于 4 个 9(每月 4.38 分钟)的要求。


九、高可用模式选型对比

以下是对三种主要高可用模式的综合对比。选型是”哪个最适合当前场景”的问题。

全维度对比表

维度 Active-Passive(主备) Active-Active(双活) N+1 冗余
架构复杂度
实施成本 中(需要备用资源) 高(需要一致性机制) 中低
资源利用率 50%(备节点闲置) 80%-100% N/(N+1),通常 >80%
故障切换时间 5-30 秒 <1 秒(摘除节点) 5-30 秒
数据一致性保证 好(单写入点) 复杂(需要冲突解决) 好(单写入点切换)
脑裂风险 有(需要 Fencing) 有(需要分区容错) 低(N 选 1 切换)
写入性能影响 无(仅主节点写入) 无(多节点写入)
读性能 可扩展(备节点分担读) 天然扩展 可扩展
运维复杂度
适用数据类型 强一致性数据(交易) 最终一致性数据(会话) 无状态或弱状态服务
典型技术实现 MySQL 主从 + Keepalived Redis Cluster、CockroachDB K8s ReplicaSet、Nginx upstream
推荐场景 数据库、核心交易 缓存、无状态服务、CDN 应用服务器集群

选型决策流

选择高可用模式时,可以按以下顺序考虑:

第一步:确定可用性目标。 不同的可用性等级对应不同的架构投入。如果目标是 3 个 9(每年 8.77 小时停机),简单的 Active-Passive 加人工切换可能就够了。如果目标是 4 个 9 以上,必须自动化故障检测和切换。

第二步:确定数据一致性要求。 金融交易要求 RPO = 0,必须使用同步复制,Active-Active 实现难度大。用户会话或缓存允许最终一致性,Active-Active 更合适。

第三步:评估资源和成本约束。 Active-Passive 运维简单但资源利用率低;Active-Active 利用率高但人力成本高;N+1 在两者之间提供较好平衡。

第四步:考虑技术栈和团队能力。 熟悉 Pacemaker/Keepalived 的团队适合 Active-Passive;已使用 Kubernetes 的团队可以自然采用 N+1 模式。

混合模式

实际生产环境中,很少有系统只使用一种高可用模式。更常见的做法是按组件特性选择不同的模式:

# Kubernetes Deployment 示例:应用层 N+M 冗余
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-api
  namespace: production
spec:
  replicas: 6                          # N=5(承载峰值)+ M=1(冗余)
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
  selector:
    matchLabels:
      app: payment-api
  template:
    spec:
      affinity:
        podAntiAffinity:              # 反亲和:副本分散到不同节点
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchExpressions:
                    - key: app
                      operator: In
                      values: ["payment-api"]
                topologyKey: "kubernetes.io/hostname"
      containers:
        - name: payment-api
          image: registry.example.com/payment-api:v2.3.1
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            periodSeconds: 10
            failureThreshold: 5

配合 PodDisruptionBudget(minAvailable: 4)可以确保在节点维护或滚动更新期间,始终有足够的副本提供服务。


十、总结

高可用不是一个开关。它是一组设计模式的组合,每个模式解决一个特定问题:

冗余解决”故障后谁来接管”的问题。 Active-Passive 简单可靠,适合需要强一致性的场景;Active-Active 资源利用率高,适合允许最终一致性的场景;N+M 提供了灵活的成本和可用性平衡。

故障检测解决”什么时候切换”的问题。 心跳检测是基础,但仅靠心跳无法区分节点故障和网络分区。Quorum 投票和 Witness 节点提供了更可靠的故障判定机制。

Fencing 解决”切换时如何避免脑裂”的问题。 STONITH 通过物理隔离确保旧主不再运行;Quorum 通过少数派自杀确保只有一组节点提供服务。两者通常配合使用。

数据复制模式解决”切换后数据是否完整”的问题。 同步复制保证零数据丢失但增加延迟;异步复制性能好但有丢数据风险;半同步复制是常用的折中方案。

网络切换解决”客户端如何找到新主”的问题。 VIP 漂移速度快但限于同一子网;DNS 切换支持跨数据中心但受 TTL 影响。

回到本文开头的两个系统:它们的差别不在于是否做了冗余部署,而在于是否完整地考虑了从故障检测到仲裁到 Fencing 到数据一致性到网络切换的整条链路。高可用设计的难点从来不是”多部署一个节点”,而是”在故障发生的那几秒内,系统的每一个组件都知道自己应该做什么”。

下一篇将讨论弹性设计模式(Resilience Patterns)。高可用解决的是”节点挂了怎么办”,弹性设计解决的是”节点还没挂但已经扛不住了怎么办”:熔断、限流、降级、重试、舱壁隔离——这些模式如何在过载和部分故障情况下保护系统。


上一篇:【系统架构设计百科】弹性伸缩:自动扩缩容的策略与实现

下一篇:【系统架构设计百科】弹性设计模式:熔断、限流、降级与舱壁隔离


参考资料

书籍

论文与标准

博客与在线资源

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】容灾架构:多活与灾备设计

同城双活、异地多活、两地三中心——名词背后是完全不同的 RPO/RTO 和成本曲线。本文从容灾基础概念出发,拆解数据同步的五种拓扑、流量调度与 DNS 切换的工程细节,深入分析蚂蚁金服 LDC(逻辑数据中心)的多活架构,最后给出不同容灾等级的成本对比与选型建议。

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。

2026-04-13 · architecture

【系统架构设计百科】复杂性管理:架构的核心战场

系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略


By .