博客 / 詳情

返回

Paxos 協議三階段解密:原理剖析與 Java 實現

你是否遇到過這樣的情況:公司的微服務集羣中,多個節點需要選出主節點,但因為網絡故障卻導致兩個節點同時認為自己是"主"?或者在容器編排系統中,因為通信延遲導致不同節點看到的系統狀態不一致,引發了一連串莫名其妙的錯誤?在分佈式系統中,這些場景時有發生,而它們本質上都指向一個核心問題:如何在不可靠的網絡環境中,讓多個獨立節點對某個決策達成一致?

這個看似簡單的問題卻難倒了無數系統設計師。幸運的是,Leslie Lamport 提出的 Paxos 協議為我們提供了一個優雅的數學解決方案。今天,我們就像拆解一台精密鐘錶那樣,一步步剖析 Paxos 協議的三個關鍵階段,理解它的數學原理,並通過 Java 代碼將其具體化。

Paxos 協議:基礎理論

Paxos 協議是解決分佈式一致性問題的基礎算法,它能確保在一個由多個節點組成的系統中,即使部分節點出現故障或網絡不穩定,系統仍能對某個提議達成一致。

在 Paxos 中,有三種角色參與決策過程:

  • 提議者(Proposer):提出決策提案,包含提案編號和提議值
  • 接受者(Acceptor):對提案進行投票,決定接受或拒絕
  • 學習者(Learner):學習最終被選定的提案值

從數學上看,Paxos 保證了以下關鍵特性:

  • 安全性:最多隻有一個值能被選定
  • 一致性:一旦值被選定,學習者最終都能學習到這個值
  • 活性:如果大多數節點正常運行且網絡最終恢復,系統總能完成決策

Paxos 協議分為三個主要階段:

  1. Prepare 階段(準備階段)
  2. Accept 階段(接受階段)
  3. Learn 階段(學習階段)

下面我們逐一深入探討每個階段的工作原理。

1. Prepare 階段:建立決策基礎

核心原理

Prepare 階段的目標是讓提議者取得"發言權"並瞭解系統的歷史狀態。具體來説:

  1. 提議者生成一個提案編號 n,向多數接受者發送 Prepare 請求
  2. 接受者檢查收到的提案編號 n:
  • 如果 n 大於它之前承諾過的任何編號,則:

    • 承諾不再接受編號小於 n 的任何提案
    • 返回它已接受的編號最大的提案信息(若有)
  • 否則拒絕此請求

這個機制確保了即使有多個提議者同時活動,系統也能夠"收斂"到唯一的決策值。數學上,它利用了"多數派交集"原理:任意兩組超過半數的接受者必然至少有一個共同成員,這保證了不同提議者之間能夠感知彼此的存在。

圖解説明

下圖展示了一個典型的 Prepare 階段流程:

sequenceDiagram
    participant P as 提議者
    participant A1 as 接受者1
    participant A2 as 接受者2
    participant A3 as 接受者3

    P->>A1: Prepare(n=5)
    P->>A2: Prepare(n=5)
    P->>A3: Prepare(n=5)

    A1-->>P: Promise(n=5, 已接受:n=3,value="數據庫")
    A2-->>P: Promise(n=5, 無已接受值)
    A3-->>P: Promise(n=5, 無已接受值)

    Note over P,A3: 提議者收到多數Promise必須採納n=3的值"數據庫"

在這個例子中,提議者可能原本想提議"文件系統"作為存儲方式,但因為收到接受者 1 返回的歷史值"數據庫"(來自編號 n=3 的舊提案),必須放棄自己的初始想法,轉而在 Accept 階段提議"數據庫"。這是 Paxos 保證安全性的關鍵機制。

Java 實現

下面是 Prepare 階段的 Java 代碼實現:

public class PaxosProposer<V> {
    private AtomicInteger proposalNumber;
    private Set<PaxosAcceptor<V>> acceptors;
    private V proposalValue;

