解耦,未解耦的区别_幂等与时间解耦之旅
解耦,未解耦的區(qū)別
HTTP中的冪等性意味著相同的請(qǐng)求可以執(zhí)行多次,效果與僅執(zhí)行一次一樣。 如果用新資源替換某個(gè)資源的當(dāng)前狀態(tài),則無(wú)論您執(zhí)行多少次,最終狀態(tài)都將與您僅執(zhí)行一次相同。 舉一個(gè)更具體的例子:刪除用戶是冪等的,因?yàn)闊o(wú)論您通過(guò)唯一標(biāo)識(shí)符刪除給定用戶多少次,最終該用戶都會(huì)被刪除。 另一方面,創(chuàng)建新用戶不是冪等的,因?yàn)閮纱握?qǐng)求該操作將創(chuàng)建兩個(gè)用戶。 用HTTP術(shù)語(yǔ)來(lái)說(shuō)是RFC 2616:9.1.2等冪方法必須說(shuō)的:
9.1.2等冪方法
方法還可以具有“ 冪等 ”的特性,因?yàn)閇…] N> 0個(gè)相同請(qǐng)求的副作用與單個(gè)請(qǐng)求的副作用相同。 GET,HEAD,PUT和DELETE方法共享此屬性。 同樣,方法OPTIONS和TRACE不應(yīng)有副作用,因此本質(zhì)上是冪等的。
時(shí)間耦合是系統(tǒng)的不良特性,其中正確的行為隱含地取決于時(shí)間維度。 用簡(jiǎn)單的英語(yǔ)來(lái)說(shuō),這可能意味著例如系統(tǒng)僅在所有組件同時(shí)存在時(shí)才起作用。 阻塞請(qǐng)求-響應(yīng)通信(ReST,SOAP或任何其他形式的RPC)要求客戶端和服務(wù)器同時(shí)可用,這就是這種效果的一個(gè)示例。
基本了解這些概念的含義后,我們來(lái)看一個(gè)簡(jiǎn)單的案例研究- 大型多人在線角色扮演游戲 。 我們的人工用例如下:玩家發(fā)送優(yōu)質(zhì)短信,以在游戲內(nèi)購(gòu)買(mǎi)虛擬劍。 交付SMS時(shí)將調(diào)用我們的HTTP網(wǎng)關(guān),我們需要通知部署在另一臺(tái)計(jì)算機(jī)上的InventoryService 。 當(dāng)前的API涉及ReST,其外觀如下:
@Slf4j @RestController class SmsController {private final RestOperations restOperations;@Autowiredpublic SmsController(RestOperations restOperations) {this.restOperations = restOperations;}@RequestMapping(value = "/sms/{phoneNumber}", method = POST)public void handleSms(@PathVariable String phoneNumber) {Optional<Player> maybePlayer = phoneNumberToPlayer(phoneNumber);maybePlayer.map(Player::getId).map(this::purchaseSword).orElseThrow(() -> new IllegalArgumentException("Unknown player for phone number " + phoneNumber));}private long purchaseSword(long playerId) {Sword sword = new Sword();HttpEntity<String> entity = new HttpEntity<>(sword.toJson(), jsonHeaders());restOperations.postForObject("http://inventory:8080/player/{playerId}/inventory",entity, Object.class, playerId);return playerId;}private HttpHeaders jsonHeaders() {HttpHeaders headers = new HttpHeaders();headers.setContentType(MediaType.APPLICATION_JSON);return headers;}private Optional<Player> phoneNumberToPlayer(String phoneNumber) {//...} }依次產(chǎn)生類似于以下內(nèi)容的請(qǐng)求:
> POST /player/123123/inventory HTTP/1.1 > Host: inventory:8080 > Content-type: application/json > > {"type": "sword", "strength": 100, ...}< HTTP/1.1 201 Created < Content-Length: 75 < Content-Type: application/json;charset=UTF-8 < Location: http://inventory:8080/player/123123/inventory/1這很簡(jiǎn)單。 SmsController只需通過(guò)發(fā)布購(gòu)買(mǎi)的劍SmsController適當(dāng)?shù)臄?shù)據(jù)轉(zhuǎn)發(fā)到SmsController inventory:8080服務(wù)。 該服務(wù)立即或201 Created返回201 Created HTTP響應(yīng),確認(rèn)操作成功。 此外,還會(huì)創(chuàng)建并返回到資源的鏈接,因此您可以對(duì)其進(jìn)行查詢。 有人會(huì)說(shuō):ReST是最新技術(shù)。 但是,如果您至少關(guān)心客戶的錢(qián)并了解什么是ACID(比特幣交易所還必須學(xué)習(xí)的東西:請(qǐng)參閱[1] , [2] , [3]和[4] )–該API也是易碎,容易出錯(cuò)。 想象所有這些類型的錯(cuò)誤:
在所有這些情況下,您僅在客戶端獲得一個(gè)異常,而您不知道服務(wù)器的狀態(tài)是什么。 從技術(shù)上講,您應(yīng)該重試失敗的請(qǐng)求,但是由于POST不具有冪等性,因此您最終可能會(huì)用一把以上的劍來(lái)獎(jiǎng)勵(lì)玩家(在5-8情況下)。 但是,如果不重試,您可能會(huì)失去游戲玩家的金錢(qián)而又不給他他寶貴的神器。 肯定有更好的辦法。
將POST轉(zhuǎn)換為冪等PUT
在某些情況下,通過(guò)將ID生成基本上從服務(wù)器轉(zhuǎn)移到客戶端,從POST轉(zhuǎn)換為冪等PUT會(huì)非常簡(jiǎn)單。 使用POST的是服務(wù)器生成劍的ID,并將其發(fā)送到Location標(biāo)頭中的客戶端。 事實(shí)證明,在客戶端急切地生成UUID并稍稍更改語(yǔ)義加上在服務(wù)器端強(qiáng)制執(zhí)行一些約束就足夠了:
private long purchaseSword(long playerId) {Sword sword = new Sword();UUID uuid = sword.getUuid();HttpEntity<String> entity = new HttpEntity<>(sword.toJson(), jsonHeaders());asyncRetryExecutor.withMaxRetries(10).withExponentialBackoff(100, 2.0).doWithRetry(ctx ->restOperations.put("http://inventory:8080/player/{playerId}/inventory/{uuid}",entity, playerId, uuid));return playerId; }該API如下所示:
> PUT /player/123123/inventory/45e74f80-b2fb-11e4-ab27-0800200c9a66 HTTP/1.1 > Host: inventory:8080 > Content-type: application/json;charset=UTF-8 > > {"type": "sword", "strength": 100, ...}< HTTP/1.1 201 Created < Content-Length: 75 < Content-Type: application/json;charset=UTF-8 < Location: http://inventory:8080/player/123123/inventory/45e74f80-b2fb-11e4-ab27-0800200c9a66為什么這么大? 簡(jiǎn)單地說(shuō)(不需要雙關(guān)語(yǔ)),客戶端現(xiàn)在可以根據(jù)需要重試PUT請(qǐng)求多次。 服務(wù)器首次收到PUT時(shí),會(huì)將劍以客戶端生成的UUID( 45e74f80-b2fb-11e4-ab27-0800200c9a66 )作為主鍵45e74f80-b2fb-11e4-ab27-0800200c9a66在數(shù)據(jù)庫(kù)中。 在第二次嘗試PUT的情況下,我們可以更新或拒絕該請(qǐng)求。 使用POST不可能,因?yàn)槊總€(gè)請(qǐng)求都被視為購(gòu)買(mǎi)新劍–現(xiàn)在我們可以跟蹤是否已經(jīng)有這樣的PUT。 我們只需要記住,后續(xù)的PUT并不是錯(cuò)誤,而是更新請(qǐng)求:
@RestController @Slf4j public class InventoryController {private final PlayerRepository playerRepository;@Autowiredpublic InventoryController(PlayerRepository playerRepository) {this.playerRepository = playerRepository;}@RequestMapping(value = "/player/{playerId}/inventory/{invId}", method = PUT)@Transactionalpublic void addSword(@PathVariable UUID playerId, @PathVariable UUID invId) {playerRepository.findOne(playerId).addSwordWithId(invId);}}interface PlayerRepository extends JpaRepository<Player, UUID> {}@lombok.Data @lombok.AllArgsConstructor @lombok.NoArgsConstructor @Entity class Sword {@Id@Convert(converter = UuidConverter.class)UUID id;int strength;@Overridepublic boolean equals(Object o) {if (this == o) return true;if (!(o instanceof Sword)) return false;Sword sword = (Sword) o;return id.equals(sword.id);}@Overridepublic int hashCode() {return id.hashCode();} }@Data @Entity class Player {@Id@Convert(converter = UuidConverter.class)UUID id = UUID.randomUUID();@OneToMany(cascade = ALL, fetch = EAGER)@JoinColumn(name="player_id")Set<Sword> swords = new HashSet<>();public Player addSwordWithId(UUID id) {swords.add(new Sword(id, 100));return this;}}上面的代碼片段中很少有快捷方式,例如直接將存儲(chǔ)庫(kù)注入到控制器,以及使用@Transactional注釋。 但是你明白了。 還要注意,假設(shè)沒(méi)有完全同時(shí)插入兩個(gè)具有相同UUID的劍,此代碼相當(dāng)樂(lè)觀。 否則將發(fā)生約束違例異常。
旁注1:我在控制器和JPA模型中都使用UUID類型。 開(kāi)箱即用不支持它們,對(duì)于JPA,您需要自定義轉(zhuǎn)換器:
public class UuidConverter implements AttributeConverter<UUID, String> {@Overridepublic String convertToDatabaseColumn(UUID attribute) {return attribute.toString();}@Overridepublic UUID convertToEntityAttribute(String dbData) {return UUID.fromString(dbData);} }對(duì)于Spring MVC同樣(僅單向):
@Bean GenericConverter uuidConverter() {return new GenericConverter() {@Overridepublic Set<ConvertiblePair> getConvertibleTypes() {return Collections.singleton(new ConvertiblePair(String.class, UUID.class));}@Overridepublic Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {return UUID.fromString(source.toString());}}; }附注2:如果無(wú)法更改客戶端,則可以通過(guò)將每個(gè)請(qǐng)求的哈希存儲(chǔ)在服務(wù)器端來(lái)跟蹤重復(fù)項(xiàng)。 這樣,當(dāng)多次發(fā)送同一請(qǐng)求(客戶端重試)時(shí),它將被忽略。 但是有時(shí)我們可能會(huì)有合法的用例,可以兩次發(fā)送完全相同的請(qǐng)求(例如,在短時(shí)間內(nèi)購(gòu)買(mǎi)兩把劍)。
時(shí)間耦合–客戶不可用
您認(rèn)為自己很聰明,但是僅重試就不夠了。 首先,客戶端可以在重新嘗試失敗的請(qǐng)求時(shí)死亡。 如果服務(wù)器嚴(yán)重?fù)p壞或關(guān)閉,重試可能要花費(fèi)幾分鐘甚至幾小時(shí)。 您不能僅僅因?yàn)橄掠我蕾図?xiàng)之一關(guān)閉而就阻止了傳入的HTTP請(qǐng)求-如果可能,您必須在后臺(tái)異步處理此類請(qǐng)求。 但是,延長(zhǎng)重試時(shí)間會(huì)增加客戶端死亡或重新啟動(dòng)的可能性,這可能會(huì)使我們的請(qǐng)求松動(dòng)。 想象一下,我們收到了優(yōu)質(zhì)的SMS,但是InventoryService目前處于關(guān)閉狀態(tài)。 我們可以在第二,第二,第四等之后重試,但是如果InventoryService停機(jī)了幾個(gè)小時(shí)又碰巧我們的服務(wù)也重新啟動(dòng)了怎么辦? 我們只是失去了短信和劍從未被賦予玩家的機(jī)會(huì)。
解決此問(wèn)題的方法是先保留未決請(qǐng)求,然后在后臺(tái)處理它。 收到SMS消息后,我們幾乎沒(méi)有將玩家ID存儲(chǔ)在名為“ pending_purchases數(shù)據(jù)庫(kù)表中。 后臺(tái)調(diào)度程序或事件喚醒異步線程,該線程將收集所有未完成的購(gòu)買(mǎi)并將嘗試將其發(fā)送到InventoryService (甚至可能以批處理方式?)每隔一分鐘甚至一秒鐘運(yùn)行一次的周期性批處理線程,并收集所有未完成的請(qǐng)求將不可避免地導(dǎo)致延遲和不必要數(shù)據(jù)庫(kù)流量。 因此,我打算使用Quartz調(diào)度程序,它將為每個(gè)待處理的請(qǐng)求調(diào)度重試作業(yè):
@Slf4j @RestController class SmsController {private Scheduler scheduler;@Autowiredpublic SmsController(Scheduler scheduler) {this.scheduler = scheduler;}@RequestMapping(value = "/sms/{phoneNumber}", method = POST)public void handleSms(@PathVariable String phoneNumber) {phoneNumberToPlayer(phoneNumber).map(Player::getId).map(this::purchaseSword).orElseThrow(() -> new IllegalArgumentException("Unknown player for phone number " + phoneNumber));}private UUID purchaseSword(UUID playerId) {UUID swordId = UUID.randomUUID();InventoryAddJob.scheduleOn(scheduler, Duration.ZERO, playerId, swordId);return swordId;}//...}和工作本身:
@Slf4j public class InventoryAddJob implements Job {@Autowired private RestOperations restOperations;@lombok.Setter private UUID invId;@lombok.Setter private UUID playerId;@Overridepublic void execute(JobExecutionContext context) throws JobExecutionException {try {tryPurchase();} catch (Exception e) {Duration delay = Duration.ofSeconds(5);log.error("Can't add to inventory, will retry in {}", delay, e);scheduleOn(context.getScheduler(), delay, playerId, invId);}}private void tryPurchase() {restOperations.put(/*...*/);}public static void scheduleOn(Scheduler scheduler, Duration delay, UUID playerId, UUID invId) {try {JobDetail job = newJob().ofType(InventoryAddJob.class).usingJobData("playerId", playerId.toString()).usingJobData("invId", invId.toString()).build();Date runTimestamp = Date.from(Instant.now().plus(delay));Trigger trigger = newTrigger().startAt(runTimestamp).build();scheduler.scheduleJob(job, trigger);} catch (SchedulerException e) {throw new RuntimeException(e);}}}每當(dāng)我們收到優(yōu)質(zhì)的SMS時(shí),我們都會(huì)安排異步作業(yè)立即執(zhí)行。 Quartz將負(fù)責(zé)持久性(如果應(yīng)用程序關(guān)閉,則在重新啟動(dòng)后將盡快執(zhí)行作業(yè))。 而且,如果該特定實(shí)例出現(xiàn)故障,則另一個(gè)可以承擔(dān)這項(xiàng)工作–或我們可以形成集群并在它們之間進(jìn)行負(fù)載平衡請(qǐng)求:一個(gè)實(shí)例接收SMS,另一個(gè)實(shí)例在InventoryService請(qǐng)求劍。 顯然,如果HTTP調(diào)用失敗,則稍后重新安排重試時(shí)間,一切都是事務(wù)性的且具有故障保護(hù)功能。 在實(shí)際代碼中,您可能會(huì)添加最大重試限制以及指數(shù)延遲,但是您了解了。
時(shí)間耦合–客戶端和服務(wù)器無(wú)法滿足
我們?yōu)檎_執(zhí)行重試所做的努力是客戶端和服務(wù)器之間模糊的時(shí)間耦合的標(biāo)志-它們必須同時(shí)生活在一起。 從技術(shù)上講,這不是必需的。 想象玩家在48小時(shí)內(nèi)向客戶服務(wù)發(fā)送一封包含訂單的電子郵件,他們手動(dòng)更改了庫(kù)存。 同樣的情況也適用于我們的情況,但是用某種消息代理(例如JMS)替換電子郵件服務(wù)器:
@Bean ActiveMQConnectionFactory activeMQConnectionFactory() {return new ActiveMQConnectionFactory("tcp://localhost:61616"); }@Bean JmsTemplate jmsTemplate(ConnectionFactory connectionFactory) {return new JmsTemplate(connectionFactory); }建立ActiveMQ連接后,我們可以簡(jiǎn)單地將購(gòu)買(mǎi)請(qǐng)求發(fā)送給經(jīng)紀(jì)人:
private UUID purchaseSword(UUID playerId) {final Sword sword = new Sword(playerId);jmsTemplate.send("purchases", session -> {TextMessage textMessage = session.createTextMessage();textMessage.setText(sword.toJson());return textMessage;});return sword.getUuid(); }通過(guò)用JMS主題上的消息傳遞完全替換同步請(qǐng)求-響應(yīng)協(xié)議,我們暫時(shí)將客戶端與服務(wù)器分離。 他們不再需要同時(shí)生活。 此外,不止一個(gè)生產(chǎn)者和消費(fèi)者可以相互交流。 例如,您可以有多個(gè)購(gòu)買(mǎi)渠道,更重要的是:多個(gè)利益相關(guān)方,而不僅僅是InventoryService 。 更好的是,如果您使用像Kafka這樣的專用消息傳遞系統(tǒng), 則從技術(shù)上講,您可以保留數(shù)天(數(shù)月)的消息而不會(huì)降低性能。 好處是,如果將另一個(gè)購(gòu)買(mǎi)事件的使用者添加到InventoryService旁邊的系統(tǒng),它將立即收到許多歷史數(shù)據(jù)。 而且,現(xiàn)在您的應(yīng)用程序在時(shí)間上與代理耦合,因此,由于Kafka是分布式和復(fù)制的,因此在這種情況下它可以更好地工作。
異步消息傳遞的缺點(diǎn)
在ReST,SOAP或任何形式的RPC中使用的同步數(shù)據(jù)交換很容易理解和實(shí)現(xiàn)。 從延遲的角度來(lái)看,誰(shuí)在乎這種抽象會(huì)瘋狂地泄漏(本地方法調(diào)用通常比遠(yuǎn)程方法快幾個(gè)數(shù)量級(jí),更不用說(shuō)它可能因本地未知的眾多原因而失敗),因此開(kāi)發(fā)起來(lái)很快。 消息傳遞的一個(gè)真正警告是反饋渠道。 因?yàn)闆](méi)有響應(yīng)管道,所以您可以不再只是“ 發(fā)送 ”(“ return ”)消息而已。 您要么需要帶有一些相關(guān)性ID的響應(yīng)隊(duì)列,要么需要每個(gè)請(qǐng)求臨時(shí)的一次性響應(yīng)隊(duì)列。 我們還撒謊了一點(diǎn),聲稱在兩個(gè)系統(tǒng)之間放置消息代理可修復(fù)時(shí)間耦合。 確實(shí)如此,但是現(xiàn)在我們耦合到了消息傳遞總線,它也可能會(huì)崩潰,特別是因?yàn)樗ǔL幱诟哓?fù)載下,有時(shí)無(wú)法正確復(fù)制。
本文展示了在分布式系統(tǒng)中提供保證的一些挑戰(zhàn)和部分解決方案。 但是,歸根結(jié)底,請(qǐng)記住,“ 僅一次 ”語(yǔ)義幾乎不可能輕松實(shí)現(xiàn),因此仔細(xì)檢查您確實(shí)需要它們。
翻譯自: https://www.javacodegeeks.com/2015/02/journey-to-idempotency-and-temporal-decoupling.html
解耦,未解耦的區(qū)別
總結(jié)
以上是生活随笔為你收集整理的解耦,未解耦的区别_幂等与时间解耦之旅的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 宏?电脑怎么正确配置(电脑里宏怎么设置)
- 下一篇: maven 插件未找到_防止在多模块Ma