yuqi-zheng

解析 B3 交易所 Binary UMDF 行情:从 pcap 到订单簿


当你接入 B3 交易所(Brasil Bolsa Balcão)的实时行情时,数据以 Binary UMDF 格式通过 UDP 组播送达——这是一种基于 Simple Binary Encoding (SBE) 的二进制协议。没有 JSON,没有 FIX 标签,没有分隔符。只有线上的字节流,按照 XML schema 定义的结构排列。

本文完整讲解解码流水线:从抓取 pcap 文件到构建实时 MBO(Market By Order)订单簿。每个协议层都有解释,每个结构体映射都有展示,每个陷阱都有提醒。


协议栈

B3 Binary UMDF 数据包是分层结构。每个 UDP 载荷包含一个包头部,后面跟着一个或多个成帧消息。每个消息又包含 SBE 头部消息体,消息体的布局由 template_id 决定。

┌──────────────────────────────────────────┐
│              UDP 载荷                      │
│  ┌────────────────────────────────────┐  │
│  │        Packet Header (12B)         │  │
│  ├────────────────────────────────────┤  │
│  │        Framing Header (4B)         │  │
│  ├────────────────────────────────────┤  │
│  │        SBE Message Header (8B)     │  │
│  ├────────────────────────────────────┤  │
│  │        Message Body (变长)          │  │
│  ├────────────────────────────────────┤  │
│  │        Repeating Groups (变长)      │  │
│  ├────────────────────────────────────┤  │
│  │        Variable-Length Data         │  │
│  └────────────────────────────────────┘  │
└──────────────────────────────────────────┘

一个 UDP 包可以包含多个成帧消息。接收端循环遍历载荷,读取成帧头部并根据 template_id 分发,直到载荷耗尽。


第一层:Packet Header

每个 Binary UMDF 包以 12 字节的包头开始:

struct packet_header {
    uint8_t  channel_id;       // 通道标识符(如 84、90)
    uint8_t  reserved;         // 始终为 0
    uint16_t sequence_version; // 通道重置时递增
    uint32_t sequence_number;  // 每个通道单调递增
    uint64_t sending_time;     // 纳秒级时间戳(PTP 同步)
};

关键字段:

  • channel_id:B3 为不同的合约组分配不同通道。行情处理器必须按通道 ID 过滤,避免处理无关数据。
  • sequence_number:用于丢包检测。如果 sequence_number 跳跃,说明丢包,需要发起恢复。
  • sending_time:这是 T11 时间戳——撮合引擎将包发布到 UDP 组播之前的瞬间,通过 PTP 同步到 B3 的三级原子钟,精度在亚微秒级。

sending_time 对延迟测量至关重要。自 2024 年 10 月起,其分辨率为纳秒,通过 PTP 同步到 B3 的三级原子主钟,偏差标准差在一微秒以内。


第二层:Framing Header

包头之后,每条消息被 4 字节的成帧头部包裹:

struct framing_header {
    uint16_t message_length;  // 总长度(含本头部)
    uint16_t encoding_type;   // SBE 必须为 0xEB50
};

encoding_type 是一个完整性校验。如果不是 0xEB50,说明该消息不是 SBE 编码的,应跳过。这可以防止损坏的包或协议版本不匹配。

message_length 告诉你在下一个成帧头部之前需要消耗多少字节——这是在同一包内遍历多条消息的方式。


第三层:SBE 消息头

每个消息体以 8 字节的 SBE 头开始:

struct sbe_message_header {
    uint16_t block_length;   // 消息体固定部分的长度
    uint16_t template_id;    // 消息类型标识符
    uint16_t schema_id;      // Schema 标识符(给定版本内为常量)
    uint16_t schema_version; // Schema 版本号
};

template_id 是分发键,告诉你在后续字节上叠加哪个结构体。以下是订单簿构建最重要的模板 ID:

Template ID名称用途
2Sequence下一个期望的序列号
3SecurityStatus单个合约的交易状态
4SecurityDefinition(已废弃)合约定义(v1.6)
9EmptyBook清空某合约的整个订单簿
10SecurityGroupPhase合约组的交易阶段
11ChannelReset重置通道的所有状态
12SecurityDefinition合约定义(v1.8+)
30SnapshotFullRefresh_Header快照头部及订单簿状态
50Order_MBO新增或修改订单簿中的订单
51DeleteOrder_MBO删除订单簿中的单个订单
52MassDeleteOrder_MBO从某位置删除订单(DELETE_THRU/FROM)
53Trade成交回报
54ForwardTrade远期成交回报
55ExecutionSummary主动方成交摘要
56ExecutionStatisticsVWAP、成交量统计
71SnapshotFullRefresh_Orders_MBO完整订单簿快照(MBO)

