博客 / 詳情

返回

分表路由:為什麼大神都用 & (n-1),而不用 % ?一次給你講透

寫在前面

"分庫分表"大家都不陌生。當數據量激增時,我們習慣性地寫下 userId % tableCount 來決定數據路由到哪張表。

這段代碼邏輯正確、簡單直觀。但在對性能要求極高的底層中間件開發中,這真的是最優解嗎?

如果我們翻開 JDK 1.8 的 HashMap 源碼,會發現大神 Doug Lea 在計算數組下標時,刻意避開了 % 取模,而是使用了一行看起來更晦澀的 & (n - 1)

今天,我們就把 HashMap 的這項底層“絕技”移植到業務系統的分表組件中,打造一個高性能、可擴展且具備防禦性的路由工具。


一、 從一次 Code Review 説起

最近在審查新版分表中間件的代碼時,看到了一段非常標準的分表路由邏輯:

// ❌ 常見的寫法
public String getTableName(int userId) {
    // 假設分32張表
    int tableIndex =  userId % 32; 
    return "t_order_" + tableIndex;
}

這段代碼在功能上沒有問題。但作為一個對底層細節有追求的工程師,我不禁想到了 HashMap 的實現。

打開 JDK 源碼,在 HashMap.put 方法中,計算節點落槽位置的代碼是這樣的:

// HashMap源碼片段
if ((p = tab[i = (n - 1) & hash]) == null) ...

Doug Lea 並沒有使用直觀的 hash % n,而是使用了與運算 &

為什麼?
根本原因在於 CPU 的指令執行效率:

  • 位運算(&, |, ^, ~):直接對應 CPU 底層指令,通常只需 1 個時鐘週期
  • 取模運算(%):涉及除法操作,在底層硬件實現上極其複雜,通常消耗 10-30 個時鐘週期

雖然在普通的業務邏輯中,幾十個時鐘週期的差異可以忽略不計。但在高頻觸發的基礎設施層(如網關路由、分表中間件、緩存尋址),這種微小的性能差異在高併發下會被顯著放大。


二、 揭秘位運算的物理意義

HashMap 能用 & 替代 %,有一個強制性的前提條件

數組長度(或分表數)必須是 2 的 n 次冪(2, 4, 8, 16, 32, 64...)

1. 數學原理

公式
n = 2^k 時,X % n 等價於 X & (n - 1)

這個公式背後的邏輯,從二進制視角來看會非常清晰。

假設分表數 n = 8(即 2³)。
那麼 n - 1 = 7

  • 8 的二進制... 0000 1000
  • 7 的二進制... 0000 0111 (低三位全為1,高位全為0)

當我們計算 userId & 7 時,實際上是在進行位掩碼(BitMask)操作。
因為 7 的高位全是 0,& 運算會將 userId 的所有高位清零,只完整保留最後三位

而一個數值的低 k 位,恰恰就是它對 2^k 取模的餘數。

userId (十進制) userId (二進制) & 0111 (掩碼) 結果 % 8 結果
10 ... 0000 1010 0010 2 2
15 ... 0000 1111 0111 7 7
16 ... 0001 0000 0000 0 0
17 ... 0001 0001 0001 1 1

結論:只要分表數是 2 的冪,位運算本質上就是一次高效的低位截取。它能在硬件層面以最快的速度得到我們想要的結果。


三、 實戰:打造生產級路由工具

原理雖然簡單,但要落地到生產環境,必須考慮工程化問題:如何防止後續維護者錯誤配置分表數?如何保證代碼的健壯性?

這裏我們推薦使用 防禦性編程 + 枚舉約束 的方式。

1. 定義分表策略枚舉

我們通過枚舉(Enum)將分表規則固定下來,並在枚舉構造器中進行嚴格校驗。

@Getter
@AllArgsConstructor
public enum TableStrategyEnum {
    /** 訂單表:分32張 */
    ORDER("t_order_", 32),
    
    /** 支付流水錶:分64張 */
    PAYMENT("t_pay_flow_", 64);

    private final String prefix;
    private final int count;
    
    // 構造時進行 Fail-Fast 檢查
    // 如果配置的不是 2 的冪,應用啓動時就會直接拋錯,阻止由於配置失誤導致的上線事故
    TableStrategyEnum(String prefix, int count) {
        if (count <= 0 || (count & (count - 1)) != 0) {
            throw new Error("配置錯誤:Strategy [" + prefix + "] 分表數必須是 2 的冪!");
        }
        this.prefix = prefix;
        this.count = count;
    }
}

2. 封裝核心路由工具類

public class DivTableUtils {

    /**
     * 獲取目標表名
     * @param bizId 業務主鍵(如 userId, orderId)
     * @param strategy 分表策略
     */
    public static String getTableName(int bizId, TableStrategyEnum strategy) {
        // 1. 基礎校驗
        if (bizId <= 0) {
            throw new IllegalArgumentException("業務ID必須為正整數");
        }
        
        // 2. 核心位運算邏輯
        // 因為枚舉構造裏已經保證了 count 是 2 的冪,這裏可以安全使用位運算
        int index = bizId & (strategy.getCount() - 1);
        
        // 3. 拼接返回
        return strategy.getPrefix() + index;
    }
}

使用示例

// 業務代碼中調用,清晰且安全
String tableName = DivTableUtils.getTableName(user.getId(), TableStrategyEnum.ORDER);
// 輸出:t_order_5

四、 深度思考:為什麼要強制 2 的冪?

肯定有人會問:

“我的業務規模剛好適合分 100 張表,為了用位運算強行改成 128 張,是不是這就叫過早優化?”

這個問題直擊要害。
事實上,HashMap 以及我們推薦的分表策略,強制使用 2 的冪次方,性能提升只是表象,真正的核心價值在於——擴容的平滑性

擴容時的 "Rehash" 魔法

通常分表擴容時,我們會將表數量翻倍(例如 16 -> 32)。
如果我們使用傳統的 hash % n,當 n 變化時,幾乎所有數據的路由結果都會發生改變,這意味着我們需要遷移絕大部分數據。

但如果我們遵循 2 的冪次方擴容:
從二進制角度看,n 從 16 (10000) 變為 32 (100000),僅僅意味着位掩碼多取了一位

對於任何一個 ID,其路由結果只有兩種可能:

  1. 保持不變:如果新增的那一位是 0。
  2. 平移固定量:如果新增的那一位是 1,新座標 = 原座標 + 原長度

這就是 HashMap 擴容時 rehash 極其高效的秘密。反映到數據庫分表擴容上,這意味着我們有一半的數據完全不需要移動,這一點對於海量數據的遷移至關重要。


五、 結語:在比特世界裏尋找優雅

計算機科學裏有一句名言:"Simple is Better"

但"簡單"往往有兩個層次:
一個是無知的簡單:隨手寫下 id % n,不管不顧。
一個是透徹的簡單:理解了二進制的規律,用 id & (n-1) 駕馭複雜。

DivideTableUtils 這個小工具,雖然只有寥寥幾行代碼,卻折射出了優秀架構設計的三個維度:

  1. 對原理的極致利用(位運算加速)。
  2. 對錯誤的零容忍(Fail-Fast 校驗)。
  3. 對未來的深遠規劃(Rehash 擴容)。

可以預見的是,當你的業務流量洪峯到來時,那些藏在比特世界裏的每一次優化,都將成為系統最堅實的護盾。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.