    /**
     * Prepare階段實現
     * 向多數接受者發送Prepare請求並處理響應
     */
    public boolean prepare() {
        // 遞增提案編號,確保全局單調遞增
        int newProposalNumber = proposalNumber.incrementAndGet();
        System.out.println("提議者發起Prepare,提案編號=" + newProposalNumber);

        int acceptCount = 0;
        V highestAcceptedValue = null;
        int highestAcceptedProposalNumber = 0; // 跟蹤已接受的最大提案編號

        for (PaxosAcceptor<V> acceptor : acceptors) {
            try {
                Promise<V> promise = acceptor.prepare(newProposalNumber);
                if (promise != null && promise.isPromised()) {
                    acceptCount++;

                    // 如果接受者已經接受過值,且該值的提案編號最大
                    // 則記錄此值和編號
                    if (promise.getAcceptedValue() != null &&
                        promise.getAcceptedProposalNumber() > highestAcceptedProposalNumber) {
                        highestAcceptedValue = promise.getAcceptedValue();
                        highestAcceptedProposalNumber = promise.getAcceptedProposalNumber();
                    }
                }
            } catch (Exception e) {
                // 處理通信失敗
                System.err.println("通信失敗: " + e.getMessage());
            }
        }

        // 成功條件:收到嚴格多於半數的承諾
        if (acceptCount > acceptors.size() / 2) {
            // 關鍵邏輯:如果有歷史接受值,必須採用它
            if (highestAcceptedValue != null) {
                proposalValue = highestAcceptedValue;
                System.out.println("採用歷史值: " + proposalValue);
            } else {
                System.out.println("使用原始值: " + proposalValue);
            }
            return true;
        }

        return false; // Prepare失敗
    }
}

public class PaxosAcceptor<V> {
    private String id;

    // 承諾過的最大提案編號(Prepare階段更新)
    private int highestPromisedNumber = 0;

    // 實際接受過的提案編號(Accept階段更新)
    private int acceptedProposalNumber = 0;

    // 接受過的提案值
    private V acceptedValue = null;

    /**
     * 處理Prepare請求
     */
    public synchronized Promise<V> prepare(int proposalNumber) {
        System.out.println(id + ": 收到Prepare, 編號=" + proposalNumber);

        // 核心決策邏輯:只承諾編號更大的提案
        if (proposalNumber > highestPromisedNumber) {
            highestPromisedNumber = proposalNumber;

            // 返回承諾和已接受的值(如果有)
            return new Promise<>(true, acceptedProposalNumber, acceptedValue);
        }

        // 拒絕編號較小的Prepare請求
        return new Promise<>(false, 0, null);
    }
}

簡單來説,Prepare 階段就像是提議者在詢問:"我想討論編號為 n 的提案,大家能否接受?如果有人已經接受過其他提案,請告訴我。"而接受者的回答決定了提議者下一步的行動。

2. Accept 階段:嘗試達成共識

核心原理

在 Accept 階段,如果提議者在 Prepare 階段獲得了多數接受者的承諾,它會發送 Accept 請求,要求接受者接受提議值。這裏有個關鍵點:提議者必須使用 Prepare 階段獲得的歷史值(如果有的話),否則使用自己的初始值。

接受者收到 Accept 請求後,如果提案編號不小於它承諾過的最大編號(即proposalNumber >= highestPromisedNumber),則接受該提案。這確保了接受者不會違背之前的承諾。

數學上,Accept 階段巧妙地實現了"只有一個值最終被選定"的保證:

  • 如果值 v 在某個提案中被多數派接受,那麼任何更高編號的提案,在 Prepare 階段都必然能發現 v
  • 因此,更高編號的提案只能提議相同的值 v,而不是新值

圖解説明

下圖展示了 Accept 階段的流程:

sequenceDiagram
    participant P as 提議者
    participant A1 as 接受者1
    participant A2 as 接受者2
    participant A3 as 接受者3

    Note over P: 基於Prepare結果提議值為"數據庫"

    P->>A1: Accept(n=5, value="數據庫")
    P->>A2: Accept(n=5, value="數據庫")
    P->>A3: Accept(n=5, value="數據庫")

    A1-->>P: Accepted(n=5)
    A2-->>P: Accepted(n=5)
    A3-xP: 拒絕(已承諾n=7)

    Note over P,A3: 多數接受者(1+2)接受提案值"數據庫"被選定

在這個例子中,即使接受者 3 因為已經承諾了更高編號的提案而拒絕,但由於多數接受者(1 和 2)已經接受了提案,因此"數據庫"被選定為最終決策。