block_length 告诉你固定部分的字节数。读完 block_length 字节后,可能遇到重复组或变长数据(由 XML schema 描述)。


三阶段数据恢复

B3 行情通过三个独立的 UDP 流发送,必须按顺序处理:

阶段 1:InstrumentDefinition  →  获取所有合约元数据
阶段 2:SnapshotRecovery      →  获取完整订单簿状态
阶段 3:Incremental           →  应用实时增量更新

阶段 1:InstrumentDefinition

这个流发送 SecurityDefinition 消息(v1.8+ 中为 template 12),描述所有可交易合约:

struct SecurityDefinition_12 {
    SecurityID securityID;           // 唯一合约标识符
    SecurityExchange securityExchange; // "BVMF"
    Symbol symbol;                   // 如 "PETR4"
    PriceOptional minPriceIncrement; // 最小变动价位(原始 int64)
    Fixed8 contractMultiplier;       // 合约乘数
    uint8_t tickSizeDenominator;     // 价格转换的指数
    Currency currency;               // "BRL"
    // ... 还有 30+ 个字段
};

代码将这些收集到一个 map 中:

std::map<uint64_t, PH_SecurityDefinition_12*> map_ph_securityDefinition;

totNoRelatedSym 字段告诉你期望收到多少个合约。行情处理器会等到所有定义都到达后才进入阶段 2。

阶段 2:SnapshotRecovery

这个流通过 SnapshotFullRefresh_Header_30 及其重复组 noMDEntries(template 71)提供完整订单簿状态:

struct SnapshotFullRefresh_Header_30 {
    uint64_t securityID;
    uint32_t lastMsgSeqNumProcessed;  // 丢包检测的关键字段
    uint32_t totNumReports;
    uint32_t totNumBids;
    uint32_t totNumOffers;
    uint16_t totNumStats;
    uint32_t lastRptSeq;
};

struct noMDEntries {
    PriceOptional mDEntryPx;
    int64_t mDEntrySize;
    uint32_t mDEntryPositionNo;     // 在订单簿中的位置
    uint32_t enteringFirm;          // 经纪商 ID
    UTCTimestampNanos mDInsertTimestamp;
    uint64_t secondaryOrderID;      // 订单 ID
    char mDEntryType;               // '0' = 买, '1' = 卖
};

lastMsgSeqNumProcessed 字段至关重要——它告诉你快照中反映了增量流的最后一个序列号。切换到增量流时,必须跳过所有 sequence_number <= lastMsgSeqNumProcessed 的消息。

阶段 3:Incremental

这是实时流。消息高速到达:

  • Order_MBO(template 50):新增或修改订单
  • DeleteOrder_MBO(template 51):删除单个订单
  • MassDeleteOrder_MBO(template 52):删除一个范围的订单
  • Trade(template 53):成交回报
  • ExecutionSummary(template 55):主动方信息

行情处理器必须验证 rptSeq 的连续性来检测丢包:

if (p_securityStatus_3->rptSeq - map_securityID_rptSeq[p_securityStatus_3->securityID] != 1) {
    printf("丢包!securityID[%ld] rptSeq[%d]\n", ...);
}

SBE 编码:零拷贝解码

Simple Binary Encoding (SBE) 的设计目标是零拷贝解码。与 FIX 或 JSON 不同,你不需要解析——只需在原始字节上叠加结构体:

// 零拷贝:直接转换指针
p_order_MBO_50 = (Order_MBO_50*)malloc(sizeof(Order_MBO_50));
memcpy(p_order_MBO_50, (uint8_t*)pkt_data + offset, p_sbe_hd->block_length);

SBE 头中的 block_length 告诉你精确拷贝多少字节。没有字符串解析,没有标签-值查找,没有变长分隔符扫描。这就是为什么 SBE 是 CME、Eurex、B3 等低延迟交易所的首选编码。

大端字节序

