背景
在分布式系統(tǒng)中為什么要使用開關(guān)?例如雙十一電商平臺(tái)需要做促銷活動(dòng),此時(shí)訂單量暴增,在下單環(huán)節(jié),可能需要調(diào)用A、B、C三個(gè)接口來完成,但是其實(shí)A和B是必須的,C只是附加的功能(例如在下單的時(shí)候獲取用戶常用地址,或者發(fā)個(gè)推送消息之類的),可有可無,在平時(shí)系統(tǒng)沒有壓力,在容量充足的情況下,調(diào)用下沒問題,但是在特殊節(jié)日的大促環(huán)節(jié),系統(tǒng)已經(jīng)滿負(fù)荷了,這時(shí)候其實(shí)完全可以不去調(diào)用C接口,怎么實(shí)現(xiàn)這個(gè)呢?改代碼重新發(fā)布?no,這樣不太敏捷,于是開關(guān)誕生了,開發(fā)人員只要簡單執(zhí)行一下命令或者點(diǎn)一下頁面,就可以關(guān)掉對于C接口的調(diào)用,在請求高峰過去之后,再把開關(guān)恢復(fù)回去即可。類似的使用場景還有A/B Test、灰度發(fā)布和數(shù)據(jù)的不停服切換等。
整體設(shè)計(jì)
高可用分布式開關(guān)設(shè)計(jì)?
一、 需求分析
開關(guān)服務(wù)的核心需求主要有以下幾點(diǎn):
?1. 支持開關(guān)的分布式化管理
? 開關(guān)統(tǒng)一管理,發(fā)布、更新等操作只需在中心服務(wù)器上進(jìn)行,一次操作,處處可用。
? 開關(guān)更新自動(dòng)化,當(dāng)開關(guān)發(fā)生變更,訂閱該開關(guān)的客戶端會(huì)自動(dòng)發(fā)現(xiàn)變更,進(jìn)而同步新值。
?2. 具有容災(zāi)機(jī)制,保證服務(wù)的高可用
? 服務(wù)集群的高可用,當(dāng)集群中的一臺(tái)server不可用了,client發(fā)現(xiàn)后可以自動(dòng)切換到其他server上進(jìn)行訪問。
? 客戶端具備容災(zāi)機(jī)制,當(dāng)開關(guān)中心完全不可用,可以在客戶端對開關(guān)進(jìn)行操作。
二、方案設(shè)計(jì)
針對此需求分析,我們抽象成了兩大塊分別是配置中心和SDK,整體架構(gòu)如下:
各個(gè)系統(tǒng)模塊介紹:
配置中心:
配置中心在此處提供開關(guān)的統(tǒng)一管理
zookeeper:
分布式開關(guān)統(tǒng)一注冊中心,主要提供變更通知服務(wù),客戶端通過訂閱開關(guān)節(jié)點(diǎn),實(shí)時(shí)獲取開關(guān)變更信息,從而同步更新到本地緩存
SDK:
client端獲取開關(guān),以及監(jiān)聽開關(guān)的變化從而更新本地緩存。
配置中心設(shè)計(jì)
配置中心在此處的作用有如下幾點(diǎn):
提供開關(guān)統(tǒng)一管理,包含開關(guān)發(fā)布、更新、查詢等基本服務(wù)
操作開關(guān)的日志,比如誰在某一時(shí)刻將開關(guān)從關(guān)閉狀態(tài)修改為打開狀態(tài)。
系統(tǒng)權(quán)限控制,只有擁有相關(guān)權(quán)限的人才能操作開關(guān)。
配置中心整體設(shè)計(jì)如下:
其中db主要保存了開關(guān)信息,以及日志信息。
zk主要用來創(chuàng)建開關(guān)和監(jiān)聽開關(guān)的變化。
配置中心的設(shè)計(jì)看似簡單,其實(shí)也需要注意以下幾點(diǎn):
1. 開關(guān)的命名重復(fù)問題
在設(shè)計(jì)系統(tǒng)的時(shí)候,開關(guān)是否要共享給所有系統(tǒng),還是其中某一個(gè)系統(tǒng),如果共享給所有系統(tǒng),那么有權(quán)限的人對開關(guān)命名的時(shí)候難免會(huì)重復(fù),針對此,我們設(shè)計(jì)了appid的概念,一個(gè)系統(tǒng)對應(yīng)一個(gè)appid,在一個(gè)appid內(nèi)開關(guān)名稱不允許有重復(fù),只有該appid的owner才有權(quán)限對該appid的下的開關(guān)做操作。
2. 開關(guān)的分類
我們可以將開關(guān)分為三大類,分別是功能開關(guān)、降級(jí)開關(guān)、灰度開關(guān):
功能開關(guān)
針對某一個(gè)功能是否打開,例如在訂單下單的時(shí)候需要獲取下單用戶的歷史換綁手機(jī)號(hào)信息,但是由于B系統(tǒng)只是提供了接口定義,實(shí)際業(yè)務(wù)還未開發(fā)完成,A系統(tǒng)可以先提前開發(fā)并上線,待B系統(tǒng)上線之后,A系統(tǒng)將該功能開關(guān)打開。
降級(jí)開關(guān)
典型的應(yīng)用場景是電商做促銷的時(shí)候,比如雙十一電商做促銷,用戶下單的時(shí)候獲取用戶歷史常用地址,因?yàn)殡p十一系統(tǒng)已經(jīng)達(dá)到負(fù)荷,為了系統(tǒng)性能,將該業(yè)務(wù)邏輯降級(jí)?;蛘逜系統(tǒng)調(diào)用B系統(tǒng),由于B系統(tǒng)整體宕機(jī),為了不影響A系統(tǒng)繼續(xù)運(yùn)行,可以手動(dòng)將B系統(tǒng)降級(jí)等。
灰度開關(guān)
針對某一功能做灰度,例如我們需要針對刷單用戶在下單過程中做攔截,為此我們在下單階段做了一套黑白名單處理,但是我們也無法知曉該套黑白名單的正確率多少,為了避免造成誤攔,我們需要對該功能做灰度采樣,以便及時(shí)調(diào)整我們的黑白名單邏輯。通常的灰度策略為 1% 灰度,10%灰度,30%灰度,50%。。。
3. zk中開關(guān)設(shè)計(jì)
zk中的設(shè)計(jì)結(jié)構(gòu)為路徑格式,我們將/appid 設(shè)置為根路徑,例如appid為order的根路徑為/order,則在該appid下設(shè)置的user_open開關(guān)的路徑則為:
/order/user_open ,所以我們設(shè)計(jì)的路徑公式如下:
/appid/switch
部分頁面效果如下:
SDK設(shè)計(jì)
SDK主要是以jar的形式嵌入在client端的,它的作用主要是在client端獲取開關(guān),以及監(jiān)聽開關(guān)的變化從而更新本地緩存。SDK整體設(shè)計(jì)如下所示:
我們使用Curator來操作zk,因?yàn)樗啾仍膠k 客戶端確實(shí)好用不少,這里不做過多展開,為了提高系統(tǒng)性能我們將開關(guān)信息緩存在本地內(nèi)存,這樣做的目的是提升系統(tǒng)的性能,所以獲取開關(guān)的流程圖如下:
1. 監(jiān)聽開關(guān)變化
如果開關(guān)發(fā)生改變,我們需要將開關(guān)變化的信息載入到本地,監(jiān)聽代碼如下:
1 private void nodeListener(final String key) {
2 final NodeCache nodeCache = new NodeCache(client, basePath + "/" + key);
3 try {
4 nodeCache.start();
5 nodeCache.getListenable().addListener(new NodeCacheListener() {
6
7 public void nodeChanged() throws Exception {
8 String msg = new String(nodeCache.getCurrentData().getData());
9 System.out.println("監(jiān)聽事件觸發(fā)");
10 System.out.println("重新獲得節(jié)點(diǎn)內(nèi)容為:" + msg);
11 //加入到本地緩存
12 dataMap.put(key, msg);
13 }
14 });
15 } catch (Exception e) {
16 e.printStackTrace();
17 }
18
19
20 }
2. 降級(jí)開關(guān)
降級(jí)開關(guān)和功能開關(guān)在底層實(shí)現(xiàn)上是一樣的,就是從zk獲取value為true的時(shí)候,是打開狀態(tài)的,代碼如下:
1 /**
2 * 獲取開關(guān),默認(rèn)是打開的
3 *
4 * @param switchKey
5 * @return
6 */
7 public boolean getSwitch(String switchKey) {
8 try {
9 String dataMsg = getDataMsg(switchKey);
10 if (isEmpty(dataMsg)) {
11 return true;
12 }
13
14 return Boolean.parseBoolean(dataMsg);
15 } catch (Exception e) {
16 e.printStackTrace();
17 }
18
19 return true;
20 }
其中g(shù)etDataMsg方法封裝了本地緩存的調(diào)用,具體代碼如下:
1 private String getDataMsg(String key) {
2 byte[] data;
3 try {
4 //先從本地緩存中找
5 String msg = dataMap.get(key);
6 if (!isEmpty(msg)) {
7 return msg;
8 }
9
10 //本地緩存沒有,則從zk中去查找
11 data = dataBuilder.forPath(basePath + "/" + key);
12 if (data != null) {
13 String dataMsg = new String(data);
14 //重新塞入緩存
15 dataMap.put(key, dataMsg);
16 nodeListener(key);
17 return dataMsg;
18 }
19 } catch (Exception e) {
20 e.printStackTrace();
21 }
22
23 return null;
24 }
降級(jí)開關(guān)和功能開關(guān)的代碼完成后,接下來是一段測試demo,測試開關(guān)是否可以正常使用,代碼如下:
1public class SwitchDemo {
2
3 public static void main(String[] args)throws Exception {
4
5 //user_open 開關(guān) 打開
6 if(SwitchHandler.config().getSwitch("user_open")){
7 System.out.println("exe user open switch 1");
8 }
9
10 Thread.sleep(10000);
11
12
13 //user_open 開關(guān) 關(guān)閉
14 if (SwitchHandler.config().getSwitch("user_open")){
15 System.out.println("exe user open switch 2");
16 }else{
17
18 System.out.println("exe user not open");
19 }
20
21
22 Thread.sleep(1000000000);
23 }
24}
接下來在本地啟動(dòng)一個(gè)zk單機(jī)服務(wù),進(jìn)入到zk的安裝目錄 ,啟動(dòng)命令如下:
1./zkServer.sh start
啟動(dòng)一個(gè)客戶端,創(chuàng)建一個(gè)開關(guān)user_open value為true,假設(shè)我這個(gè)服務(wù)的appid叫sky,那么我應(yīng)該先創(chuàng)建/sky 這個(gè)路徑,接著創(chuàng)建,/sky/user_open這個(gè)路徑,命令如下:
1create /sky 1
2create /sky/user_open true
接下來我們啟動(dòng)SwitchDemo測試類,在代碼走到第一次sleep階段,我們立馬將user_open 這個(gè)值修改為false,修改zk的命令為:
1set /sky/user_open false
最終打印結(jié)果如下:
從結(jié)果可以看出,第一次執(zhí)行的時(shí)候,由于user_open的value為true,所以
日志 exe user open switch 1 打印出來了,其次監(jiān)聽的日志也打印出來了,當(dāng)代碼執(zhí)行到第十行的時(shí)候,我們將user_open的value修改為false,此時(shí)監(jiān)聽的日志監(jiān)聽到開關(guān)發(fā)生了變化,并將本地內(nèi)存的開關(guān)地址修改了false,最后執(zhí)行第14行代碼的時(shí)候,由于開關(guān)是關(guān)閉狀態(tài),所以走到了第18行的邏輯。
3. 灰度開關(guān)設(shè)計(jì)
灰度開關(guān)主要針對某一個(gè)功能來進(jìn)行灰度,那么就需要有一個(gè)灰度策略的概念,比如設(shè)置的是灰度10%,此時(shí)有1000個(gè)請求進(jìn)來,應(yīng)該只有100個(gè)左右的請求是命中這段邏輯,在微服務(wù)架構(gòu)中,服務(wù)與服務(wù)之間的調(diào)用都會(huì)透傳一個(gè)requestId(請求id),因此將requestId 當(dāng)做灰度的主體是最適合不過了,簡單的灰度算法可以將requestId 進(jìn)行hash 取模100 然后跟設(shè)置的灰度值進(jìn)行比較即可。代碼如下:
1 /**
2 * 灰度開關(guān)
3 *
4 * @param switchKey
5 * @param strategyId 灰度策略,可以傳入requestId,手機(jī)號(hào)來進(jìn)行灰度
6 * @return
7 */
8 public boolean getGrayscaleSwitch(String switchKey, String strategyId) {
9
10 int value = getInt(switchKey, 100);
11
12 int hash = strategyId.hashCode();
13
14 return Math.abs(hash) % 100 <= value;
15 }
灰度不是一個(gè)精確值,請求量越大灰度的越精確,因此接下來我們的測試demo,會(huì)模擬10000條請求,如果命中了大約1000條左右,那么說明我們的灰度算法沒啥問題,我們將當(dāng)前時(shí)間戳當(dāng)做requestId(當(dāng)然實(shí)際不要這么做,應(yīng)該用微服務(wù)之間透傳的requestId,這里只是為了測試)
首先設(shè)置一個(gè)灰度開關(guān)user_gary,value為10(代表灰度10%,最大為100)
1create /sky/user_gary 10
測試代碼如下:
1 //灰度10%開關(guān)
2 int grayCount=0;//
3 for (int i=0;i<10000;i++){
4 String requestId = System.currentTimeMillis()+"-"+i;
5 if (SwitchHandler.config().getGrayscaleSwitch("user_gary",requestId)){
6 grayCount++;
7 }
8 }
9
10 System.out.println("進(jìn)入灰度開關(guān)的次數(shù)為:"+grayCount);
運(yùn)行結(jié)果:
我們看到10000次請求,命中了1176次,大約灰度10%,說明灰度起作用了。
SDK完整代碼
pom依賴:
1 <dependency>
2 <groupId>org.apache.curator</groupId>
3 <artifactId>curator-recipes</artifactId>
4 <version>4.0.1</version>
5 </dependency>
SDK完整代碼:
1package com.wuzy.myswitch;
2
3import org.apache.curator.RetryPolicy;
4import org.apache.curator.framework.CuratorFramework;
5import org.apache.curator.framework.CuratorFrameworkFactory;
6import org.apache.curator.framework.api.GetDataBuilder;
7import org.apache.curator.framework.recipes.cache.NodeCache;
8import org.apache.curator.framework.recipes.cache.NodeCacheListener;
9import org.apache.curator.retry.ExponentialBackoffRetry;
10
11import java.util.Map;
12import java.util.concurrent.ConcurrentHashMap;
13
14public class SwitchHandler {
15
16 private String basePath = "/sky";
17 private GetDataBuilder dataBuilder;
18
19 private Map<String, String> dataMap = new ConcurrentHashMap<String, String>();
20
21 private SwitchHandler() {
22 RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
23 client = CuratorFrameworkFactory.builder()
24 .connectString("127.0.0.1:2181")
25 .retryPolicy(retryPolicy)
26 .sessionTimeoutMs(6000)
27 .connectionTimeoutMs(3000)
28 .build();
29 client.start();
30
31 dataBuilder = client.getData();
32
33 //TODO 后期這里最好將數(shù)據(jù)庫中的開關(guān)加載出來,從zk中找出放入本地緩存中,加快查詢速度
34 }
35
36 private static class SwitchHandlerHolder {
37 private static final SwitchHandler switchHandler = new SwitchHandler();
38 }
39
40 public static SwitchHandler config() {
41 return SwitchHandlerHolder.switchHandler;
42 }
43
44 private CuratorFramework client;
45
46
47 /**
48 * 獲取開關(guān),默認(rèn)是打開的
49 *
50 * @param switchKey
51 * @return
52 */
53 public boolean getSwitch(String switchKey) {
54 try {
55 String dataMsg = getDataMsg(switchKey);
56 if (isEmpty(dataMsg)) {
57 return true;
58 }
59
60 return Boolean.parseBoolean(dataMsg);
61 } catch (Exception e) {
62 e.printStackTrace();
63 }
64
65 return true;
66 }
67
68 /**
69 * 灰度開關(guān)
70 *
71 * @param switchKey
72 * @param strategyId 灰度策略,可以傳入requestId,手機(jī)號(hào)來進(jìn)行灰度
73 * @return
74 */
75 public boolean getGrayscaleSwitch(String switchKey, String strategyId) {
76
77 int value = getInt(switchKey, 100);
78
79 int hash = strategyId.hashCode();
80
81 return Math.abs(hash) % 100 <= value;
82 }
83
84
85 public int getInt(String key, int defaultValue) {
86 try {
87 String dataMsg = getDataMsg(key);
88 if (isEmpty(dataMsg)) {
89 return defaultValue;
90 }
91 return Integer.parseInt(dataMsg);
92
93 } catch (Exception e) {
94 e.printStackTrace();
95 }
96
97 return defaultValue;
98 }
99
100
101 private boolean isEmpty(String msg) {
102 return msg == null || msg.trim().equals("");
103 }
104
105 private String getDataMsg(String key) {
106 byte[] data;
107 try {
108 //先從本地緩存中找
109 String msg = dataMap.get(key);
110 if (!isEmpty(msg)) {
111 return msg;
112 }
113
114 //本地緩存沒有,則從zk中去查找
115 data = dataBuilder.forPath(basePath + "/" + key);
116 if (data != null) {
117 String dataMsg = new String(data);
118 //重新塞入緩存
119 dataMap.put(key, dataMsg);
120 nodeListener(key);
121 return dataMsg;
122 }
123 } catch (Exception e) {
124 e.printStackTrace();
125 }
126
127 return null;
128 }
129
130
131 private void nodeListener(final String key) {
132 final NodeCache nodeCache = new NodeCache(client, basePath + "/" + key);
133 try {
134 nodeCache.start();
135 nodeCache.getListenable().addListener(new NodeCacheListener() {
136
137 public void nodeChanged() throws Exception {
138 String msg = new String(nodeCache.getCurrentData().getData());
139 System.out.println("監(jiān)聽事件觸發(fā)");
140 System.out.println("重新獲得節(jié)點(diǎn)內(nèi)容為:" + msg);
141 //加入到本地緩存
142 dataMap.put(key, msg);
143 }
144 });
145 } catch (Exception e) {
146 e.printStackTrace();
147 }
148
149
150 }
151}
如果你想學(xué)好JAVA這門技術(shù),也想在IT行業(yè)拿高薪,可以參加我們的訓(xùn)練營課程,選擇最適合自己的課程學(xué)習(xí),技術(shù)大牛親授,8個(gè)月后,進(jìn)入名企拿高薪。我們的課程內(nèi)容有:Java工程化、高性能及分布式、高性能、深入淺出。高架構(gòu)。性能調(diào)優(yōu)、Spring,MyBatis,Netty源碼分析和大數(shù)據(jù)等多個(gè)知識(shí)點(diǎn)。如果你想拿高薪的,想學(xué)習(xí)的,想就業(yè)前景好的,想跟別人競爭能取得優(yōu)勢的,想進(jìn)阿里面試但擔(dān)心面試不過的,你都可以來,q群號(hào)為:180705916 進(jìn)群免費(fèi)領(lǐng)取學(xué)習(xí)資料。