【源碼分析專題】-阿里開源Nacos註冊及配置中心 最佳長輪詢 實現原理

前言:看完本篇,你將瞭解到web端常用的實時通訊技術種類及其適用場景,你將瞭解到幾種不同的長輪詢方式,以及它們的差異,最後將一睹互聯網大廠Nacos的長輪詢技術,從而在以後遇到消息推送場景/在線聊天/配置中心等需要長輪詢技術的場景時,可以寫出優雅又性能爆棚的代碼,文中內容看起來較長,其實大部分篇幅是代碼實現,可以選擇跳過或者簡單看看,裏面的代碼都是可以直接跑通的,不妨複製粘貼到IDE裏運行看看效果.另外延伸閱讀部分建議看完通篇後有餘力再閱讀,否則會打斷思路.


目錄

1.Web端即時通訊技術

1.1常見的Web端實時通訊技術的實現方式

1.1.1短輪詢

1.1.2長輪詢

1.1.3長連接

1.1.4websocket

2.長輪詢詳解

2.1長輪詢的好處

2.2長輪詢的原理

2.3長輪詢的實現

2.3.1實現一:while死循環

2.3.2實現二:Lock notify  + future.get(timeout)

2.3.3實現三:schedule + AsyncContext方式(Nacos的配置中心實現方式)

3.總結


1.Web端即時通訊技術

Web端即時通訊技術即服務端可以即時將信息變更傳輸至客戶端,這種場景在開發中非常常見,比如消息推送,比如在線聊天等,是一種比較實用的技術,在很多場景對提升用戶體驗有奇效.

1.1常見的Web端實時通訊技術的實現方式

1.1.1短輪詢

客戶端每隔一段時間向服務端發送請求,服務端收到請求即響應客戶端請求,這種方式實現起來最簡單,也比較實用,但缺點顯而易見,實時性不高,而且頻繁的請求在用戶量過大時對服務器會造成很大壓力.

1.1.2長輪詢

服務端收到客戶端發來的請求後不直接響應,而是將請求hold住一段時間,在這段時間內如果數據有變化,服務端纔會響應,如果沒有變化則在到達一定的時間後才返回請求,客戶端Js處理完響應後會繼續重發請求...這種方式能夠大幅減少請求次數,減少服務端壓力,同時能夠增加響應的實時性,做的好的話基本上是即時響應的.

1.1.3長連接

長連接SSE是H5推出的新功能,全稱Server-Sent Events,它可以允許服務推送數據到客戶端,SSE不需要客戶端向服務端發請求,服務端數據發生變化時,會主動向客戶端發送,可以保證實時性,顯著減輕服務端壓力.

1.1.4websocket

websocket是H5提供的一個新協議,可以實現客戶端和服務端的全雙工通信,服務端和客戶端可以自由相互傳輸數據,不存在請求和響應的區別.

以上實現方式各有優劣,不作評判,各有各的適用場景,不必糾結哪種技術更好,只有更適合.

另外本篇只對長輪詢做詳細介紹,因爲最近研究了大廠的長輪詢技術,覺得很厲害,佩服的膝蓋都獻上了,所以分享一下.

2.長輪詢詳解

2.1長輪詢的好處

長輪詢具有實現相對簡單,高效,服務端壓力小,輕量,響應迅速等優點,所以被廣泛的用於各種中間件,配置中心,在線聊天(如Web qq)等場景. 反正我在第一次接觸到長輪詢時感覺還挺神奇的,就是當時第一次用spring-cloud的配置中心config時,對它可以在github或者碼雲上修改配置文件application.yml後可以立即在客戶端即時拉取該更新的配置內容產生了極大興趣和好奇,你是否也有同樣的疑惑,讀完本篇就可以解開此謎團。

2.2長輪詢的原理

客戶端向服務端發起請求,服務端收到請求後不直接響應,而是把請求hold一段時間,在這段時間如果服務端檢測到有數據發生變化,就中斷hold,然後立即響應客戶端,否則就啥也不做,直到達到預設的超時時間,再返回響應. 在hold住請求這段時間,其實是一個監聽器或者觀察者模式,但重點是如何Hold住請求?