SBE 对所有多字节字段使用大端序(网络字节序)。在 x86(小端序)上,必须交换字节:

static inline uint16_t read_uint16(uint8_t* buf) {
    return (buf[0] << 8) + buf[1];
}

static inline uint32_t read_uint32(uint8_t* buf) {
    return (buf[0] << 24) + (buf[1] << 16) + (buf[2] << 8) + buf[3];
}

但对于固定大小的 SBE 消息体,如果结构体声明使用了 #pragma pack(1),可以直接 memcpy——字节交换在字段访问时进行,而不是在拷贝时。在生产代码中,应使用 ntohs()/ntohl() 或编译器内建函数以获得更好性能。

重复组

固定长度消息体之后,SBE 使用重复组处理变长数据。每个组以 GroupSizeEncoding 开头:

struct GroupSizeEncoding {
    uint16_t blockLength;   // 每个组条目的大小
    uint8_t  numInGroup;    // 条目数量
};

例如,SecurityDefinition_12 有三个重复组:noUnderlyings_12noLegs_12noInstrAttribs_12。解码器读取 GroupSizeEncoding,然后循环 numInGroup 次,每次读取 blockLength 字节。

变长数据

重复组之后可能有变长字段(如 SecurityDefinition 中的 securityDesc)。这些以长度前缀字节开头:

uint8_t securityDesc_len = *(uint8_t*)(pkt_data + offset);
offset += 1;
if (securityDesc_len > 0) {
    char securityDesc[256];
    memcpy(securityDesc, (uint8_t*)pkt_data + offset, securityDesc_len);
    offset += securityDesc_len;
}

价格精度:指数模型

B3 将价格编码为原始 int64 值。实际价格通过除以一个取决于合约的指数来计算:

#define EXPONENT_4  10000       // 4 位小数
#define EXPONENT_7  10000000    // 7 位小数
#define EXPONENT_8  100000000   // 8 位小数

int64_t data_exponent(int64_t int_data, uint32_t exponent) {
    if (int_data == 0x8000000000000000) return 0;  // 空值
    return int_data / exponent;
}

给定合约的指数由 SecurityDefinition_12 中的 tickSizeDenominator 字段决定。例如:

  • 股票期权:EXPONENT_4(价格 = BRL × 10000)
  • 迷你指数期货:EXPONENT_8(价格 = 点数 × 100000000)

空值约定

SBE 使用哨兵值表示空/可选字段:

const int64_t null_value_int64 = 0x8000000000000000;  // INT64_MIN

任何具有此值的字段应视为”不存在”。data_exponent() 函数对空值返回 0。


MBO 订单簿构建

B3 使用 Market By Order (MBO) 模式,订单簿中的每个订单通过 secondaryOrderIDmDEntryPositionNo 单独标识。这与许多亚洲交易所使用的 Market By Price (MBP) 模式有本质区别。

B3 与价格优先交易所的区别

一个关键区别:B3 按时间优先组织订单簿,而不是价格优先。 这意味着:

  • B3:同一价格的订单按进入时间排序(FIFO)
  • 中国交易所(如上交所、深交所):同一价格的订单按价格-时间优先排列,最优价格被聚合

这对 mDEntryPositionNo 有影响——它代表在时间排序簿中的绝对位置,而不是价格档位。

五种订单簿操作

sfr_order_book 类实现了对应于 MDUpdateAction 的五种操作:

1. NEW (0) — 插入订单

void insert_entries(char entryType, int64_t entryPx, int64_t entrySize,
                    uint32_t entryPositionNo) {
    price_volume* pv = (price_volume*)malloc(sizeof(price_volume));
    pv->PositionNo = entryPositionNo;
    pv->Price = entryPx;
    pv->Volume = entrySize;

    // 在指定位置插入
    auto iter = list_bid_orders.begin();
    std::advance(iter, entryPositionNo - 1);
    list_bid_orders.insert(iter, pv);

    // 递增所有后续订单的位置号
    for (iter; iter != list_bid_orders.end(); iter++) {
        (*iter)->PositionNo += 1;
    }
}

在位置 N 插入新订单时,所有位置 ≥ N 的订单的 PositionNo 必须加 1。这是与 MBP 的关键区别——在列表中间插入会移动后面所有元素。

2. CHANGE (1) — 修改订单

