95 - 實(shí)戰(zhàn)之通用的接口冪等框架(實(shí)現(xiàn)篇)

前面我們講解了冪等框架的設(shè)計思路晃琳。在正常情況下,冪等框架的處理流程是比較簡單的姆涩。調(diào)用方生成冪等號,傳遞給實(shí)現(xiàn)方惭每,實(shí)現(xiàn)方記錄冪等號或者用冪等號判重骨饿。但是,冪等框架要處理的異常情況很多台腥,這也是設(shè)計的復(fù)雜之處和難點(diǎn)之處宏赘。比如,代碼運(yùn)行異常黎侈、業(yè)務(wù)系統(tǒng)宕機(jī)察署、冪等框架異常。

雖然冪等框架要處理的異常很多峻汉,但考慮到開發(fā)成本以及簡單易用性贴汪,我們對某些異常的處理在工程上做了妥協(xié),交由業(yè)務(wù)系統(tǒng)或者人工介入處理休吠。這樣就大大簡化了冪等框架開發(fā)的復(fù)雜度和難度扳埂。

本文,我們針對冪等框架的設(shè)計思路瘤礁,講解如何編碼實(shí)現(xiàn)阳懂。跟限流框架的講解相同,對于冪等框架柜思,我們也會還原它的整個開發(fā)過程岩调,從 V1 版本需求、最小原型代碼講起赡盘,然后講解如何 review 代碼發(fā)現(xiàn)問題号枕、重構(gòu)代碼解決問題,最終得到一份易讀亡脑、易擴(kuò)展堕澄、易維護(hù)邀跃、靈活、可測試的高質(zhì)量代碼實(shí)現(xiàn)