Java 實現

下面是 Accept 階段的 Java 代碼實現:

public class PaxosProposer<V> {
    // 前面的代碼省略...

    /**
     * Accept階段實現
     * 向多數接受者發送Accept請求
     */
    public boolean accept() {
        int currentProposalNumber = proposalNumber.get();
        System.out.println("提議者發起Accept, 編號=" + currentProposalNumber + ", 值=" + proposalValue);

        int acceptCount = 0;

        for (PaxosAcceptor<V> acceptor : acceptors) {
            try {
                boolean accepted = acceptor.accept(currentProposalNumber, proposalValue);
                if (accepted) {
                    acceptCount++;
                }
            } catch (Exception e) {
                System.err.println("通信失敗: " + e.getMessage());
            }
        }

        // 成功條件:嚴格多於半數的接受者接受提案
        return acceptCount > acceptors.size() / 2;
    }
}

public class PaxosAcceptor<V> {
    // 前面的代碼省略...

    /**
     * 處理Accept請求
     */
    public synchronized boolean accept(int proposalNumber, V value) {
        System.out.println(id + ": 收到Accept, 編號=" + proposalNumber + ", 值=" + value);

        // 核心決策邏輯:如果提案編號不小於承諾的最大編號,則接受
        if (proposalNumber >= highestPromisedNumber) {
            highestPromisedNumber = proposalNumber;
            acceptedProposalNumber = proposalNumber;
            acceptedValue = value;

            // 在實際系統中應持久化這些狀態
            return true;
        }

        return false; // 拒絕Accept請求(提案編號小於承諾編號)
    }
}

Accept 階段就像是提議者對接受者説:"既然大家同意討論編號 n 的提案,那麼我提議值為 v,請大家投票。"而接受者則根據自己之前的承諾決定是否接受這個提議。

3. Learn 階段:傳播最終決策

核心原理

Learn 階段的目標是確保所有系統節點(學習者)都瞭解最終選定的值。這對於系統實際工作至關重要,因為只有知道決策結果,節點才能正確執行相應操作。

Learn 階段有兩種實現方式:

  1. 被動學習:提議者主動通知所有學習者選定的值
  2. 主動學習:學習者自己查詢多數接受者,獲取選定的值

第二種方式更為健壯,因為它不依賴於提議者的可靠性,即使提議者在通知前崩潰,學習者仍能獲知選定的值。

圖解説明

下圖展示了 Learn 階段的兩種學習方式:

sequenceDiagram
    participant P as 提議者
    participant A as 接受者集合
    participant L1 as 學習者1
    participant L2 as 學習者2

    Note over P: 提案"數據庫"已被選定

    rect rgb(200, 240, 200)
    Note over P,L2: 被動學習
    P->>L1: Learn(value="數據庫")
    P->>L2: Learn(value="數據庫")
    end

    rect rgb(240, 220, 200)
    Note over L1,L2: 主動學習(提議者故障時)
    L1->>A: 查詢當前值
    A-->>L1: 多數返回"數據庫"
    end

    Note over L1,L2: 所有學習者獲知最終決策

Java 實現

下面是 Learn 階段的 Java 代碼實現:

public class PaxosProposer<V> {
    // 前面的代碼省略...
    private Set<PaxosLearner<V>> learners;

    /**
     * Learn階段實現:通知所有學習者
     */
    public void informLearners() {
        System.out.println("提議者通知學習者, 選定值: " + proposalValue);

        for (PaxosLearner<V> learner : learners) {
            try {
                learner.learn(proposalValue);
            } catch (Exception e) {
                System.err.println("通知學習者失敗: " + e.getMessage());
            }
        }
    }

    /**
     * 運行完整的Paxos流程
     */
    public boolean runPaxos(V initialValue) {
        if (initialValue != null) {
            this.proposalValue = initialValue;
        }

        // 依次執行三個階段
        if (prepare() && accept()) {
            informLearners();
            return true;
        }
        return false;
    }
}

public class PaxosLearner<V> {
    private String id;
    private V learnedValue = null;
    private Set<PaxosAcceptor<V>> acceptors; // 用於主動學習

