冪等公共組件

前言

今天想聊一聊冪等相關的知識,以及實現一個冪等公共組件需要重點涉及和思考的點。

概念

首先,什麼是冪等,在實際代碼生產過程中有什麼作用呢?

在編程中一個冪等操作的特點是其任意多次執行所產生的影響均與一次執行的影響相同。

舉個例子,假如有個方法,用於修改一個訂單的狀態爲已完成,只改一個狀態字段,要達到冪等的效果我們可以這樣:

  • 每次執行都正真執行更新語句接口,結果都是狀態保持已完成
  • 每次執行先判斷訂單狀態是否已經是已更新狀態,如果是就返回,如果不是就執行更新語句,也過也是保持已完成狀態

所以,一個擁有冪等性的業務代碼,就可以保證外部重複調用的結果和單次調用的結果一致,保證這一點在實際代碼生產中的一些場景中是非常重要的。以下做一些例舉:

  • 客戶端併發重複提交,這種比較常見,用戶連續點擊按鈕即可觸發重複提交
  • 微服務架構中Http或RPC請求調用失敗觸發重試
  • 消息中間件重複消費,消息中間件本身就是通過重複消費達到業務解耦和一致性的,所以使用消息是必然需要考慮冪等情況的
  • 調用方定時任務重複調用或者上游觸發歷史業務請求,從不信任外部的角度看,有時也需要考慮

總結一下:

At least once + 冪等 = exactly once

邏輯

一下是使用較多的冪等方案的流程圖如下:

image

  • 一個微服務系統中在進入業務執行前必須要保證拿到分佈式鎖,這樣才能屏蔽掉併發重複請求
  • 執行業務和冪等標記的存儲需要保證原子性,才能保證不會出現冪等標記和業務變更的數據不一致情況的發生,否則這個冪等標記就沒有意義了

公共組件

公共組件的例子以Spring爲基礎,其中會使用到Spring相關的組件。

設計

根據前面的流程圖,使用AOP非常適合實現一個公共組件。

image

代碼

定義註解提供給業務使用:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {

   /**
    * Name used to determine the target idempotent key prefix
    */
   String name();

   /**
    * support Spring Expression Language (SpEL)
    */
   String key();

   /**
    * set idempotent key expire time, default 0 second
    */
   long idempotentExpire() default 0;

   /**
    * set lock expire time default 60 seconds
    */
   long lockExpire() default 60;

}
  • 註解中的信息包含冪等key,鎖過期時間,冪等保護時間,注意這裏的key支持SpEL,這樣就可以非常方便的可以把方法中的參數作爲key的一部分

通過註解切面,核心代碼如下:

public <T> T execute(IdempotentRequest request, Supplier<T> processSupplier, Supplier<T> failSupplier) {
    String idempotentKey = request.getKey();
    long idempotentExpire = request.getIdempotentExpire();
    long lockExpire = request.getLockExpire() == 0 ? DEFAULT_LOCK_TIME : request.getLockExpire();
    IdempotentRecordStorage idempotentRecordStorage = getIdempotentRecordStorage(idempotentExpire);
    try {
        boolean locked = redisLockService.lock(idempotentKey, LOCK_VALUE, lockExpire, true);
        if (locked) {
            if (idempotentRecordStorage.hasKey(idempotentKey)) {
                return failSupplier.get();
            }
            T result = processSupplier.get();
            idempotentRecordStorage.setKey(idempotentKey, idempotentExpire);
            return result;
        } else {
            return failSupplier.get();
        }
    } finally {
        redisLockService.unlock(idempotentKey, LOCK_VALUE, true);
    }

}

看一下Oracle 的實現例子:

public class OracleStorage implements IdempotentRecordStorage {

    private JdbcTemplate jdbcTemplate;

    public OracleStorage(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public void setKey(String key, long expire) {
        Date expireDate = expire == 0 ? null : new Date(System.currentTimeMillis() + expire * 1000);
        String sql = "insert into AAP_IDEMPOTENT_RECORD(ID, KEY, CREATE_TIME, EXPIRE_TIME) values(IDEMPOTENT_RECORD_SEQUENCE.nextval, ?,?,?)";
        jdbcTemplate.update(sql, key, new Date(), expireDate);
    }

    @Override
    public boolean hasKey(String key) {
        String sql = "select count(1) from AAP_IDEMPOTENT_RECORD WHERE KEY = ?";
        Integer value = jdbcTemplate.queryForObject(sql, Integer.class, key);
        return value > 0;
    }

    @Override
    public StorageTypeEnum getType() {
        return StorageTypeEnum.ORACLE;
    }

}

思考

在實際coding的過程中有幾個有意思的點:

  • 1,想把冪等記錄操作和業務操作放入一個事務內,才能保證前面圖中的原子操作,而一般我們會使用@Transaction註解,這個註解也和我們一樣使用AOP實現,所以問題就來了,兩個切面的順序性需要做準確的調整,因爲我的例子項目裏沒有設置@Transaction切面的order,所以默認是Integer.MAX_VALUE,自定義的切面也默認是Integer.MAX_VALUE,所以就出現了@Transaction註解在內層,導致變成兩個事務的提交,而不能保證原子性。調整順序方式:@EnableTransactionManagement(order = Ordered.LOWEST_PRECEDENCE - 100)
  • 2,當我以爲業務操作和冪等操作在一個事務的時候我產生了一個疑惑,冪等操作自己提前會先提交嗎?如果會的話,那又保證不了原子了。這裏注意使用的是jdbcTemplate,底層還是會和@Transaction註解一樣拿到相同的Connection,所以可以達到一起提交的能力。
  • 3,如果使用Redis做冪等數據的操作,那麼就需要額外考慮保證原子性的方法,比如在setKey的位置實際執行成功,但是返回網絡問題拋出異常,前面業務操作的事會被回滾,但是冪等數據實際已經存在的問題。爲了解決這個問題,更傾向於提供給使用方決定何種情況下需要清楚冪等數據。這裏代碼沒有提供,需要補充。

以上是個人的一些思考,實現代碼放在github,歡迎交流:
https://github.com/dchack/crab

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章