V1 版本功能需求

  • 冪等框架的設(shè)計思路是很簡單的蛙紫,主要包含下面這樣兩個主要的功能開發(fā)點(diǎn):
    • 實(shí)現(xiàn)生成冪等號的功能拍屑;
    • 實(shí)現(xiàn)存儲、查詢坑傅、刪除冪等號的功能僵驰。
  1. 我們先來看,如何生成冪等號唁毒。
  • 冪等號用來標(biāo)識兩個接口請求是否是同一個業(yè)務(wù)請求蒜茴,換句話說,兩個接口請求是否是重試關(guān)系浆西,而非獨(dú)立的兩個請求粉私。接口調(diào)用方需要在發(fā)送接口請求的同時,將冪等號一塊傳遞給接口實(shí)現(xiàn)方近零。那如何來生成冪等號呢诺核?一般有兩種生成方式。一種方式是集中生成并且分派給調(diào)用方久信,另一種方式是直接由調(diào)用方生成窖杀。
  • 對于第一種生成方式,我們需要部署一套冪等號的生成系統(tǒng)裙士,并且提供相應(yīng)的遠(yuǎn)程接口(Restful 或者 RPC 接口)入客,調(diào)用方通過調(diào)用遠(yuǎn)程接口來獲取冪等號。這樣做的好處是腿椎,對調(diào)用方完全隱藏了冪等號的實(shí)現(xiàn)細(xì)節(jié)桌硫。當(dāng)我們需要改動冪等號的生成算法時,調(diào)用方不需要改動任何代碼酥诽。
  • 對于第二種生成方式鞍泉,調(diào)用方按照跟接口實(shí)現(xiàn)方預(yù)先商量好的算法,自己來生成冪等號肮帐。這種實(shí)現(xiàn)方式的好處在于咖驮,不用像第一種方式那樣調(diào)用遠(yuǎn)程接口,所以執(zhí)行效率更高训枢。但是托修,一旦需要修改冪等號的生成算法,就需要修改每個調(diào)用方的代碼恒界。
  • 并且睦刃,每個調(diào)用方自己實(shí)現(xiàn)冪等號的生成算法也會有問題。一方面十酣,重復(fù)開發(fā)涩拙,違反 DRY 原則际长。另一方面,工程師的開發(fā)水平層次不齊兴泥,代碼難免會有 bug工育。除此之外,對于復(fù)雜的冪等號生成算法搓彻,比如依賴外部系統(tǒng) Redis 等如绸,顯然更加適合上一種實(shí)現(xiàn)方式,可以避免調(diào)用方為了使用冪等號引入新的外部系統(tǒng)旭贬。
  • 權(quán)衡來講怔接,既考慮到生成冪等號的效率,又考慮到代碼維護(hù)的成本稀轨,我們選擇第二種實(shí)現(xiàn)方式扼脐,并且在此基礎(chǔ)上做些改進(jìn),由冪等框架來統(tǒng)一提供冪等號生成算法的代碼實(shí)現(xiàn)靶端,并封裝成開發(fā)類庫谎势,提供給各個調(diào)用方復(fù)用。除此之外杨名,我們希望生成冪等號的算法盡可能的簡單,不依賴其他外部系統(tǒng)猖毫。
  • 實(shí)際上台谍,對于冪等號的唯一要求就是全局唯一。全局唯一 ID 的生成算法有很多吁断。比如趁蕊,簡單點(diǎn)的有取 UUID,復(fù)雜點(diǎn)的可以把應(yīng)用名拼接在 UUID 上仔役,方便做問題排查掷伙。總體上來講又兵,冪等號的生成算法并不難任柜。
  1. 我們再來看,如何實(shí)現(xiàn)冪等號的存儲沛厨、查詢和刪除宙地。
  • 從現(xiàn)在的需求來看,冪等號只是為了判重逆皮。在數(shù)據(jù)庫中宅粥,我們只需要存儲一個冪等號就可以,不需要太復(fù)雜的存儲結(jié)構(gòu)电谣,所以秽梅,我們不選擇使用復(fù)雜的關(guān)系型數(shù)據(jù)庫抹蚀,而是選擇使用更加簡單的、讀寫更加快速的鍵值數(shù)據(jù)庫企垦,比如 Redis环壤。
  • 在冪等判重邏輯中,我們需要先檢查冪等號是否存在竹观。如果沒有存在镐捧,再將冪等號存儲進(jìn) Redis。多個線程(同一個業(yè)務(wù)實(shí)例的多個線程)或者多進(jìn)程(多個業(yè)務(wù)實(shí)例)同時執(zhí)行剛剛的“檢查 - 設(shè)置”邏輯時臭增,就會存在競爭關(guān)系(競態(tài)懂酱,race condition)。比如誊抛,A 線程檢查冪等號不存在列牺,在 A 線程將冪等號存儲進(jìn) Redis 之前,B 線程也檢查冪等號不存在拗窃,這樣就會導(dǎo)致業(yè)務(wù)被重復(fù)執(zhí)行瞎领。為了避免這種情況發(fā)生,我們要給“檢查 - 設(shè)置”操作加鎖随夸,讓同一時間只有一個線程能執(zhí)行九默。除此之外,為了避免多進(jìn)程之間的競爭宾毒,普通的線程鎖還不起作用驼修,我們需要分布式鎖。
  • 引入分布式鎖會增加開發(fā)的難度和復(fù)雜度诈铛,而 Redis 本身就提供了把“檢查 - 設(shè)置”操作作為原子操作執(zhí)行的命令:setnx(key, value)乙各。它先檢查 key 是否存在,如果存在幢竹,則返回結(jié)果 0耳峦;如果不存在,則將 key 值存下來焕毫,并將值設(shè)置為 value蹲坷,返回結(jié)果 1。因?yàn)?Redis 本身是單線程執(zhí)行命令的咬荷,所以不存在剛剛講到的并發(fā)問題冠句。

最小原型代碼實(shí)現(xiàn)

  • V1 版本要實(shí)現(xiàn)的功能和實(shí)現(xiàn)思路,現(xiàn)在已經(jīng)很明確了⌒移梗現(xiàn)在懦底,我們來看下具體的代碼實(shí)現(xiàn)。還是跟限流框架同樣的實(shí)現(xiàn)方法,我們先不考慮設(shè)計和代碼質(zhì)量聚唐,怎么簡單怎么來丐重,先寫出 MVP 代碼,然后基于這個最簡陋的版本做優(yōu)化重構(gòu)杆查。
  • V1 版本的功能非常簡單扮惦,我們用一個類就能搞定,代碼如下所示亲桦。只用了不到 30 行代碼崖蜜,就搞定了一個框架,是不是覺得有點(diǎn)不可思議客峭。對于這段代碼豫领,你可以先思考下,有哪些值得優(yōu)化的地方舔琅。
public class Idempotence {
  private JedisCluster jedisCluster;
  public Idempotence(String redisClusterAddress, GenericObjectPoolConfig config) {
    String[] addressArray= redisClusterAddress.split(";");
    Set<HostAndPort> redisNodes = new HashSet<>();
    for (String address : addressArray) {
      String[] hostAndPort = address.split(":");
      redisNodes.add(new HostAndPort(hostAndPort[0], Integer.valueOf(hostAndPort[1])));
    }
    this.jedisCluster = new JedisCluster(redisNodes, config);
  }
  