void change_entries(char entryType, int64_t entryPx, int64_t entrySize,
                    uint32_t entryPositionNo) {
    // 找到该位置的订单并更新价格/数量
    for (auto iter = list_bid_orders.begin(); iter != list_bid_orders.end(); iter++) {
        if ((*iter)->PositionNo == entryPositionNo) {
            (*iter)->Price = entryPx;
            (*iter)->Volume = entrySize;
            return;
        }
    }
}

修改不会改变位置号。但如果修改改变了订单优先级(如价格改善),B3 会发送 DELETE + NEW 对而不是 CHANGE。

3. DELETE (2) — 删除单个订单

void delete_entries(char entryType, uint32_t entryPositionNo) {
    // 删除该位置的订单
    auto iter = list_bid_orders.begin();
    std::advance(iter, entryPositionNo - 1);
    list_bid_orders.erase(iter);

    // 递减所有后续订单的位置号
    for (auto iter = list_bid_orders.begin(); iter != list_bid_orders.end(); iter++) {
        if ((*iter)->PositionNo > entryPositionNo) {
            (*iter)->PositionNo -= 1;
        }
    }
}

插入的镜像操作:删除位置 N 后,所有后续位置向下移动 1。

4. DELETE_THRU (3) — 清空一侧的所有订单

void delete_thru_entries(char entryType) {
    // 清空整个买方或卖方
    for (auto iter = list_bid_orders.begin(); iter != list_bid_orders.end(); iter++) {
        free(*iter);
    }
    list_bid_orders.clear();
}

通常在通道重置或交易阶段切换时发送。

5. DELETE_FROM (4) — 从某位置删除到末尾

MassDeleteOrder_MBO_52 消息的 mDUpdateAction=4 表示删除从给定位置到订单簿末尾的所有订单。当合约进入某个使挂单失效的状态时使用。

快照初始化

处理 SnapshotFullRefresh_Orders_MBO_71 时,订单簿从 noMDEntries 重复组初始化:

void init_entries(noMDEntries* md) {
    price_volume* pv = (price_volume*)malloc(sizeof(price_volume));
    pv->PositionNo = md->mDEntryPositionNo;
    pv->Price = md->mDEntryPx;
    pv->Volume = md->mDEntrySize;

    if (md->mDEntryType == '0') {
        list_bid_orders.emplace_back(pv);
    } else if (md->mDEntryType == '1') {
        list_ask_orders.emplace_back(pv);
    }
}

注意:'0' = 买,'1' = 卖。这些是 FIX tag 269 的值。


时间戳:PUMA 时间模型

B3 的 PUMA 交易系统提供多个时间戳字段,每个在交易生命周期的不同时刻捕获。理解这些对延迟分析至关重要。

时间戳测量点

测量点字段捕获者描述
T1InboundBusinessHeader.sendingTime客户端客户端发送订单的时刻
T2receivedTime网关网关从 socket 收到订单的时刻
T4marketSegmentReceivedTime撮合引擎撮合引擎从内部总线收到的时刻
T5transactTime / mDInsertTimestamp撮合引擎事务发生的时刻
T6OutboundBusinessHeader.sendingTime网关网关将响应排入客户端 socket 的时刻
T10mDEntryTimestamp撮合引擎组装行情消息时(同包内所有消息相同)
T11packetHeader.sendingTime撮合引擎即将发布到 UDP 组播之前的时刻

关键观察

  1. T10 vs T11mDEntryTimestamp(T10)在撮合引擎组装行情消息时赋值,packetHeader.sendingTime(T11)在包即将发送到网络之前捕获。差值 T11 - T10 衡量了内部发布延迟。

  2. T5 同时出现在私有和公有流中:私有 ExecutionReport 消息中的 transactTime 和公有 Order_MBO 消息中的 mDInsertTimestamp 都携带 T5——撮合引擎处理事务的确切时刻。

  3. aggressorTimeExecutionSummary 消息(template 55)包含 aggressorTime,等于主动方订单的 T4(撮合引擎收到主动订单的时刻)。这对于测量从下单到成交发布的往返延迟非常有价值。

  4. PTP 同步:所有时间戳通过 PTP 同步到 B3 的三级原子钟,精度在亚微秒级,偏差标准差在一微秒以内。


pcap 处理流水线

示例代码分三个阶段处理离线 pcap 文件:

