一文搞懂高性能定時器:時間輪

平常在工作中, 很多業務都會用到定時任務,我們常見的實現方式是TimerScheduledExecutorService, 今天這篇文章,帶你認識一種更輕量級、更適合高併發場景的定時方案 —— 時間輪(TimeWheel),並結合 Netty 的 HashedWheelTimer 具體講講如何在 SpringBoot 中使用它,以及它背後的底層設計。

什麼是時間輪(Time Wheel)?

時間輪是一種高效的、用於實現大量定時任務調度的算法和數據結構。它的核心思想借鑑了現實生活中的鐘表:一個圓盤(輪)被劃分爲多個(Slot),每個槽代表一個時間間隔(Tick)。一個指針(當前指針)按固定的時間間隔(Tick Duration)順時針轉動。任務根據其到期時間被散列(Hashed)到對應的槽中。當指針移動到某個槽時,就執行該槽中所有到期的任務。

時間輪的關鍵要素

  1. 輪(Wheel):一個環形數組(或鏈表數組),數組的每個元素(桶)代表一個時間槽。

  2. 槽(Slot / Bucket):輪上的一個單元,用於存放預定在該槽對應的時間點到期執行的任務。任務通常以鏈表形式存儲在槽中。

  3. 刻度(Tick):輪轉動的最小時間單位。指針每次移動一個槽,代表經過了一個 Tick Duration

  4. 指針(Current Pointer):指向當前時間對應的槽。隨着系統時間的推進(或內部計時器的驅動),指針按 Tick Duration 的間隔順時針移動。

  5. 輪數(Wheel Size / Ticks per Wheel):輪上槽的總數。它決定了在不升級到更高級輪的情況下,時間輪能表示的最大時間範圍 = Wheel Size * Tick Duration

  6. 多級時間輪(Hierarchical Timing Wheel):類似於現實中的鐘表有時針、分針、秒針。當任務到期時間超過單級時間輪的範圍時,會被放入更粗粒度(更大刻度)的上一級時間輪中。當上一級時間輪的指針移動時,會將其槽中的任務重新計算並降級(Rehash)到下一級更細粒度的時間輪中。這是處理非常長時間定時任務的關鍵機制。

多級時間輪示意圖:

時間輪的工作流程

  1. 添加任務
  1. 指針移動(滴答 - Tick)
  1. 執行任務

時間輪與傳統定時任務區別

rAghXB

總結區別

時間輪的典型實現:Netty HashedWheelTimer

HashedWheelTimer是 Netty 根據時間輪 (Timing Wheel) 開發的工具類,它要解決什麼問題呢?

netty 中使用 HashedWheelTimer 的場景:

HashedWheelTimer 的使用方式

通過構造函數看主要參數

public HashedWheelTimer(
        ThreadFactory threadFactory,
        long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection,
        long maxPendingTimeouts, Executor taskExecutor) {

}

具體參數說明如下:

Spring Boot 中使用 Netty HashedWheelTimer 實現定時任務

接下來我們通過具體代碼,看看HashedWheelTimer如何在 springboot 項目中使用:

步驟詳解

  1. 添加依賴 (pom.xml)

    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-common</artifactId>
        <version>4.1.109.Final</version> <!-- 使用最新穩定版 -->
    </dependency>
  2. 創建 HashedWheelTimer Bean

    在 Spring Boot 的配置類(@Configuration)中創建 Timer 實例。強烈建議將其配置爲單例 Bean,避免創建多個 Timer 實例浪費資源。

    import io.netty.util.HashedWheelTimer;
    import io.netty.util.Timer;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
        
    import java.util.concurrent.TimeUnit;
        
    @Configuration
    publicclass TimerConfig {
        
        @Bean
        public Timer hashedWheelTimer() {
            // 參數說明:
            //   tickDuration: 每個刻度的時間長度 (建議 >= 1ms)
            //   unit: tickDuration 的時間單位
            //   ticksPerWheel: 輪的大小(槽的數量),必須是 2 的冪 (默認 512)
            returnnew HashedWheelTimer(
                    new DefaultThreadFactory("netty-timer"), // 自定義線程工廠(可選,推薦)
                    100, // tickDuration = 100ms
                    TimeUnit.MILLISECONDS,
                    512// ticksPerWheel = 512 slots
            );
        }
    }
  1. 定義任務 (TimerTask)

    實現 Netty 的 TimerTask 接口,在 run 方法中編寫你的業務邏輯。

    import io.netty.util.TimerTask;
    import lombok.extern.slf4j.Slf4j;
    import java.util.concurrent.TimeUnit;
        
    @Slf4j
    publicclass MyTimeoutTask implements TimerTask {
        
        privatefinal String taskId;
        
        public MyTimeoutTask(String taskId) {
            this.taskId = taskId;
        }
        
        @Override
        public void run(Timeout timeout) throws Exception {
            // 注意:這個 run 方法是在 HashedWheelTimer 的單線程裏執行的!
            // 如果任務執行耗時較長,會阻塞後續任務的觸發和指針移動!
            // 對於耗時操作,應該在這裏提交到另一個線程池去執行。
            log.info("Task [{}] executed at {}", taskId, System.currentTimeMillis());
            // 模擬業務邏輯
            // ... 你的業務代碼 ...
        }
    }

    重要警告:run 方法在 HashedWheelTimer 的單線程(處理指針移動的線程)中執行。如果這個任務執行很慢(如 I/O 操作、複雜計算),會阻塞指針移動,導致後續所有任務的觸發都延遲!絕對不能在 run 方法中執行耗時操作! 解決方案:

  1. 提交定時任務

    在你需要設置定時器的地方(如 Service、Controller 中),注入 Timer Bean,然後使用它的 newTimeout 方法提交任務。

    import io.netty.util.Timeout;
    import io.netty.util.Timer;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
        
    import java.util.concurrent.TimeUnit;
        
    @Service
    publicclass TaskSchedulerService {
        
        privatefinal Timer timer; // 注入前面配置的Bean
        
        @Autowired
        public TaskSchedulerService(Timer timer) {
            this.timer = timer;
        }
        
        public void scheduleTask(String taskId, long delay, TimeUnit unit) {
            // 創建任務實例
            MyTimeoutTask task = new MyTimeoutTask(taskId);
            // 提交任務到時間輪,在指定延遲後執行
            Timeout timeout = timer.newTimeout(task, delay, unit);
            // 你可以保存這個 Timeout 對象,用於後續取消任務
            // timeout.cancel();
        }
        
        // 示例:5秒後執行任務
        public void scheduleDemo() {
            scheduleTask("demo-task-1", 5, TimeUnit.SECONDS);
        }
    }
  1. 資源清理(重要!)

    HashedWheelTimer 內部有一個線程。當 Spring Boot 應用關閉時,你需要優雅地停止這個 Timer,釋放其線程資源。實現 DisposableBean 或使用 @PreDestroy

    @Configuration
    publicclass TimerConfig {
        
        private Timer timer; // 保存Bean引用
        
        @Bean
        public Timer hashedWheelTimer() {
            timer = new HashedWheelTimer(100, TimeUnit.MILLISECONDS, 512);
            return timer;
        }
        
        @PreDestroy
        public void stopTimer() {
            if (timer != null) {
                // 停止 Timer,釋放資源。返回一個 Set<Timeout> 包含未執行的任務
                Set<Timeout> stoppedTimeouts = timer.stop();
                log.info("HashedWheelTimer stopped, cancelled {} tasks", stoppedTimeouts.size());
            }
        }
    }

關鍵注意事項與最佳實踐

  1. 單線程阻塞問題: 這是使用 HashedWheelTimer最關鍵的注意事項。務必確保 TimerTask.run() 方法執行非常快(毫秒級)。耗時任務必須異步化處理。

  2. 精度誤差: 接受 ± tickDuration 的誤差。如果你的業務要求精確到毫秒級執行,且任務量不大,ScheduledThreadPoolExecutor 可能更合適。

  3. 任務取消: 利用 Timeout.cancel() 及時取消不再需要的任務,防止內存泄漏和無謂的執行。

  4. 內存管理: 雖然時間輪本身結構內存佔用相對固定,但提交的任務對象本身會佔用內存。確保長時間不執行的任務(比如延遲 1 小時)能被正確清理(取消或執行完)。

  5. 監控: 監控 HashedWheelTimer 內部線程的狀態、任務隊列長度(雖然不像堆那樣 O(log n),但單個槽鏈表過長也可能影響性能)、任務執行耗時(確保沒有阻塞指針線程)。Netty 的 Timer 提供了一些統計方法(如 pendingTimeouts())。

  6. 參數調優:

  1. 替代方案考慮:

總結

時間輪以接近 O(1) 的複雜度實現任務的添加和到期觸發,性能遠超基於堆的傳統調度器。但使用時務必牢記其單線程執行任務的核心限制,避免阻塞指針移動,並通過異步化處理耗時任務。合理配置參數,並在應用關閉時妥善停止 Timer,就能高效穩定地服務於連接超時、心跳、緩存失效等典型場景。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/CrrBTIZA2j4SXgYU5gdjTQ