  public String genId() {
    return UUID.randomUUID().toString();
  }
  public boolean saveIfAbsent(String idempotenceId) {
    Long success = jedisCluster.setnx(idempotenceId, "1");
    return success == 1;
  }
  public void delete(String idempotenceId) {
    jedisCluster.del(idempotenceId);
  }
}

Review 最小原型代碼

  • 盡管 MVP 代碼很少等恐,但仔細(xì)推敲,也有很多值得優(yōu)化的地方”蛤荆現(xiàn)在课蔬,我們就站在 Code Reviewer 的角度,分析一下這段代碼郊尝。部分意見放到代碼注釋中了二跋,你可以對照著代碼一塊看下。
public class Idempotence {
  // comment-1: 如果要替換存儲方式流昏,是不是很麻煩呢同欠?
  private JedisCluster jedisCluster;
  // comment-2: 如果冪等框架要跟業(yè)務(wù)系統(tǒng)復(fù)用jedisCluster連接呢?
  // comment-3: 是不是應(yīng)該注釋說明一下redisClusterAddress的格式横缔,以及config是否可以傳遞進(jìn)null呢?
  public Idempotence(String redisClusterAddress, GenericObjectPoolConfig config) {
    // comment-4: 這段邏輯放到構(gòu)造函數(shù)里衫哥,不容易寫單元測試呢
    String[] addressArray= redisClusterAddress.split(";");
    Set<HostAndPort> redisNodes = new HashSet<>();
    for (String address : addressArray) {
      String[] hostAndPort = address.split(":");
      redisNodes.add(new HostAndPort(hostAndPort[0], Integer.valueOf(hostAndPort[1])));
    }
    this.jedisCluster = new JedisCluster(redisNodes, config);
  }
  
  // comment-5: generateId()是不是比縮寫要好點(diǎn)茎刚?
  // comment-6: 根據(jù)接口隔離原則,這個函數(shù)跟其他函數(shù)的使用場景完全不同撤逢,這個函數(shù)主要用在調(diào)用方膛锭,其他函數(shù)用在實(shí)現(xiàn)方,是不是應(yīng)該分別放到兩個類中蚊荣?
  public String genId() {
    return UUID.randomUUID().toString();
  }
  // comment-7: 返回值的意義是不是應(yīng)該注釋說明一下初狰?
  public boolean saveIfAbsent(String idempotenceId) {
    Long success = jedisCluster.setnx(idempotenceId, "1");
    return success == 1;
  }
  public void delete(String idempotenceId) {
    jedisCluster.del(idempotenceId);
  }
}
  • 總結(jié)一下,MVP 代碼主要涉及下面這樣幾個問題互例。
    • 代碼可讀性問題:有些函數(shù)的參數(shù)和返回值的格式和意義不夠明確奢入,需要注釋補(bǔ)充解釋一下。genId() 函數(shù)使用了縮寫媳叨,全拼 generateId() 可能更好些腥光!
    • 代碼可擴(kuò)展性問題:按照現(xiàn)在的代碼實(shí)現(xiàn)方式关顷,如果改變冪等號的存儲方式和生成算法,代碼修改起來會比較麻煩武福。除此之外议双,基于接口隔離原則,我們應(yīng)該將 genId() 函數(shù)跟其他函數(shù)分離開來捉片,放到兩個類中平痰。獨(dú)立變化,隔離修改伍纫,更容易擴(kuò)展宗雇!
    • 代碼可測試性問題:解析 Redis Cluster 地址的代碼邏輯較復(fù)雜,但因?yàn)榉诺搅藰?gòu)造函數(shù)中翻斟,無法對它編寫單元測試逾礁。
    • 代碼靈活性問題:業(yè)務(wù)系統(tǒng)有可能希望冪等框架復(fù)用已經(jīng)建立好的 jedisCluster,而不是單獨(dú)給冪等框架創(chuàng)建一個 jedisCluster访惜。

重構(gòu)最小原型代碼

  • 問題找到了嘹履,修改起來就容易多了。針對剛剛羅列的幾個問題债热,我們對 MVP 代碼進(jìn)行重構(gòu)砾嫉,重構(gòu)之后的代碼如下所示。