    /**
     * 被動學習:從提議者接收選定值
     */
    public synchronized void learn(V value) {
        this.learnedValue = value;
        System.out.println(id + ": 學習到值: " + value);
    }

    /**
     * 主動學習:查詢多數接受者
     */
    public synchronized V activeLearn() {
        System.out.println(id + ": 正在主動查詢當前系統狀態");
        Map<V, Integer> valueCount = new HashMap<>();

        // 查詢所有接受者
        for (PaxosAcceptor<V> acceptor : acceptors) {
            try {
                AcceptorState<V> state = acceptor.getState();
                if (state.getAcceptedValue() != null) {
                    valueCount.put(
                        state.getAcceptedValue(),
                        valueCount.getOrDefault(state.getAcceptedValue(), 0) + 1
                    );
                }
            } catch (Exception e) {
                // 部分接受者可能不可用,這是正常的
            }
        }

        // 檢查是否有值被多數接受者接受
        for (Map.Entry<V, Integer> entry : valueCount.entrySet()) {
            if (entry.getValue() > acceptors.size() / 2) {
                V majorityValue = entry.getKey();
                this.learnedValue = majorityValue;
                System.out.println(id + ": 主動學習到值: " + majorityValue);
                return majorityValue;
            }
        }

        return null; // 沒有值被多數接受者接受
    }
}

Learn 階段確保了系統中的所有組件最終都能瞭解到最終決策,無論它們是否參與了決策過程。

完整的 Java 實現與測試

下面提供一個完整的 Paxos 協議 Java 實現,包括異常處理、故障模擬和測試場景:

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Paxos協議完整實現示例
 */
public class PaxosDemo {
    public static void main(String[] args) {
        // 創建3個接受者和2個學習者
        Set<PaxosAcceptor<String>> acceptors = new HashSet<>();
        for (int i = 0; i < 3; i++) {
            acceptors.add(new PaxosAcceptor<>("接受者-" + i));
        }

        Set<PaxosLearner<String>> learners = new HashSet<>();
        for (int i = 0; i < 2; i++) {
            learners.add(new PaxosLearner<>("學習者-" + i, acceptors));
        }

        // 創建提議者並運行Paxos
        PaxosProposer<String> proposer = new PaxosProposer<>(acceptors, learners);
        boolean success = proposer.runPaxos("數據庫");

        System.out.println("Paxos協議執行" + (success ? "成功" : "失敗") +
                          ",最終選定值: " + proposer.getProposalValue());

        // 測試多提議者競爭
        testCompetingProposers(acceptors, learners);

        // 測試接受者故障恢復
        testAcceptorFailure(acceptors, learners);
    }