void binary_manager::run() {
    // 阶段 1:解析 InstrumentDefinition pcap
    pcap_t* fp_InstrumentDefinition = pcap_open_offline("MBO_090_InstrumentDefinition.pcap", errbuf);
    pcap_loop(fp_InstrumentDefinition, 0, dispatcher_handler, (uint8_t*)this);
    pcap_close(fp_InstrumentDefinition);

    // 等待所有定义到达
    while (!is_InstrumentDefinition_ready) { sleep(1); }

    // 阶段 2:解析 SnapshotRecovery pcap
    pcap_t* fp_SnapshotRecovery = pcap_open_offline("MBO_090_SnapshotRecovery.pcap", errbuf);
    pcap_loop(fp_SnapshotRecovery, 0, dispatcher_handler, (uint8_t*)this);
    pcap_close(fp_SnapshotRecovery);

    // 等待快照完成
    while (!is_snapshotFullRefreshHeader_ready) { sleep(1); }

    // 阶段 3:解析 Incremental pcap
    pcap_t* fp_Incremental = pcap_open_offline("MBO_090_Incremental.pcap", errbuf);
    pcap_loop(fp_Incremental, 0, dispatcher_handler, (uint8_t*)this);
    pcap_close(fp_Incremental);
}

网络层解析

调度回调逐层剥除网络头部:

auto dispatcher_handler = [](uint8_t* temp, const struct pcap_pkthdr* header,
                             const uint8_t* pkt_data) -> void {
    binary_manager* pm = (binary_manager*)temp;

    IPv4Header ipv4(pkt_data);
    if (ipv4.Protocol == 0x11) {  // UDP
        UDPHeader udp(pkt_data, ipv4.IPv4Header_len);
        pm->payload = udp.UDP_HeaderLen - 8;  // 减去 UDP 头
        pm->pkg_len = header->len;
        pm->parse_package(pkt_data, udp.UDPHeader_len);
    }
};

IPv4HeaderUDPHeader 类处理 Ethernet → IP → UDP 解析,提取 Binary UMDF 数据开始的载荷偏移。

通道 ID 过滤

parse_package 方法立即检查通道 ID:

if (p_pkt_hd->channel_id != m_channel_id) {
    printf("error channel[%d], ignore\n", p_pkt_hd->channel_id);
    return;
}

这很关键,因为 B3 为不同合约组分配不同通道。在单线程上处理所有通道会混淆不相关的订单簿。


输出格式

示例程序产生三种输出:

1. SBE 消息 CSV

每个解析后的 SBE 消息写入模板特定的 CSV 文件。例如 ph_order_MBO_50.csv

channel_id,sequence_version,sequence_number,securityID,matchEventIndicator,
mDUpdateAction,mDEntryType,padding,mDEntryPx,mDEntrySize,mDEntryPositionNo,
enteringFirm,mDInsertTimestamp,secondaryOrderID,rptSeq,mDEntryTimestamp

2. Tick 级行情数据

合并的 tick 文件,包含 10 档行情:

updateTime,updateNano,securityID,Symbol,lastPrice,tradeVolume,
bid_px1,bid_sz1,...,bid_px10,bid_sz10,
ask_px1,ask_sz1,...,ask_px10,ask_sz10

3. 订单簿快照

每个合约的完整 MBO 订单簿:

updateTime,updateNano,secondaryOrderID,enteringFirm,securityID,Symbol,
lastMsgSeqNumProcessed,totNumBids,totNumOffers,lastPrice,tradeVolume,
highLimitPrice,lowLimitPrice

常见陷阱

1. SBE 版本不匹配

B3 在协议版本 1.6 和 1.8 之间从 SecurityDefinition_4(template 4)升级到 SecurityDefinition_12(template 12)。解码器必须同时处理两者。SBE 头中的 schema_version 字段告诉你应期望哪个版本。readme 中也提到:“2024 年 2 月之前的 SBE 格式暂不支持。“

2. 位置号维护

mDEntryPositionNo 不是简单的数组索引。在位置 N 插入会将所有位置 ≥ N 的订单上移 1。删除位置 N 会将所有位置 > N 的订单下移 1。如果把它当作简单数组索引处理,订单簿会静默损坏。

3. 序列号间隙