// 代碼目錄結(jié)構(gòu)
com.xzg.cd.idempotence
 --Idempotence
 --IdempotenceIdGenerator(冪等號生成類)
 --IdempotenceStorage(接口:用來讀寫冪等號)
 --RedisClusterIdempotenceStorage(IdempotenceStorage的實(shí)現(xiàn)類)
// 每個類的代碼實(shí)現(xiàn)
public class Idempotence {
  private IdempotenceStorage storage;
  public Idempotence(IdempotenceStorage storage) {
    this.storage = storage;
  }
  public boolean saveIfAbsent(String idempotenceId) {
    return storage.saveIfAbsent(idempotenceId);
  }
  public void delete(String idempotenceId) {
    storage.delete(idempotenceId);
  }
}
public class IdempotenceIdGenerator {
  public String generateId() {
    return UUID.randomUUID().toString();
  }
}
public interface IdempotenceStorage {
  boolean saveIfAbsent(String idempotenceId);
  void delete(String idempotenceId);
}
public class RedisClusterIdempotenceStorage implements IdempotenceStorage {
  private JedisCluster jedisCluster;
  /**
   * Constructor
   * @param redisClusterAddress the format is 128.91.12.1:3455;128.91.12.2:3452;289.13.2.12:8978
   * @param config should not be null
   */
  public RedisIdempotenceStorage(String redisClusterAddress, GenericObjectPoolConfig config) {
    Set<HostAndPort> redisNodes = parseHostAndPorts(redisClusterAddress);
    this.jedisCluster = new JedisCluster(redisNodes, config);
  }
  public RedisIdempotenceStorage(JedisCluster jedisCluster) {
    this.jedisCluster = jedisCluster;
  }
  /**
   * Save {@idempotenceId} into storage if it does not exist.
   * @param idempotenceId the idempotence ID
   * @return true if the {@idempotenceId} is saved, otherwise return false
   */
  public boolean saveIfAbsent(String idempotenceId) {
    Long success = jedisCluster.setnx(idempotenceId, "1");
    return success == 1;
  }
  public void delete(String idempotenceId) {
    jedisCluster.del(idempotenceId);
  }
  @VisibleForTesting
  protected Set<HostAndPort> parseHostAndPorts(String redisClusterAddress) {
    String[] addressArray= redisClusterAddress.split(";");
    Set<HostAndPort> redisNodes = new HashSet<>();
    for (String address : addressArray) {
      String[] hostAndPort = address.split(":");
      redisNodes.add(new HostAndPort(hostAndPort[0], Integer.valueOf(hostAndPort[1])));
    }
    return redisNodes;
  }
}
  • 在代碼可讀性方面窒篱,我們對構(gòu)造函數(shù)焕刮、saveIfAbsense() 函數(shù)的參數(shù)和返回值做了注釋,并且將 genId() 函數(shù)改為全拼 generateId()墙杯。不過配并,對于這個函數(shù)來說,縮寫實(shí)際上問題也不大高镐。
  • 在代碼可擴(kuò)展性方面溉旋,我們按照基于接口而非實(shí)現(xiàn)的編程原則,將冪等號的讀寫?yīng)毩⒊鰜砑邓瑁O(shè)計成 IdempotenceStorage 接口和 RedisClusterIdempotenceStorage 實(shí)現(xiàn)類观腊。RedisClusterIdempotenceStorage 實(shí)現(xiàn)了基于 Redis Cluster 的冪等號讀寫。如果我們需要替換新的冪等號讀寫方式算行,比如基于單個 Redis 而非 Redis Cluster梧油,我們就可以再定義一個實(shí)現(xiàn)了 IdempotenceStorage 接口的實(shí)現(xiàn)類:RedisIdempotenceStorage。
  • 除此之外州邢,按照接口隔離原則儡陨,我們將生成冪等號的代碼抽離出來,放到 IdempotenceIdGenerator 類中。這樣迄委,調(diào)用方只需要依賴這個類的代碼就可以了褐筛。冪等號生成算法的修改,跟冪等號存儲邏輯的修改叙身,兩者完全獨(dú)立,一個修改不會影響另外一個信轿。
  • 在代碼可測試性方面晃痴,我們把原本放在構(gòu)造函數(shù)中的邏輯抽離出來财忽,放到了 parseHostAndPorts() 函數(shù)中紧唱。這個函數(shù)本應(yīng)該是 Private 訪問權(quán)限的,但為了方便編寫單元測試隶校,我們把它設(shè)置為成了 Protected 訪問權(quán)限漏益,并且通過注解 @VisibleForTesting 做了標(biāo)明。
  • 在代碼靈活性方面深胳,為了方便復(fù)用業(yè)務(wù)系統(tǒng)已經(jīng)建立好的 jedisCluster绰疤,我們提供了一個新的構(gòu)造函數(shù),支持業(yè)務(wù)系統(tǒng)直接傳遞 jedisCluster 來創(chuàng)建 Idempotence 對象舞终。

