前面我們講解了冪等框架的設(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)存儲、查詢坑傅、刪除冪等號的功能僵驰。
- 我們先來看,如何生成冪等號唁毒。
- 冪等號用來標(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 上仔役,方便做問題排查掷伙。總體上來講又兵,冪等號的生成算法并不難任柜。
- 我們再來看,如何實(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é)都做好那才是真的牛隘擎!