在這篇文章中,我們將討論我們如何以及為何開發
Interaction System(以下簡稱SV)是一個分散式、容錯、保證傳遞的訊息系統。 SV 被設計為具有高可擴展性的高負載服務,既可作為線上服務(由 1C 提供),也可作為可部署在您自己的伺服器設施上的大量生產產品。
SV使用分散式存儲
制定問題
為了清楚地說明我們創建互動系統的原因,我將向您介紹 1C 中業務應用程式開發的工作原理。
首先,為那些還不知道我們做什麼的人介紹一下我們:) 我們正在製作 1C:企業技術平台。 該平台包括業務應用程式開發工具以及允許業務應用程式在跨平台環境中運行的運行時。
客戶端-伺服器開發範例
1C上創建的業務應用:企業三級運營
在應用程式程式碼中,過程和函數的標頭必須明確指示程式碼將在何處執行 - 使用 &AtClient / &AtServer 指令(英文版本中為 &AtClient / &AtServer)。 1C 開發人員現在會糾正我,說指令其實是
您可以從客戶端程式碼呼叫伺服器程式碼,但不能從伺服器程式碼呼叫客戶端程式碼。 這是我們出於多種原因而做出的根本限制。 特別是,因為伺服器程式碼必須以這樣的方式編寫:無論從客戶端還是從伺服器呼叫它,它都以相同的方式執行。 在從另一個伺服器程式碼呼叫伺服器程式碼的情況下,不存在這樣的客戶端。 而且因為在伺服器程式碼執行期間,呼叫它的客戶端可以關閉、退出應用程序,並且伺服器將不再有任何人可以呼叫。
處理按鈕點擊的程式碼:從客戶端呼叫伺服器過程將起作用,從伺服器呼叫客戶端過程將不起作用
這意味著,如果我們想要從伺服器向客戶端應用程式發送一些訊息,例如,「長時間運行」報告的產生已完成並且可以查看該報告,我們沒有這樣的方法。 您必須使用技巧,例如,定期從客戶端程式碼輪詢伺服器。 但這種方法會為系統帶來不必要的調用,並且通常看起來不太優雅。
而且還有一個需求,像是有電話打過來的時候
生產本身
建立訊息傳遞機制。 快速、可靠、有保證的交付,並且能夠靈活搜尋訊息。 基於此機制,實現在 1C 應用程式內運作的信使(訊息、視訊通話)。
將系統設計為可水平擴展。 必須透過增加節點數量來滿足不斷增加的負載。
履行
我們決定不將SV的伺服器部分直接整合到1C:Enterprise平台中,而是將其實作為一個單獨的產品,其API可以從1C應用解決方案的程式碼中呼叫。 這樣做的原因有很多,其中最主要的一個是我希望能夠在不同的 1C 應用程式之間(例如,貿易管理和會計之間)交換訊息。 不同的1C應用程式可以運行在不同版本的1C:Enterprise平台上,位於不同的伺服器上等。 在這種情況下,將 SV 作為位於 1C 安裝「側面」的獨立產品實施是最佳解決方案。
因此,我們決定將 SV 作為一個單獨的產品。 我們建議小型公司使用我們在雲端中安裝的 CB 伺服器 (wss://1cdialog.com),以避免與本地安裝和設定伺服器相關的管理費用。 大客戶可能會發現建議在其設施中安裝自己的 CB 伺服器。 我們在雲端 SaaS 產品中使用了類似的方法
應用
為了分配負載和容錯能力,我們將部署不是一個 Java 應用程序,而是多個 Java 應用程序,並在它們前面放置一個負載平衡器。 如果您需要在節點之間傳輸訊息,請在 Hazelcast 中使用發布/訂閱。
客戶端和伺服器之間的通訊是透過 websocket 進行的。 它非常適合即時系統。
分散式快取
我們在 Redis、Hazelcast 和 Ehcache 之間進行選擇。 現在是 2015 年。 Redis剛剛發布了新的集群(太新了,嚇人),有Sentinel,限制很多。 Ehcache不知道如何組裝成叢集(這個功能稍後出現)。 我們決定使用 Hazelcast 3.4 來嘗試。
Hazelcast 開箱即可組裝成叢集。 在單節點模式下,它不是很有用,只能用作快取 - 它不知道如何將資料轉儲到磁碟,如果遺失唯一的節點,就會遺失資料。 我們部署了多個 Hazelcast,在它們之間備份關鍵資料。 我們不備份快取——我們不介意。
對我們來說,Hazelcast 是:
- 使用者會話的儲存。 每次去資料庫查詢一個session需要很長時間,所以我們把所有的session都放在Hazelcast中。
- 快取. 如果您正在查找用戶配置文件,請檢查快取。 寫入一條新訊息 - 將其放入快取中。
- 應用程式實例之間通訊的主題。 該節點產生一個事件並將其放置在 Hazelcast 主題中。 訂閱該主題的其他應用程式節點接收並處理該事件。
- 集群鎖。 例如,我們使用唯一鍵來建立一個討論(1C 資料庫中的單一討論):
conversationKeyChecker.check("БЕНЗОКОЛОНКА");
doInClusterLock("БЕНЗОКОЛОНКА", () -> {
conversationKeyChecker.check("БЕНЗОКОЛОНКА");
createChannel("БЕНЗОКОЛОНКА");
});
我們檢查了一下,沒有頻道。 我們拿走了鎖,再次檢查它,然後創建它。 如果您在獲取鎖定後不檢查鎖定,那麼另一個線程有可能當時也進行了檢查,並且現在將嘗試創建相同的討論 - 但它已經存在。 您無法使用同步或常規 java Lock 進行鎖定。 透過資料庫-速度慢,對資料庫來說很可惜;透過Hazelcast-這就是你所需要的。
選擇資料庫管理系統
我們在使用 PostgreSQL 以及與該 DBMS 的開發人員合作方面擁有豐富且成功的經驗。
PostgreSQL 叢集並不容易 - 有
如果您需要擴展關係型資料庫,這意味著
我們分片的第一個版本假設能夠以不同的比例將應用程式的每個表分佈在不同的伺服器上。 伺服器 A 上有很多訊息 - 請將此表的一部分移至伺服器 B。這個決定只是為了過早優化,因此我們決定將自己限制為多租戶方法。
例如,您可以在網站上閱讀有關多租戶的信息
SV有應用程式和訂閱者的概念。 應用程式是業務應用程式(例如 ERP 或會計)及其使用者和業務資料的特定安裝。 訂閱者是代表其應用程式在 SV 伺服器中註冊的組織或個人。 訂閱者可以註冊多個應用程序,並且這些應用程式可以相互交換訊息。 訂戶成為我們系統中的租戶。 來自多個訂閱者的消息可以位於一個實體資料庫中; 如果我們看到訂閱者已經開始產生大量流量,我們會將其移至單獨的實體資料庫(甚至單獨的資料庫伺服器)。
我們有一個主資料庫,其中儲存了路由表,其中包含有關所有訂戶資料庫位置的資訊。
為了防止主資料庫成為瓶頸,我們將路由表(和其他經常需要的資料)保存在快取中。
如果訂閱者的資料庫開始變慢,我們會將其切成內部分區。 在我們使用的其他項目中
由於丟失用戶訊息很糟糕,因此我們使用副本來維護資料庫。 同步和非同步副本的組合可讓您在主資料庫遺失時確保自己。 僅當主資料庫及其同步副本同時發生故障時才會發生訊息遺失。
如果同步副本遺失,非同步副本將變為同步副本。
如果主資料庫遺失,同步副本將成為主資料庫,非同步副本將成為同步副本。
Elasticsearch 用於搜尋
除此之外,由於 SV 也是一個信使,因此它需要快速、方便和靈活的搜索,同時考慮形態學並使用不精確的匹配。 我們決定不再重新發明輪子,而是使用基於該程式庫創建的免費搜尋引擎 Elasticsearch
在github上我們發現
「文本」這個字的字根也將被保留。 這種方法允許您在單字的開頭、中間和結尾處進行搜尋。
大局觀
重複文章開頭的圖片,但有解釋:
- Balancer暴露在網路上; 我們有 nginx,它可以是任何。
- Java 應用程式實例透過 Hazelcast 相互通訊。
- 為了使用網路套接字,我們使用
網狀 . - Java 應用程式是用 Java 8 編寫的,由捆綁包組成
操作系統Gi 。 這些計劃包括遷移到 Java 10 和過渡到模組。
開發與測試
在開發和測試 SV 的過程中,我們發現了我們使用的產品的許多有趣的功能。
負載測試和記憶體洩漏
每個SV版本的發布都涉及負載測試。 當滿足以下條件時,即成功:
- 測試了幾天,沒有服務故障
- 關鍵操作的響應時間沒有超過舒適的閾值
- 與先前版本相比效能下降不超過10%
我們用資料填充測試資料庫 - 為此,我們從生產伺服器接收有關最活躍訂閱者的信息,將其數字乘以 5(訊息數、討論數、用戶數)並以此方式進行測試。
我們以三種配置對互動系統進行負載測試:
- 壓力測試
- 僅連接
- 訂閱者註冊
在壓力測試期間,我們啟動了數百個線程,它們不停地載入系統:編寫訊息、建立討論、接收訊息清單。 我們模擬普通用戶的操作(獲取我的未讀訊息清單、寫信給某人)和軟體解決方案(傳輸不同配置的套件、處理警報)。
例如,壓力測試的一部分如下所示:
- 使用者登入
- 請求您未讀的討論
- 50% 的人可能會閱讀訊息
- 50% 可能發簡訊
- 下一個用戶:
- 有 20% 的機會創造新討論
- 隨機選擇其中的任何討論
- 進去
- 請求訊息、用戶設定文件
- 建立 XNUMX 個訊息,發送給此討論中的隨機用戶
- 留下討論
- 重複20次
- 註銷,返回腳本的開頭
- 聊天機器人進入系統(模擬來自應用程式程式碼的訊息傳遞)
- 有50%的幾率創建新的資料交換通道(特別討論)
- 50% 的可能性寫入任何現有管道是訊息
「僅連結」場景的出現是有原因的。 有一種情況:用戶已連接系統,但尚未參與其中。 每個用戶在早上 09:00 打開計算機,建立與伺服器的連接並保持沉默。 這些傢伙很危險,他們有很多 - 他們唯一的軟體包是 PING/PONG,但他們保持與伺服器的連接(他們無法保持連接 - 如果有新訊息怎麼辦)。 測試重現了半小時內大量此類用戶嘗試登入系統的情況。 它類似於壓力測試,但它的重點正是在第一個輸入上 - 這樣就不會出現故障(一個人不使用該系統,它已經脫落 - 很難想到更糟糕的事情)。
訂戶註冊腳本從第一次啟動時開始。 我們進行了壓力測試,並確信系統在通訊過程中不會變慢。 但用戶來了之後,由於超時,註冊開始失敗。 註冊時我們使用
我們用作負載生成器
這就是我們決定開始的地方。
幾乎在開始認真測試後,我們立即發現 JMeter 開始洩漏記憶體。
該插件是一個單獨的大故事;擁有 176 顆星,在 github 上有 132 個分支。 作者本人自 2015 年以來一直沒有承諾(我們在 2015 年採取了它,然後它沒有引起懷疑),幾個關於內存洩漏的 github 問題,7 個未關閉的拉取請求。
如果您決定使用此外掛程式進行負載測試,請注意以下討論:
- 在多執行緒環境下,使用了常規的LinkedList,結果是
NPE 在運行時。 這可以透過切換到 ConcurrentLinkedDeque 或同步區塊來解決。 我們為自己選擇了第一個選項(https://github.com/maciejzaleski/JMeter-WebSocketSampler/issues/43 ). - 記憶體洩漏;斷開連接時,連接資訊未刪除(
https://github.com/maciejzaleski/JMeter-WebSocketSampler/issues/44 ). - 在流模式下(當 websocket 在範例結束時未關閉,但稍後在計劃中使用時),響應模式不起作用(
https://github.com/maciejzaleski/JMeter-WebSocketSampler/issues/19 ).
這是 github 上的其中之一。 我們做了什麼:
- 已經採取
前叉 Elyran Kogan (@elyrank) – 它解決了問題 1 和 3 - 解決問題2
- 將碼頭從 9.2.14 更新到 9.3.12
- 在 ThreadLocal 中包裝了 SimpleDateFormat; SimpleDateFormat不是執行緒安全的,導致執行階段出現NPE
- 修復了另一個記憶體洩漏(斷開連接時連接被錯誤關閉)
但它卻在流動!
記憶開始耗盡不是一天,而是兩天。 絕對沒有時間了,所以我們決定啟動更少的線程,但在四個代理上。 這應該已經足夠至少一周了。
兩天過去了...
現在 Hazelcast 記憶體不足。 日誌顯示,經過幾天的測試,Hazelcast 開始抱怨記憶體不足,一段時間後叢集崩潰了,節點繼續一一死亡。 我們將 JVisualVM 連接到 hazelcast,看到了「鋸子」——它定期呼叫 GC,但無法清除記憶體。
事實證明,在hazelcast 3.4中,當刪除map/multiMap(map.destroy())時,記憶體並未完全釋放:
該錯誤現已在 3.5 中修復,但這在當時是一個問題。 我們建立了具有動態名稱的新 multiMap,並根據我們的邏輯刪除了它們。 程式碼看起來像這樣:
public void join(Authentication auth, String sub) {
MultiMap<UUID, Authentication> sessions = instance.getMultiMap(sub);
sessions.put(auth.getUserId(), auth);
}
public void leave(Authentication auth, String sub) {
MultiMap<UUID, Authentication> sessions = instance.getMultiMap(sub);
sessions.remove(auth.getUserId(), auth);
if (sessions.size() == 0) {
sessions.destroy();
}
}
沃茲奧夫:
service.join(auth1, "НОВЫЕ_СООБЩЕНИЯ_В_ОБСУЖДЕНИИ_UUID1");
service.join(auth2, "НОВЫЕ_СООБЩЕНИЯ_В_ОБСУЖДЕНИИ_UUID1");
multiMap 是為每個訂閱建立的,並在不需要時刪除。 我們決定開始地圖,鍵將是訂閱的名稱,值將是會話標識符(如果需要,您可以從中取得使用者識別碼)。
public void join(Authentication auth, String sub) {
addValueToMap(sub, auth.getSessionId());
}
public void leave(Authentication auth, String sub) {
removeValueFromMap(sub, auth.getSessionId());
}
圖表有所改善。
關於負載測試我們還了解了什麼?
關於我們使用 Hazelcast 的經驗
Hazelcast 對我們來說是一個新產品,我們從 3.4.1 版本開始使用它,現在我們的生產伺服器運行版本 3.9.2(在撰寫本文時,Hazelcast 的最新版本是 3.10)。
ID生成
我們從整數標識符開始。 假設我們需要另一個 Long 來建立一個新實體。 資料庫中的序列不合適,表涉及分片 - 原來DB1中有一條訊息ID=1,DB1中有一條訊息ID=2,你不能把這個ID放在Elasticsearch中,也不能放在Hazelcast中,但最糟糕的是,如果您想將兩個資料庫中的資料合併為一個(例如,決定一個資料庫足以滿足這些訂閱者的需求)。 你可以為 Hazelcast 添加幾個 AtomicLong 並將計數器保留在那裡,那麼取得新 ID 的效能就是incrementAndGet 加上向 Hazelcast 請求的時間。 但 Hazelcast 有更優化的東西 - FlakeIdGenerator。 當聯繫每個客戶時,他們會得到一個 ID 範圍,例如,第一個 - 從 1 到 10,第二個 - 從 000 到 10,等等。 現在,客戶端可以自行發出新的標識符,直到向其發出的範圍結束。 它工作得很快,但是當您重新啟動應用程式(和 Hazelcast 客戶端)時,一個新的序列開始 - 因此會跳過等等。 另外,開發人員並不真正理解為什麼ID是整數,但又如此不一致。 我們權衡了一切並改用 UUID。
順便說一句,對於那些想要像 Twitter 一樣的人,有這樣一個 Snowcast 庫 - 這是在 Hazelcast 之上的 Snowflake 實現。 您可以在這裡查看:
但我們還沒時間再做這件事。
TransactionalMap.replace
另一個驚喜:TransactionalMap.replace 不起作用。 這是一個測試:
@Test
public void replaceInMap_putsAndGetsInsideTransaction() {
hazelcastInstance.executeTransaction(context -> {
HazelcastTransactionContextHolder.setContext(context);
try {
context.getMap("map").put("key", "oldValue");
context.getMap("map").replace("key", "oldValue", "newValue");
String value = (String) context.getMap("map").get("key");
assertEquals("newValue", value);
return null;
} finally {
HazelcastTransactionContextHolder.clearContext();
}
});
}
Expected : newValue
Actual : oldValue
我必須使用 getForUpdate 來寫自己的替換:
protected <K,V> boolean replaceInMap(String mapName, K key, V oldValue, V newValue) {
TransactionalTaskContext context = HazelcastTransactionContextHolder.getContext();
if (context != null) {
log.trace("[CACHE] Replacing value in a transactional map");
TransactionalMap<K, V> map = context.getMap(mapName);
V value = map.getForUpdate(key);
if (oldValue.equals(value)) {
map.put(key, newValue);
return true;
}
return false;
}
log.trace("[CACHE] Replacing value in a not transactional map");
IMap<K, V> map = hazelcastInstance.getMap(mapName);
return map.replace(key, oldValue, newValue);
}
不僅測試常規資料結構,還測試它們的事務版本。 碰巧 IMap 可以工作,但 TransactionalMap 不再存在。
無需停機即可插入新 JAR
首先,我們決定在 Hazelcast 中記錄我們類別的物件。 例如,我們有一個Application類,我們要保存並讀取它。 節省:
IMap<UUID, Application> map = hazelcastInstance.getMap("application");
map.set(id, application);
我們讀:
IMap<UUID, Application> map = hazelcastInstance.getMap("application");
return map.get(id);
一切正常。 然後我們決定在 Hazelcast 中建立一個索引來搜尋:
map.addIndex("subscriberId", false);
當編寫新實體時,他們開始收到 ClassNotFoundException。 Hazelcast 嘗試添加到索引中,但對我們的類別一無所知,並且希望向其提供包含此類的 JAR。 我們就是這樣做的,一切正常,但是出現了一個新問題:如何在不完全停止叢集的情況下更新 JAR? Hazelcast 在逐節點更新期間不會取得新的 JAR。 此時我們決定無需索引搜尋即可生存。 畢竟,如果您使用 Hazelcast 作為鍵值存儲,那麼一切都會正常嗎? 並不真地。 IMap 和 TransactionalMap 的行為再次不同。 如果 IMap 不關心,TransactionalMap 就會拋出錯誤。
地圖。 我們寫入 5000 個物件並讀取它們。 一切都在預料之中。
@Test
void get5000() {
IMap<UUID, Application> map = hazelcastInstance.getMap("application");
UUID subscriberId = UUID.randomUUID();
for (int i = 0; i < 5000; i++) {
UUID id = UUID.randomUUID();
String title = RandomStringUtils.random(5);
Application application = new Application(id, title, subscriberId);
map.set(id, application);
Application retrieved = map.get(id);
assertEquals(id, retrieved.getId());
}
}
但它在事務中不起作用,我們得到一個 ClassNotFoundException:
@Test
void get_transaction() {
IMap<UUID, Application> map = hazelcastInstance.getMap("application_t");
UUID subscriberId = UUID.randomUUID();
UUID id = UUID.randomUUID();
Application application = new Application(id, "qwer", subscriberId);
map.set(id, application);
Application retrievedOutside = map.get(id);
assertEquals(id, retrievedOutside.getId());
hazelcastInstance.executeTransaction(context -> {
HazelcastTransactionContextHolder.setContext(context);
try {
TransactionalMap<UUID, Application> transactionalMap = context.getMap("application_t");
Application retrievedInside = transactionalMap.get(id);
assertEquals(id, retrievedInside.getId());
return null;
} finally {
HazelcastTransactionContextHolder.clearContext();
}
});
}
3.8中出現了User Class Deployment機制。 您可以指定一個主節點並更新其上的 JAR 檔案。
現在我們完全改變了我們的方法:我們自己將其序列化為 JSON 並將其保存在 Hazelcast 中。 Hazelcast 不需要知道我們類別的結構,我們可以在不停機的情況下進行更新。 域物件的版本控制由應用程式控制。 不同版本的應用程式可以同時運行,並且可能出現一種情況:新應用程式寫入具有新欄位的對象,但舊應用程式還不知道這些欄位。 同時,新應用程式讀取舊應用程式編寫的沒有新欄位的物件。 我們在應用程式中處理此類情況,但為了簡單起見,我們不更改或刪除字段,我們僅透過新增字段來擴展類別。
我們如何確保高性能
四次訪問 Hazelcast - 好,兩次訪問資料庫 - 差
存取快取獲取資料總是比存取資料庫更好,但您也不想儲存未使用的記錄。 我們將快取內容的決定留到開發的最後階段。 當新功能編碼時,我們開啟 PostgreSQL 中所有查詢的日誌記錄(log_min_duration_statement 為 0)並執行負載測試 20 分鐘。使用收集的日誌,pgFouine 和 pgBadger 等公用程式可以建立分析報告。 在報告中,我們主要尋找緩慢且頻繁的查詢。 對於慢速查詢,我們建立一個執行計劃(EXPLAIN)並評估是否可以加速這樣的查詢。 對相同輸入資料的頻繁請求非常適合快取。 我們嘗試保持查詢“扁平”,每個查詢一個表。
開發
SV 作為線上服務於 2017 年春季投入運營,作為獨立產品,SV 於 2017 年 XNUMX 月發布(當時處於測試版狀態)。
運行一年多來,CB線上服務運行未出現嚴重問題。 我們透過以下方式監控線上服務
SV 伺服器發行版以本機套件的形式提供:RPM、DEB、MSI。 另外,對於 Windows,我們提供單一 EXE 形式的安裝程序,用於在一台電腦上安裝伺服器、Hazelcast 和 Elasticsearch。 我們最初將此版本的安裝稱為「演示」版本,但現在很明顯這是最受歡迎的部署選項。
來源: www.habr.com