小結(jié)

  • 我們用很大的篇幅在講需求和設(shè)計轻庆,特別是設(shè)計的緣由。而真正到了實(shí)現(xiàn)環(huán)節(jié)敛劝,我們只用了不到 30 行代碼余爆,就實(shí)現(xiàn)了冪等框架。這就很好體現(xiàn)了“思從深而行從簡”的道理夸盟。對于不到 30 行代碼龙屉,很多人覺得不大可能有啥優(yōu)化空間了,但我們今天還是提出了 7 個優(yōu)化建議满俗,并且對代碼結(jié)構(gòu)做了比較大的調(diào)整。這說明作岖,只要仔細(xì)推敲唆垃,再小的代碼都有值得優(yōu)化的地方。
  • 編碼本身是一個很細(xì)節(jié)的事情痘儡,牛不牛也都隱藏在一行一行的代碼中辕万。空談架構(gòu)、設(shè)計渐尿、大道理醉途,實(shí)際上沒有太多意義,對你幫助不大砖茸。能沉下心來把細(xì)節(jié)都做好那才是真的牛隘擎!
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市凉夯,隨后出現(xiàn)的幾起案子货葬,更是在濱河造成了極大的恐慌,老刑警劉巖劲够,帶你破解...
    沈念sama閱讀 211,265評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件震桶,死亡現(xiàn)場離奇詭異,居然都是意外死亡征绎,警方通過查閱死者的電腦和手機(jī)蹲姐,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,078評論 2 385
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來人柿,“玉大人柴墩,你說我怎么就攤上這事∏昀” “怎么了拐邪?”我有些...
    開封第一講書人閱讀 156,852評論 0 347
  • 文/不壞的土叔 我叫張陵,是天一觀的道長隘截。 經(jīng)常有香客問我扎阶,道長,這世上最難降的妖魔是什么婶芭? 我笑而不...
    開封第一講書人閱讀 56,408評論 1 283
  • 正文 為了忘掉前任东臀,我火速辦了婚禮,結(jié)果婚禮上犀农,老公的妹妹穿的比我還像新娘惰赋。我一直安慰自己,他們只是感情好呵哨,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,445評論 5 384
  • 文/花漫 我一把揭開白布赁濒。 她就那樣靜靜地躺著,像睡著了一般孟害。 火紅的嫁衣襯著肌膚如雪拒炎。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,772評論 1 290
  • 那天挨务,我揣著相機(jī)與錄音击你,去河邊找鬼玉组。 笑死,一個胖子當(dāng)著我的面吹牛丁侄,可吹牛的內(nèi)容都是我干的惯雳。 我是一名探鬼主播,決...
    沈念sama閱讀 38,921評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼鸿摇,長吁一口氣:“原來是場噩夢啊……” “哼石景!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起户辱,我...
    開封第一講書人閱讀 37,688評論 0 266
  • 序言:老撾萬榮一對情侶失蹤鸵钝,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后庐镐,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體恩商,經(jīng)...
    沈念sama閱讀 44,130評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,467評論 2 325
  • 正文 我和宋清朗相戀三年必逆,在試婚紗的時候發(fā)現(xiàn)自己被綠了怠堪。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,617評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡名眉,死狀恐怖粟矿,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情损拢,我是刑警寧澤陌粹,帶...
    沈念sama閱讀 34,276評論 4 329
  • 正文 年R本政府宣布,位于F島的核電站福压,受9級特大地震影響掏秩,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜荆姆,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,882評論 3 312
  • 文/蒙蒙 一蒙幻、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧胆筒,春花似錦邮破、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,740評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至彤蔽,卻和暖如春构诚,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背铆惑。 一陣腳步聲響...
    開封第一講書人閱讀 31,967評論 1 265
  • 我被黑心中介騙來泰國打工范嘱, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人员魏。 一個月前我還...
    沈念sama閱讀 46,315評論 2 360
  • 正文 我出身青樓丑蛤,卻偏偏與公主長得像,于是被迫代替她去往敵國和親撕阎。 傳聞我的和親對象是個殘疾皇子受裹,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,486評論 2 348

推薦閱讀更多精彩內(nèi)容