如何設(shè)計(jì)分布式系統(tǒng)開關(guān)

背景

在分布式系統(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í)資料。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容