(延伸閱讀:【設計模式】-監聽者模式和觀察者模式的區別與聯繫https://blog.csdn.net/lovexiaotaozi/article/details/102579360)

2.3長輪詢的實現

服務端實現長輪詢的方式也有很多種,本篇介紹三種,先不着急寫代碼,理一下思路:

①被觀察對象如果沒有改變,服務端就啥也不做,傻傻等待就完事了.

②一旦被觀察對象發生改變,立即終結hold狀態,響應請求.

③hold直到預設的超時時間都沒數據發生變化,返回響應(可以包含超時信息,也可以不包含,反正客戶端還是要重新發請求的...)


TIPS:答應我,一定要看到實現三,因爲實現三這種方式是阿里內部某不方便透露名字的中間件產品採用的核心技術,該中間件作爲阿里的配置中心,承載了每年雙11海量的請求, 當然該中間件並沒有這麼簡單,裏面融入了緩存,負載均衡,MD5...各種內容,但長輪詢的核心實現原理就是實現三這麼樸素。


2.3.1實現一:while死循環

完整代碼我已經貼出來了,可以直接複製到你的IDE裏運行:

@RestController
@RequestMapping("/loop")
public class LoopLongPollingController {
    @Autowired
    LoopLongPollingService loopLongPollingService;

    /**
     * 從服務端拉取被變更的數據
     * @return
     */
    @GetMapping("/pull")
    public Result pull() {
        String result = loopLongPollingService.pull();
        return ResultUtil.success(result);
    }

    /**
     * 向服務端推送變更的數據
     * @param data
     * @return
     */
    @GetMapping("/push")
    public Result push(@RequestParam("data") String data) {
        String result = loopLongPollingService.push(data);
        return ResultUtil.success(result);
    }
}
@Data
public class Result<T> {
    private T data;
    private Integer code;
    private Boolean success;
}
public class ResultUtil {
    public static Result success() {
        Result result = new Result();
        result.setCode(200);
        result.setSuccess(true);
        return result;
    }

    public static Result success(Object data) {
        Result result = new Result();
        result.setSuccess(true);
        result.setCode(200);
        result.setData(data);
        return result;
    }
}
@Configuration
public class ThreadPoolConfig {
    @Bean
    public ScheduledExecutorService getScheduledExecutorService() {
        AtomicInteger poolNum = new AtomicInteger(0);
        ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(2, r -> {
            Thread t = new Thread(r);
            t.setName("LoopLongPollingThread-" + poolNum.incrementAndGet());
            return t;
        });
        return scheduler;
    }
}
public interface LoopLongPollingService {
    String pull();

    String push(String data);
}
@Service
public class LoopLongPollingServiceImpl implements LoopLongPollingService {
    @Autowired
    ScheduledExecutorService scheduler;
    private LoopPullTask loopPullTask;

    @Override
    public String pull() {
        loopPullTask = new LoopPullTask();
        Future<String> result = scheduler.schedule(loopPullTask, 0L, TimeUnit.MILLISECONDS);
        try {
            return result.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        return "ex";
    }

    @Override
    public String push(String data) {
        Future<String> future = scheduler.schedule(new LoopPushTask(loopPullTask, data), 0L, TimeUnit.MILLISECONDS);
        try {
            return future.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        return "ex";
    }
}
@Slf4j
public class LoopPullTask implements Callable<String> {
    @Getter
    @Setter
    public volatile String data;
    private Long TIME_OUT_MILLIS = 10000L;

    @Override
    public String call() throws Exception {
        Long startTime = System.currentTimeMillis();
        while (true) {
            if (!StringUtils.isEmpty(data)) {
                return data;
            }
            if (isTimeOut(startTime)) {
                log.info("獲取數據請求超時" + new Date());
                data = "請求超時";
                return data;
            }
            //減輕CPU壓力
            Thread.sleep(200);
        }
    }

    private boolean isTimeOut(Long startTime) {
        Long nowTime = System.currentTimeMillis();
        return nowTime - startTime > TIME_OUT_MILLIS;
    }
}
public class LoopPushTask implements Callable<String> {
    private LoopPullTask loopPullTask;
    private String data;

    public LoopPushTask(LoopPullTask loopPullTask, String data) {
        this.loopPullTask = loopPullTask;
        this.data = data;
    }

    @Override
    public String call() throws Exception {
        loopPullTask.setData(data);
        return "changed";
    }
}

然後我依次在瀏覽器訪問:

http://localhost:8080/loop/pull

http://localhost:8080/loop/push?data=aa

效果:

超時:

如果可以動態展示就好了,這樣看效果不直觀,原本效果是拉取數據變更的頁面處在加載過程中,當數據變更頁面被訪問後,加載中的頁面即刻收到了返回,返回的內容就是變更後的數據,有興趣的可以自行演示.


思考:

這樣做確實實現了預期的效果,但存在非常嚴重的性能問題,在請求獲取數據時一直處於while(true)的死循環中,如果在這個過程中並沒有任何數據變更,CPU資源就白白浪費了,在併發較高的場景中,所有線程都在競爭CPU資源,然後while(true)循環,不僅寶貴的CPU資源被浪費,還容易使服務器過載崩潰。那能否採用什麼手段,使得CPU資源僅在數據發生改變時才被利用,其餘時間被讓出做別的事情,答案是肯定的。

2.3.2實現二:Lock notify  + future.get(timeout)

思路:通過Object.wait()阻塞拉取任務的線程,等到數據發生變更時,再將其喚醒,這樣就不會像前面的while死循環那樣浪費CPU資源了,而且通知也足夠及時!

爲了區分,這裏採用Lock代替Loop,其餘公用代碼與上面保持一致:

@RestController
@RequestMapping("/lock")
public class LockLongPollingController {
    @Autowired
    private LockLongPollingService lockLongPollingService;

    @RequestMapping("/pull")
    public Result pull() {
        String result = lockLongPollingService.pull();
        return ResultUtil.success(result);
    }

    @RequestMapping("/push")
    public Result push(@RequestParam("data") String data) {
        String result = lockLongPollingService.push(data);
        return ResultUtil.success(result);
    }
}
public interface LockLongPollingService {
    String pull();

    String push(String data);
}

 

@Service
public class LockLongPollingServiceImpl implements LockLongPollingService {
    @Autowired
    ScheduledExecutorService scheduler;
    private LockPullTask lockPullTask;
    private Object lock;

    @PostConstruct
    public void post() {
        lock = new Object();
    }

    @Override
    public String pull() {
        lockPullTask = new LockPullTask(lock);
        Future<String> future = scheduler.submit(lockPullTask);
        try {
            return future.get(10000, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        } catch (TimeoutException e) {
            return dealTimeOut();
        }
        return "ex";
    }

    private String dealTimeOut() {
        synchronized (lock) {
            lock.notifyAll();
            lockPullTask.setData("timeout");
        }
        return "timeout";
    }

    @Override
    public String push(String data) {
        Future<String> future = scheduler.schedule(new LockPushTask(lockPullTask, data, lock), 0L,
            TimeUnit.MILLISECONDS);
        try {
            return future.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        return "ex";
    }
}
@Slf4j
public class LockPullTask implements Callable<String> {
    @Getter
    @Setter
    public volatile String data;
    private Object lock;

    public LockPullTask(Object lock) {
        this.lock = lock;
    }

    @Override
    public String call() throws Exception {
        log.info("長輪詢任務開啓:" + new Date());
        while (StringUtils.isEmpty(data)) {
            synchronized (lock) {
                lock.wait();
            }
        }
        log.info("長輪詢任務結束:" + new Date());
        return data;
    }

}
@Slf4j
public class LockPushTask implements Callable<String> {
    private LockPullTask lockPullTask;
    private String data;
    private Object lock;

    public LockPushTask(LockPullTask lockPullTask, String data, Object lock) {
        this.lockPullTask = lockPullTask;
        this.data = data;
        this.lock = lock;
    }

    @Override
    public String call() throws Exception {
        log.info("數據發生變更:" + new Date());
        synchronized (lock) {
            lockPullTask.setData(data);
            lock.notifyAll();
            log.info("數據變更爲:" + data);
        }
        return "changed";
    }
}

測試效果:

超時:

思考:

這樣做在性能方面有了很大提升,同時也解決了通知的時效性問題,但仔細看還是存在一些問題,比如:《阿里巴巴java開發手冊》中提到的異常不要用來做流程控制,然而這邊在超時的異常處理中做了流程控制,當然這樣寫也無可厚非...

那麼有沒有辦法可以讓代碼更優雅?  

2.3.3實現三:schedule + AsyncContext方式(Nacos的配置中心實現方式)

Nacos在設計上考慮了頗多,除了我前面提到的代碼優雅的問題,還需要考慮高並和多用戶訂閱以及性能等諸多問題,對於各種細節本篇不作討論,只抽取最爲核心的長輪詢部分作演示.

基本思路是通過Servlet3.0後提供的異步處理能力,把請求的任務添加至隊列中,在有數據發生變更時,從隊列中取出相應請求,然後響應請求,負責拉取數據的接口通過延時任務完成超時處理,如果等到設定的超時時間還沒有數據變更時,就主動推送超時信息完成響應,下面我們來看代碼實現:

@RestController
@GetMapping("/nacos")
public class NacosLongPollingController extends HttpServlet {
    @Autowired
    private NacosLongPollingService nacosLongPollingService;

    @RequestMapping("/pull")
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        String dataId = req.getParameter("dataId");
        if (StringUtils.isEmpty(dataId)) {
            throw new IllegalArgumentException("請求參數異常,dataId能爲空");
        }
        nacosLongPollingService.doGet(dataId, req, resp);
    }
    //爲了在瀏覽器中演示,我這裏先用Get請求,dataId可以區分不同應用的請求
    @GetMapping("/push")
    public Result push(@RequestParam("dataId") String dataId, @RequestParam("data") String data) {
        if (StringUtils.isEmpty(dataId) || StringUtils.isEmpty(data)) {
            throw new IllegalArgumentException("請求參數異常,dataId和data均不能爲空");
        }
        nacosLongPollingService.push(dataId, data);
        return ResultUtil.success();
    }
}
public interface NacosLongPollingService {
    void doGet(String dataId, HttpServletRequest req, HttpServletResponse resp);

    void push(String dataId, String data);
}

Service層注意asyncContext的啓動一定要由當前執行doGet方法的線程啓動,不能在異步線程中啓動,否則響應會立即返回,不能起到hold的效果。 

@Service
public class NacosLongPollingServiceImpl implements NacosLongPollingService {
    final ScheduledExecutorService scheduler;
    final Queue<NacosPullTask> nacosPullTasks;

    public NacosLongPollingServiceImpl() {
        scheduler = new ScheduledThreadPoolExecutor(1, r -> {
            Thread t = new Thread(r);
            t.setName("NacosLongPollingTask");
            t.setDaemon(true);
            return t;
        });
        nacosPullTasks = new ConcurrentLinkedQueue<>();
        scheduler.scheduleAtFixedRate(() -> System.out.println("線程存活狀態:" + new Date()), 0L, 60, TimeUnit.SECONDS);
    }

    @Override
    public void doGet(String dataId, HttpServletRequest req, HttpServletResponse resp) {
        // 一定要由當前HTTP線程調用,如果放在task線程容器會立即發送響應
        final AsyncContext asyncContext = req.startAsync();
        scheduler.execute(new NacosPullTask(nacosPullTasks, scheduler, asyncContext, dataId, req, resp));
    }

    @Override
    public void push(String dataId, String data) {
        scheduler.schedule(new NacosPushTask(dataId, data, nacosPullTasks), 0L, TimeUnit.MILLISECONDS);
    }
}

NacosPullTask負責拉取變更內容,注意內部類中的this指向內部類本身,而非引用匿名內部類的對象.

@Slf4j
public class NacosPullTask implements Runnable {
    Queue<NacosPullTask> nacosPullTasks;
    ScheduledExecutorService scheduler;
    AsyncContext asyncContext;
    String dataId;
    HttpServletRequest req;
    HttpServletResponse resp;

    Future<?> asyncTimeoutFuture;

    public NacosPullTask(Queue<NacosPullTask> nacosPullTasks, ScheduledExecutorService scheduler,
        AsyncContext asyncContext, String dataId, HttpServletRequest req, HttpServletResponse resp) {
        this.nacosPullTasks = nacosPullTasks;
        this.scheduler = scheduler;
        this.asyncContext = asyncContext;
        this.dataId = dataId;
        this.req = req;
        this.resp = resp;
    }

    @Override
    public void run() {
        asyncTimeoutFuture = scheduler.schedule(() -> {
            log.info("10秒後開始執行長輪詢任務:" + new Date());
            //這裏如果remove this會失敗,內部類中的this指向的並非當前對象,而是匿名內部類對象
            nacosPullTasks.remove(NacosPullTask.this);
            //sendResponse(null);
        }, 10, TimeUnit.SECONDS);
        nacosPullTasks.add(this);
    }

    /**
     * 發送響應
     *
     * @param result
     */
    public void sendResponse(String result) {
        System.out.println("發送響應:" + new Date());
        //取消等待執行的任務,避免已經響完了,還有資源被佔用
        if (asyncTimeoutFuture != null) {
            //設置爲true會立即中斷執行中的任務,false對執行中的任務無影響,但會取消等待執行的任務
            asyncTimeoutFuture.cancel(false);
        }

        //設置頁碼編碼
        resp.setContentType("application/json; charset=utf-8");
        resp.setCharacterEncoding("utf-8");

        //禁用緩存
        resp.setHeader("Pragma", "no-cache");
        resp.setHeader("Cache-Control", "no-cache,no-store");
        resp.setDateHeader("Expires", 0);
        resp.setStatus(HttpServletResponse.SC_OK);
        //輸出Json流
        sendJsonResult(result);
    }

    /**
     * 發送響應流
     *
     * @param result
     */
    private void sendJsonResult(String result) {
        Result<String> pojoResult = new Result<>();
        pojoResult.setCode(200);
        pojoResult.setSuccess(!StringUtils.isEmpty(result));
        pojoResult.setData(result);
        PrintWriter writer = null;
        try {
            writer = asyncContext.getResponse().getWriter();
            writer.write(pojoResult.toString());
            writer.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            asyncContext.complete();
            if (null != writer) {
                writer.close();
            }
        }
    }
}

NacosPushTask執行數據變更: 

public class NacosPushTask implements Runnable {
    private String dataId;
    private String data;
    private Queue<NacosPullTask> nacosPullTasks;

    public NacosPushTask(String dataId, String data,
        Queue<NacosPullTask> nacosPullTasks) {
        this.dataId = dataId;
        this.data = data;
        this.nacosPullTasks = nacosPullTasks;
    }

    @Override
    public void run() {
        Iterator<NacosPullTask> iterator = nacosPullTasks.iterator();
        while (iterator.hasNext()) {
            NacosPullTask nacosPullTask = iterator.next();
            if (dataId.equals(nacosPullTask.dataId)) {
                //可根據內容的MD5判斷數據是否發生改變,這裏爲了演示簡單就不寫了
                //移除隊列中的任務,確保下次請求時響應的task不是上次請求留在隊列中的task
                iterator.remove();
                //執行數據變更,發送響應
                nacosPullTask.sendResponse(data);
                break;
            }
        }
    }
}

效果:

超時場景:

 

3.總結

從實現難易角度來看:實現一 < 實現二 < 實現三

從性能角度來看:實現一 < 實現二 < 實現三

實現三不同於前兩種實現方式的根本在於,實現三發送response的方法由自己控制,而前面兩種方式是交給springboot控制的.自己控制就避免了受制於人的限制,更加自由靈活.如果進一步設計,可以考慮加上緩存,對dataId和data加密確保信息安全,對數據變更的MD5校驗,心跳監控,高可用等...配合一套UI界面,就可以初步媲美阿里提供的中間件了.

文中如有不正之處歡迎留言斧正,有疑問也可以留言,我看到會及時回覆.

如果覺得閱讀本文有收穫,歡迎關注,我將與大家一起持續分享和學習成長.

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章