检测到间隙(sequence_number 跳跃)后,不能简单地继续处理。必须:

  1. 记录间隙
  2. 请求快照恢复
  3. 从快照重建订单簿
  4. lastMsgSeqNumProcessed + 1 恢复增量处理

4. 大端序混淆

#pragma pack(1) 结构体与 memcpy 配合用于消息体,但包头部成帧头部中的多字节字段是大端序。代码对这些使用 read_uint16()/read_uint32(),但对 SBE 消息体使用原始 memcpy。搞混这些是数据乱码的常见原因。

5. 空值处理

哨兵值 0x8000000000000000 是一个有效的 int64 位模式,代表”不存在”。如果在转换价格前忘记检查空值,会得到垃圾数字。

6. DELETE_THRU vs DELETE_FROM

MassDeleteOrder_MBO_52mDUpdateAction=3(DELETE_THRU)清空一侧的所有订单。mDUpdateAction=4(DELETE_FROM)清空从某位置到末尾的订单。混淆这两者会清除订单簿的错误部分。


SBE 与其他金融编码格式对比

特性SBEFIX/FASTProtobufJSON
解码零拷贝(叠加结构体)基于 token需要解析文本解析
延迟~100 ns~1 μs~5 μs~50 μs
SchemaXML → 代码生成XML 字典.proto
字段访问直接偏移标签查找字段编号键查找
线上大小紧凑(无标签)紧凑(增量编码)Varint 编码冗长
使用者CME、Eurex、B3CME(旧版)、Euronext内部系统Web API

SBE 的零拷贝特性是其对交易系统的杀手锏。XML schema(b3-market-data-messages-1.8.0.xml)可以与 real-logic SBE 工具配合自动生成 C++、Java 或 C# 编解码器。


快速参考:完整解码循环

void binary_manager::parse_package(const uint8_t* pkt_data, uint32_t offset) {
    // 1. 读取包头
    memcpy(p_pkt_hd, pkt_data + offset, sizeof(packet_header));
    offset += sizeof(packet_header);

    // 2. 按通道过滤
    if (p_pkt_hd->channel_id != m_channel_id) return;

    // 3. 循环处理包内所有消息
    while (offset < pkg_len) {
        // 3a. 读取成帧头部
        memcpy(p_frm_hd, pkt_data + offset, sizeof(framing_header));
        offset += sizeof(framing_header);

        // 3b. 读取 SBE 头
        memcpy(p_sbe_hd, pkt_data + offset, sizeof(sbe_message_header));
        offset += sizeof(sbe_message_header);

        // 3c. 验证编码类型
        if (0xEB50 != p_frm_hd->encoding_type) return;

        // 3d. 按 template_id 分发
        switch (p_sbe_hd->template_id) {
            case 50:  // Order_MBO
                memcpy(p_order_MBO_50, pkt_data + offset, p_sbe_hd->block_length);
                // 处理插入/修改...
                break;
            case 51:  // DeleteOrder_MBO
                memcpy(p_deleteOrder_MBO_51, pkt_data + offset, p_sbe_hd->block_length);
                // 处理删除...
                break;
            // ... 其他模板
        }
        offset += p_sbe_hd->block_length;
        // ... 处理重复组和变长数据
    }
}

总结

解析 B3 Binary UMDF 行情是一个系统化的过程:

  1. 捕获 UDP 组播包(pcap 或实时流)
  2. 解析 网络头部(Ethernet → IPv4 → UDP)
  3. 解码 Binary UMDF 包头(通道 ID、序列号、T11 时间戳)
  4. 迭代 使用成帧头部遍历消息
  5. 分发 按 SBE template_id 到正确的结构体
  6. 转换 使用指数模型和空值检查转换价格
  7. 构建 MBO 订单簿,使用五种操作(NEW/CHANGE/DELETE/DELETE_THRU/DELETE_FROM)
  8. 维护 正确的 mDEntryPositionNo(插入上移,删除下移)
  9. 检测 通过 sequence_numberrptSeq 连续性检测丢包
  10. 恢复 使用三阶段协议从丢包中恢复(InstrumentDefinition → SnapshotRecovery → Incremental)

SBE 编码使第 5 步几乎免费——你在原始字节上叠加 C 结构体,无需解析开销。真正的难点在于正确实现订单簿逻辑,特别是 PositionNo 维护和三阶段恢复序列。