    /**
     * 測試多提議者競爭場景
     */
    private static void testCompetingProposers(
            Set<PaxosAcceptor<String>> acceptors,
            Set<PaxosLearner<String>> learners) {
        System.out.println("\n=== 多提議者競爭測試 ===");

        // 重置接受者狀態
        for (PaxosAcceptor<String> acceptor : acceptors) {
            acceptor.reset();
        }

        // 創建兩個提議者,分別提出不同的值
        PaxosProposer<String> proposerA = new PaxosProposer<>(acceptors, learners);
        PaxosProposer<String> proposerB = new PaxosProposer<>(acceptors, learners);

        // 設置不同的優先級,影響提案編號
        proposerA.setPriority(1); // 較低優先級
        proposerB.setPriority(2); // 較高優先級

        // 啓動兩個線程模擬併發提議
        Thread threadA = new Thread(() -> {
            boolean success = proposerA.runPaxos("文件系統");
            System.out.println("提議者A: " + (success ? "成功" : "失敗") +
                              ",最終值: " + proposerA.getProposalValue());
        });

        Thread threadB = new Thread(() -> {
            boolean success = proposerB.runPaxos("數據庫");
            System.out.println("提議者B: " + (success ? "成功" : "失敗") +
                              ",最終值: " + proposerB.getProposalValue());
        });

        // 啓動線程
        threadA.start();
        threadB.start();

        try {
            threadA.join();
            threadB.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 驗證所有學習者學到的是相同的值
        Set<String> learnedValues = new HashSet<>();
        for (PaxosLearner<String> learner : learners) {
            learnedValues.add(learner.getLearnedValue());
        }

        System.out.println("所有學習者學到的值" +
                          (learnedValues.size() == 1 ? "一致" : "不一致") +
                          ": " + learnedValues);
    }

    /**
     * 測試接受者故障場景
     */
    private static void testAcceptorFailure(
            Set<PaxosAcceptor<String>> acceptors,
            Set<PaxosLearner<String>> learners) {
        System.out.println("\n=== 接受者故障測試 ===");

        // 重置接受者狀態
        for (PaxosAcceptor<String> acceptor : acceptors) {
            acceptor.reset();
        }

        // 模擬一個接受者故障
        PaxosAcceptor<String> failingAcceptor = acceptors.iterator().next();
        failingAcceptor.simulateFailure();
        System.out.println(failingAcceptor.getId() + " 發生故障");

        // 創建提議者並運行Paxos
        PaxosProposer<String> proposer = new PaxosProposer<>(acceptors, learners);
        boolean success = proposer.runPaxos("內存緩存");

        System.out.println("故障存在時,Paxos執行" + (success ? "成功" : "失敗") +
                          ",選定值: " + proposer.getProposalValue());

        // 模擬接受者恢復
        failingAcceptor.simulateRecovery();
        System.out.println(failingAcceptor.getId() + " 已恢復");

        // 學習者主動學習
        for (PaxosLearner<String> learner : learners) {
            String activeLearnedValue = learner.activeLearn();
            System.out.println(learner.getId() + " 主動學習結果: " + activeLearnedValue);
        }
    }
}

關鍵實現類的完整代碼(省略上文已展示的核心方法):

class PaxosProposer<V> {
    private AtomicInteger proposalNumber;
    private final Set<PaxosAcceptor<V>> acceptors;
    private final Set<PaxosLearner<V>> learners;
    private V proposalValue;
    private int priority = 1;
    private static final int MAX_RETRIES = 3;

    // 構造器和其他方法...

    /**
     * 設置提議者優先級
     */
    public void setPriority(int priority) {
        this.priority = priority;
    }

    /**
     * 獲取當前提案值
     */
    public V getProposalValue() {
        return proposalValue;
    }

    /**
     * 運行Paxos協議,包含重試邏輯
     */
    public boolean runPaxos(V initialValue) {
        if (initialValue != null) {
            this.proposalValue = initialValue;
        }

        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
            if (prepare() && accept()) {
                informLearners();
                return true;
            }

            // 失敗後隨機退避,避免活鎖
            try {
                long backoffTime = (long) (Math.random() * 100 * (attempt + 1));
                System.out.println("提議者退避 " + backoffTime + "ms 後重試");
                Thread.sleep(backoffTime);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        return false; // 重試次數用盡仍失敗
    }
}

class PaxosAcceptor<V> {
    // 字段和核心方法...

    /**
     * 模擬故障
     */
    public void simulateFailure() {
        this.failure = true;
    }

    /**
     * 模擬恢復
     * 從持久存儲中加載狀態
     */
    public synchronized void simulateRecovery() {
        // 從持久存儲中恢復狀態
        this.failure = false;

        // 真實系統中,這裏應當從磁盤/數據庫加載最新狀態
        if (persistentStorage.containsKey("highestPromisedNumber")) {
            this.highestPromisedNumber = (Integer) persistentStorage.get("highestPromisedNumber");
        }

        if (persistentStorage.containsKey("acceptedValue")) {
            @SuppressWarnings("unchecked")
            V value = (V) persistentStorage.get("acceptedValue");
            this.acceptedValue = value;
        }
    }

    /**
     * 重置狀態(用於測試)
     */
    public synchronized void reset() {
        this.highestPromisedNumber = 0;
        this.acceptedProposalNumber = 0;
        this.acceptedValue = null;
        this.persistentStorage.clear();
        this.failure = false;
    }

