一、訊息佇列概述
訊息佇列中介軟體是分布式系統中重要的組件,主要解決應用耦合,非同步訊息,流量削鋒等問題。實現高效能,高可用,可伸縮和最終一致性架構。是大型分布式系統不可缺少的中介軟體。
目前在生產環境,使用較多的訊息佇列有ActiveMQ,RabbitMQ,ZeroMQ,Kafka,MetaMQ,RocketMQ等。
自己實現一個較完善的訊息佇列要考慮高可用、順序和重複訊息、可靠投遞、消費關係解析等等比較複雜的問題,筆者不對這些內容進行闡述,重點結合線程池實現解耦,非同步訊息功能,對其他功能有興趣的話推薦美團技術部落格的一篇文章訊息佇列設計的精髓基本都藏在本文裡了
二.非同步處理的流程
情境說明:使用者註冊後,需要發註冊郵件和註冊簡訊。傳統的做法有兩種
1.串列的方式;2.並行方式。
(1)串列方式:將註冊資訊寫入資料庫成功後,發送註冊郵件,再發送註冊簡訊。以上三個任務全部完成後,返回給用戶端。
(2)並行方式:將註冊資訊寫入資料庫成功後,發送註冊郵件的同時,發送註冊簡訊。以上三個任務完成後,返回給用戶端。與串列的差別是,並行的方式可以提高處理的時間。
假設三個業務節點每個使用50毫秒鐘,不考慮網路等其他開銷,則串列方式的時間是150毫秒,並行的時間可能是100毫秒。
因為CPU在單位時間內處理的請求數是一定的,假設CPU1秒內輸送量是100次。則串列方式1秒內CPU可處理的請求量是7次(1000/150)。並行方式處理的請求量是10次(1000/100)。
小結:如以上案例描述,傳統的方式系統的效能(並發量,輸送量,回應時間)會有瓶頸。如何解決這個問題呢。
引入非同步處理。改造後的架構如下:
三.使用MongoDB實現上述的架構 3.1 資料庫定義
如上圖所示,“發送註冊簡訊“和“發送註冊郵件“都是需要非同步處理的任務。該任務將均由主線程寫入資料庫(“任務隊列表”),同時有另一個線程讀取任務隊列表的資料並根據具體的“發送註冊簡訊“和“發送註冊郵件“來處理。
任務隊列模型定義
TaskQueue.java
@Documentpublic class TaskQueue { @Id private String _id; private String name; //任務名稱。例如:“發送註冊簡訊“或“發送註冊郵件“等 private String param; //需要的處理任務的參數 private int status; // 狀態,0:初始 1:處理中 8:處理失敗 9:處理成功 private int retry; // 重試次數,僅對於處理失敗,status:8 private Date created; // 建立時間 private int threadNid; //處理成功該任務的任務處理線程唯一標示符 private int priority; // 越小,被執行的機率越大}
任務處理執行緒模式定義
ThreadInstance.java
@Documentpublic class ThreadInstance implements Comparable<ThreadInstance>{ @Id private String _id; @Indexed(unique=true) private int nid; // 序號,唯一索引。唯一索引的意義後續解釋 private long pid; // 線程id private int taskCounts; // 當前任務處理線程正在處理的任務數,通過心跳來更新 private int execedTasks; //該任務處理線程的總數,通過心跳來更新 private Date update; // 上次活躍時間,通過心跳來更新}
3.2非同步處理任務的線程池
對應上圖,“發送註冊簡訊“和“發送註冊郵件“等需要通過單獨的線程來完成。因為可以更快的完成非同步任務,可能需要多個線程同時來作為非同步處理線程。此處,使用線程池來完成。
ThreadPoolExecutor線程池的工作原理
線程池有三個核心關鍵詞:核心線程、工作隊列、最大線程和飽和策略 核心線程池對應corePoolSize變數的值,如果啟動並執行線程小於corePoolSize無論當前是否有空閑線程,總是會建立新的線程執行任務(這個過程需要擷取全域鎖) 如果啟動並執行線程大於corePoolSize,則將任務加入BlockingQueue–對應工作隊列 如果BlockingQueue已滿,且當前線程數尚未超過maximumPoolSize—最大線程。則線程池繼續建立線程。 如果BlockingQueue已滿,並且當前線程數超過maximumPoolSize,則根據當前線程池已飽和。根據指定的飽和策略來處理。拋出異常,丟棄等等。
// 建立一個順序儲存的阻塞隊列,並指定大小為500 BlockingQueue<Runnable> blockingQueue = new ArrayBlockingQueue<Runnable>(500); // 建立線程池的飽和策略,AbortPolicy拋異常 RejectedExecutionHandler handler = new ThreadPoolExecutor.AbortPolicy(); // 建立線程池,線程池基本大小5 最大線程數為10 線程最大空閑時間10分鐘 ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 10, TimeUnit.MINUTES, blockingQueue, handler); // 提交5個job for (int i = 0; i < 5; i++) { final int nid = i; //nid是每個非同步任務處理隊列的唯一識別碼 threadPoolExecutor.execute(new Runnable() { @Override public void run() { //不停的從資料庫中擷取“發送註冊簡訊“和“發送註冊郵件“等任務並完成 } }); }
3.3任務的抽象介面和實現
無論是“發送註冊簡訊“和“發送註冊郵件“,都屬於被非同步處理都任務,需抽象出一個介面,這樣各個非同步處理線程才能方便的執行
需要非同步處理任務的介面:
Job.java
public interface Job { /** *@params _id如前所述,每個待處理待任務都是mongo中的一條記錄,該參數為mongo主鍵 * param 該任務需要的參數 *@return 8:處理失敗 9:處理成功 */ public abstract int exec(String _id, String param);}
發送註冊簡訊任務
MessageJob.java
public class MessageJob implements Job{ public int exec(String _id, String param) { try { //TODO 傳送簡訊的代碼 return 9; }catch(Exception e) { return 8; } }}
發送註冊簡訊任務
EmailJob.java
public class EmailJob implements Job{ public int exec(String _id, String param) { try { //TODO 發送郵件的代碼 return 9; }catch(Exception e) { return 8; } }}
3.4非同步任務處理線程的實現
這是最為核心的內容,代碼實現如下:
public class JobTread { //當前job線程從資料庫中載入出的任務 private List<TaskQueue> prepareExecTask; //當前線程正在處理的任務數。該變數需要每格一段時間心跳給ThreadInstance,需要volatile修飾 private volatile int nowExecTasks = 0; //上次心跳到當前心跳到時間中處理的進程數,用做記錄當前線程已處理的task總數。該變數需要每格一段時間心跳給ThreadInstance,需要volatile修飾 private volatile int execedTasks = 0; @Autowired private ThreadInstanceService processService; private Timer timer = new Timer(); @Autowired private TaskQueueService taskQueueService; private String[] jobs = { "com.mq.job.jobImpl.EmailJob", "com.mq.job.jobImpl.MessageJob" }; //當前非同步任務處理的唯一識別碼 private int nid; //儲存所有任務的執行個體(根據jobs類路徑資料反射) private Map<String, Job> jobMap; public JobTread() { } public JobTread(int nid) { this.nid = nid; this.jobMap = getJobMap(); openHeartbeat(Thread.currentThread().getId()); } /** *根據所有任務的類路徑反射出執行執行個體並儲存在Map中,key為類名,也是TaskQueue模型中的name */ private Map<String, Job> getJobMap() { jobMap = new HashMap<String, Job>(); // 根據jobs中的類名,反射出Class,根據類名為key,Class為value存入map for (int i = 0; i < jobs.length; i++) { String classPath = jobs[i]; try { Class<Job> clazz = (Class<Job>) Class.forName(classPath); String className[] = clazz.getName().split("\\."); jobMap.put(className[className.length - 1], clazz.newInstance()); } catch (Exception e) { // TODO Auto-generated catch block e.printStackTrace(); } } return jobMap; } /** * 每個Thread載入處理的task * @return */ public int loadTask() { //從資料庫中peak出一定數量的task prepareExecTask = taskQueueService.peaks(nid); nowExecTasks= prepareExecTask.size(); prepareExecTask.stream().forEach(it -> this.exec(it)); return prepareExecTask.size(); } /** *使用Timer開啟當前線程對TaskQueue模型的心跳。每一定的時間會將當前線程正在處理的任務數和已經處理的任務數記錄到資料庫中 */ private void openHeartbeat(long threadId) { // 開啟一個定時 timer.schedule(new TimerTask() { @Override public void run() { // TaskProcess的心跳,隨時更新當前擷取時間和正常處理的task數量 processService.touch(nid, threadId, nowExecTasks, execedTasks); execedTasks = 0; } }, 0, 1000*30); } /** * 執行一個task */ public void exec(TaskQueue take) { if (take==null) { return; } // 擷取Job執行個體(com.mq.job.jobImpl下的執行個體) Job jobInstance = jobMap.get(take.getName()); // Job來執行task int result = jobInstance.exec(take.get_id(), take.getParam()); switch (result) { case 8: // 處理失敗, 置Task.status為8 taskQueueService.execFail(take.get_id()); break; case 9: // 處理成功,置Task.status為9 taskQueueService.execSuccess(take.get_id()); break; } nowExecTasks--; execedTasks++; }}
該類主要提供了四個方法: 根據類路徑反射所有的具體的任務類(“發送註冊簡訊“和“發送註冊郵件“)的實現,儲存與Map中。key為類名,也是TaskQueue(儲存非同步任務的模型中的name) 從TaskQueue中取出一定數量的任務來處理,並即時維護當前正在處理的任務數和已處理完成的任務數 將當前正在處理的任務數和已處理完成的任務數資訊即時更新到ThreadInstance執行個體中 具體的任務執行,根據TaskQueue.name從Map中取出任務實現,並執行exec方法,具體傳回值標示給任務是否處理成功 四.ThreadInstance模型的DAO方法
ThreadInstance即為非同步任務處理線程的執行個體,即提交給線程池的任務執行個體。該模型的唯一標示符為建立時的序號id,並非線程id,因為考慮到重啟時線程id可能會改變。故將向線程池中提交的序號id(nid)作為唯一索引。
DAO方法僅提供一個touch心跳方法,即在Timer中定時執行,來傳遞當前正在處理的任務數和距離上次心跳後處理的任務總數
public class ThreadInstanceService { @Autowired private ThreadDao threadDao; /** *@params nid 當前線程---非同步任務處理隊列的唯一標示符 * threadId線程ID * taskSize 當前正在處理的任務數 * execedTasks 距離上次心跳後處理的任務總數 */ public void touch(int nid,long threadId, int taskSize, int execedTasks) { ThreadInstance thread = threadDao.findAndUpdatByNid(nid,threadId, taskSize, execedTasks); //如果當前線程還未建立於ThreadInstance,則建立 if (thread == null) { threadDao.create(nid, threadId); } }}public class ThreadDaoImpl implements ThreadDao{ @Autowired private MongoTemplate template; /** db.ThreadInstance.update({ nid: nid },{ $set: { pid: threadId, taskCounts: taskSize, update: new Date() }, $inc: { execedTasks: execedTasks } }) */ public ThreadInstance findAndUpdatByNid(long nid, long threadId, int taskSize, int execedTasks) { return template.findAndModify(Query.query(Criteria.where("nid").is(nid)), new Update().set("pid", threadId).set("update", new Date()).set("taskCounts", taskSize).inc("execedTasks", execedTasks), ThreadInstance.class); } /** db.ThreadInstance.create({ nid: nid, pid: threadId, taskCounts: 0, execedTasks: 0, }) **/ @Override public void create(int nid, long threadId) { template.insert(new ThreadInstance(nid,threadId,0,0)); }}
五.ThreadQueue模型的DAO方法
ThreadQueue模型的每一個記錄即為待非同步處理的任務,如需要“發送註冊簡訊“和“發送註冊郵件“等。
如前所示,ThreadQueue主要提供如下三個方法: 從線程池中擷取一定數量的task 標註當前任務已處理成功 標註當前任務處理失敗
由於從線程池中擷取一定數量的任務是多個線程同時擷取的,因次可能存在多個線程擷取到同一個任務的可能。相信讀者也知道Mongo一個操作是原子性的,因為可以使用findOneAndUpdate,{status:0,{$set:{status:1}}}。這樣固然不會存在多個線程擷取到同一個記錄到情況,筆者第一次也是這麼實現的。但當將其放入生產環境後,發現這樣每次僅僅從資料庫中讀一個記錄來處理效率非常非常低。大量的任務被阻塞到資料庫中。
因此,需要一種一次性可以擷取多條記錄,又避免重複擷取的方法。TastQueue中的threadNid欄位就是為此而生
public class TaskQueueService { private TaskQueueDao taskQueueDao = new TaskQueueDaoImpl(); /** * 擷取該次peak中的優先順序 * 返回為1的機率最大,2,3,4,5依次 * @return */ public int roll() { Double random = Math.random(); for (int i = 1; i <= 5; i++) { if (random > 1 / Math.pow(2, i)) { return i; } } return 1; } /** * 從線程池中擷取一定數量的task * * @param threadNid * 需要擷取task的threadNid.非線程id而是作為唯一索引的nid * @return List<TaskQueue> */ public List<TaskQueue> peaks(int threadNid) { // 擷取task狀態為0,1(初始化,處理中)。避免將載入到記憶體中task因重啟而丟失 List<Integer> statuses = new ArrayList<Integer>(); statuses.add(0); statuses.add(1); // 擷取task的tag為null和當前線程的 List<Integer> tags = new ArrayList<Integer>(); tags.add(null); tags.add(threadNid); //擷取一個優先順序 int priority = this.roll(); //擷取出未處理的和當前線程正在處理的TaskQueue List<TaskQueue> taskQueues = taskQueueDao.peaks(statuses, tags, priority); //取出所有的_id。 多個線程可能會擷取出相同的任務,均是未處理的 List<String> ids = taskQueues.stream().map(it -> it.get_id()).collect(Collectors.toList()); // 將上述擷取到所有未處理的或當前線程正在處理的所有線程全部更新為當前線程正常處理 // 此處的更新的條件除了$in:ids外,還必須threadNid為null。 // 對於一個任務來說,無論有多少個線程擷取到它。但只能有一個線程更新成功“處理中”,即status更新為1,threadNid更新為當前線程nid int updateSize = taskQueueDao.execing(ids, threadNid); // 如果更新出的數量為0,說明所有的task全部是處理中的,返回空 if (updateSize == 0) { return new ArrayList<TaskQueue>(); } // 此時,再次擷取出所有當前線程處理中的task,即多個線程不會返回相同的任務給上級 List<Integer> execStatuses = new ArrayList<Integer>(); execStatuses.add(1); List<Integer> execTags = new ArrayList<Integer>(); execTags.add(threadNid); return taskQueueDao.peaks(execStatuses, execTags, priority); } //更新status為9 public void execSuccess(String _id) { taskQueueDao.success(_id); } //更新status為8 public void execFail(String _id) { taskQueueDao.fail(_id); }}public class TaskQueueDaoImpl implements TaskQueueDao{ @Autowired private MongoTemplate template; /** db.TaskQueue.find({ status: {$in: statuses}, threadNid: {$in: threadNids}, priority: priority, }).limit(20) **/ @Override public List<TaskQueue> peaks(List<Integer> statuses, List<Integer> threadNids, int priority) { return template.find(Query.query(Criteria.where("status").in(statuses).and("threadNid").in(threadNids).and("priority").is(priority)).limit(20), TaskQueue.class); } /** db.TaskQueue.update({_id: _id},{$set:{status: 9}}) **/ @Override public void success(String _id) { template.updateFirst(Query.query(Criteria.where("_id").is(_id)), Update.update("status", 9), TaskQueue.class); } /** db.TaskQueue.update({_id: _id},{$set:{status: 8}}) **/ @Override public void fail(String _id) { template.updateFirst(Query.query(Criteria.where("_id").is(_id)), Update.update("status", 3), TaskQueue.class); } @Override public int execing(List<String> _ids, int threadNid) { WriteResult writeResult = template.updateMulti(Query.query(Criteria.where("_id").in(_ids).and("threadNid").is(null)), Update.update("status", 1).set("threadTag", threadNid), TaskQueue.class); return writeResult.getN(); }}
六.測試
運行如下代碼在TaskQueue中建立300個任務,然後啟動線程池,提交5個線程來處理這300個任務。
public static void testData() { new Thread(new Runnable() { @Override public void run() { for(int i=0;i<150;i++){ getMongoTemplate().save(new TaskQueue("EmailJob",UUID.randomUUID().toString()+"@sina.com")); getMongoTemplate().save(new TaskQueue("MessageJob","隨機手機號")); } } }).start(); }
// 提交5個job for (int i = 0; i < 5; i++) { final int nid = i; threadPoolExecutor.execute(new Runnable() { @Override public void run() { exec(nid); } }); }public static void exec(int nid) { JobTread thread = new JobTread(nid); while (true) { int tasks = thread.loadTask(); if (tasks == 0) { // 休息一會 try { Thread.sleep(3000); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }
此時會在ThreadInstance模型中產生5條記錄,並且即時會更新出每個線程正常處理的任務數,和處理的任務總數。當運行完成後,運行如下語句,輸出300:
db.getCollection('threadInstance').aggregate( [ { $group: { _id: 'execedTasks', count:{$sum:'$execedTasks'} } } ])