    /**
     * 獲取接受者狀態(供學習者主動學習)
     */
    public synchronized AcceptorState<V> getState() {
        if (failure) {
            throw new RuntimeException("接受者不可用");
        }
        return new AcceptorState<>(acceptedProposalNumber, acceptedValue);
    }
}

Paxos 算法的挑戰與優化方向

在實際應用中,Paxos 會面臨一些挑戰:

1. 併發提議者導致的反覆重試

當多個提議者同時提出提議時,可能出現"互相阻塞"的情況:

flowchart TD
    A[提議者A: Prepare n=5] --> B{接受者羣}
    C[提議者B: Prepare n=6] --> B
    B --> D[接受者承諾n=6]
    D --> E[A的Accept被拒絕]
    E --> F[A提出更大編號n=7]
    F --> G[B的Accept被拒絕]
    G --> H[B提出更大編號n=8]
    H --> I[循環往復...]

    style I fill:#f99,stroke:#333

解決方法

  • 隨機退避:提議者失敗後等待隨機時間再重試
  • 選舉唯一協調者:在系統中選出主提議者,只有主提議者才能提出提議
  • 分區編號:不同提議者使用不同數字段(如奇偶分開)

2. 優化通信輪次

Multi-Paxos: 多數系統採用的優化方式

  • 選出固定的主提議者後,後續提案可跳過 Prepare 階段
  • 大大減少消息數量和通信輪次
  • 將多個值的決策打包處理

協議通信對比

3. 狀態持久化與恢復

在實際系統中,接受者必須將關鍵狀態持久化存儲:

  • highestPromisedNumber:保證不違背承諾
  • acceptedProposalNumberacceptedValue:恢復後能報告正確歷史
// 持久化關鍵狀態示例
public synchronized void persistState() {
    try (FileOutputStream fos = new FileOutputStream("acceptor_state.dat");
         ObjectOutputStream oos = new ObjectOutputStream(fos)) {
        PaxosState state = new PaxosState(
            highestPromisedNumber, acceptedProposalNumber, acceptedValue);
        oos.writeObject(state);
    } catch (IOException e) {
        throw new RuntimeException("持久化狀態失敗", e);
    }
}

從數學角度理解 Paxos 的正確性

Paxos 協議的安全性(一致性)基於以下數學性質:

正確性證明核心

定理 1: 如果值 v 在提案編號 n 被選定,那麼任何編號大於 n 的被接受的提案,其值必然是 v。

證明思路:

  1. 假設提案(n,v)被多數派 M1 接受
  2. 任何更高編號 m 的提案必須在 Prepare 階段獲得多數派 M2 的響應
  3. 由於兩個多數派必有交集,至少有一個接受者同時在 M1 和 M2 中
  4. 該接受者會在響應 m 的 Prepare 時返回(n,v)
  5. 如果 m 的提議者看到的最大已接受提案是(n,v),它必須提議值 v
  6. 因此更高編號的提案只能包含值 v,不能是其他值

這個數學性質確保了即使有多個提議者競爭,最終也只有一個值被選定。

實際應用示例

讓我們用一個具體例子説明 Paxos 的工作流程:

場景:三節點集羣需要決定使用哪種數據存儲方式

  1. 初始狀態:
  • 3 個接受者:A1, A2, A3
  • 2 個提議者:P1 想提議"Redis",P2 想提議"MySQL"
  1. 執行過程:
  • P1 發送 Prepare(n=5),獲得多數派承諾
  • P1 發送 Accept(n=5, v="Redis"),成功獲得多數派接受
  • 此時"Redis"被選定,學習者學習到這個值
  • 系統開始使用 Redis 作為存儲方式
  • 如果 P2 嘗試提議"MySQL",必須使用更大編號(如 n=10)
  • 但 P2 會在 Prepare 階段發現已有值"Redis"被選定
  • P2 必須放棄"MySQL",轉而提議"Redis"

這個例子展示了 Paxos 如何在分佈式環境中確保一致性決策。

總結

下表對比了 Paxos 協議三個階段的關鍵特性:

階段名稱 主要目的 核心操作 數學基礎 成功條件
Prepare 階段 獲取"發言權"並瞭解歷史 提議者發送提案編號;接受者承諾不接受更小編號 通過多數派交集保證值的連續性 多於半數接受者承諾
Accept 階段 嘗試讓多數接受者接受值 提議者發送(編號,值);接受者在未違背承諾情況下接受 多數派機制確保唯一值選定 多於半數接受者接受
Learn 階段 傳播最終決策 通知或查詢選定值 確保所有節點最終獲知決策 所有學習者瞭解決策
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.