冪等な問題の解決策

1.冪等性とは何ですか?

数学におけるべき等とは、複数の演算の結果が一貫していることを意味し、実際に動作するソフトウェアやネットワーク環境に応じて、同じ演算を何度実行しても結果が同じになることを意味します。
プログラミング プロセスでは、次のような冪等性が自然に存在することがわかります。

  1. クエリ操作の選択
  2. 削除操作では、特定のキー値に基づいて削除します
  3. update はフィールド値を更新します

2. 冪等性の問題はなぜ発生するのでしょうか?

冪等の問題が発生する理由は、クリックの繰り返しやネットワークの再送信に他なりません。たとえば、次のとおりです。
1) 送信ボタンを 2 回クリックする
2) 操作の進行中に更新ボタンをクリックする
3) ブラウザに戻って前の操作を繰り返す, 重複が発生する フォームを送信する
4) Nginx の再送信
5) 分散 RPC 環境で再送信を試行する
6) メッセージの繰り返しの消費 MQ メッセージ ミドルウェアを使用している場合、メッセージ ミドルウェアのエラーが時間内に送信されず、繰り返し消費が発生します。

3. 冪等なソリューションを確保する

冪等性を確保するには主に以下の方法があります。

1) 重複防止識別子(トークントークン)の実装

この方法では、呼び出し元はインターフェイスを呼び出すときに最初にバックエンドにグローバル ID (トークン) を要求し、そのグローバル ID を要求に含めます。バックエンドはこのトークンを Key として使用し、ユーザー情報を Value として使用する必要があります。キーと値のコンテンツの Redis。キーが存在し、値が一致することを確認し、削除コマンドを実行してから、後続のビジネス ロジックを実行します。対応する Key が存在しない場合、または Value が一致しない場合は、実行エラー メッセージが返されます。
使用プロセスは次の図に示されています。

①サーバーはトークンを取得するためのインターフェースを提供しており、このトークンはシリアル番号、配布ID、UUIDのいずれかになります。クライアントはインターフェイスを呼び出してトークンを取得し、サーバーはトークン文字列を生成します。
②このToken文字列をRedisに保存し、Redisキーとして使用します(有効期限の設定が必要です)。
③トークンをクライアントに返却し、クライアントは取得後フォームの隠しフィールドにトークンを格納します。
④クライアントがフォームを実行して送信すると、ヘッダーにトークンが組み込まれます。
⑤リクエストを受信したサーバーはHeaderからTokenを取得し、Redisを検索して該当するKeyが存在するかどうかを確認し、存在する場合は削除し、存在しない場合は重複送信例外をスローします。ここで、検索操作と削除操作は原子性を保証する必要があり、そうでないと同時状況で冪等性が保証されない可能性があることに注意してください。アトミック性に関しては、分散ロックまたは Lua スクリプトを通じてクエリおよび削除操作をログアウトできます。
⑥ 結果を返し、通常のビジネス ロジックを実行するか、エラー メッセージを表示します。

このメソッドは、挿入、更新、削除の操作に適用できます。制限は、グローバルに一意のトークン文字列を生成する必要があり、データ検証に Redis を使用する必要があることです。
ここではその実装方法を詳しく見ていきます。pom の
実装では
、 springboot、Redis、lombok、およびその他の関連する依存関係が導入されています。

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.3.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>2.2.2.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
            <version>2.6.1</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.16</version>
        </dependency>
    </dependencies>

アプリケーションは
Redis 接続関連のパラメーター構成ファイルを実装します

spring:
  redis:
    ssl: false
    host: 127.0.0.1
    port: 6379
    database: 0
    timeout: 1000
    password:
    lettuce:
      pool:
        max-active: 100
        max-wait: -1
        min-idle: 0
        max-idle: 20

トークン検証トークン ツール クラスの作成

@Slf4j
@Service
public class TokenUtilService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /*
    * 存入Redis的Token的前缀
    */
    private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";
    
    
    public String generateToken(String value) {
        String token = UUID.randomUUID().toString();
        //设置存入Redis的key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        //存储Token到Redis并设置过期时间为5分钟
        redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MiNUTES);
        
        return token;
    }
    
    public boolean validToken(String token, String value) {
        //设置Lus脚本,KEYS[1]是key,KEYS[2]是value
        String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Long> redisScript= new DefaultRedisScript<>(script, Long.class);
        
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        //执行Lua脚本
        Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
        //根据返回结果判断是否成功匹配并删除,结果不为空或0,验证通过
        if (result != null && result != 0L) {
            log.info("验证 token={}, key={}, value={}, 成功", token, key, value);
            return true;
        }
        log.info("验证 token={}, key={}, value={}, 失败", token, key, value);
        return false;
    }

}

テストクラス(コントローラー層シミュレーション)

@Slf4j
@RestController
public class TokenContoller {
    
    @Autowired
    private TokenUtilService tokenService;
    
    /*
    * 获取Token接口,返回Token串
    */
    @GetMapping("/token")
    public String getToken() {
        //模拟数据,使用token验证是否存在对应的key
        String userInfo = "myInfo";
        
        return tokenService.generateToken(userInfo);
    }
    
    /*
    * 幂等性测试接口
    */
    @PostMapping("/test")
    public String test(@RequestHeader(value = "token") String token) {
        String userInfo = "myInfo";
        boolean result = tokenService.validToken(tolen, userInfo);
        return result ? "正常调用":"重复调用";
    }
}

最後に、このソリューションの改良版があり、リレーショナル ライブラリを導入し、リレーショナル ライブラリのトランザクション特性を使用して操作のアトミック性を確保します。つまり、処理されたデータをリレーショナル ライブラリに挿入し、最後にRedis への冪等キー。これにより、同時条件下でも冪等性が保証されます。

2) ダウンストリームへの固有シリアル番号送信の実装

サーバーへの各リクエストには、短期間の一意で反復しないシーケンス番号が付いています。このシーケンス番号は通常、下流で生成されます。上流のサーバー インターフェイスを呼び出すときに、認証に使用されるシーケンス番号と ID が追加されます。上流サーバーは、このシリアル番号と下流認証 ID を組み合わせて、Redis の操作に使用されるキーを形成し、Redis にクエリを実行して、対応するキーが存在するかどうかを確認します。存在する場合は、下流のシーケンス番号リクエストが処理されたことを意味し、繰り返しリクエストのエラーメッセージが直接返されます。存在しない場合は、この Key が Redis キーとして使用され、下流のキー情報が保存された値、キーは です。値のペアは Redis に保存され、通常のビジネス ロジックが実行されます。
使用されるプロセスを以下に示します。

Redis にデータを挿入するときは、有効期限を設定する必要があることに注意してください。これにより、インターフェイスへの繰り返しの呼び出しを時間範囲内で確実に識別できます。そうでないと、無制限の量のデータが Redis に保存される可能性があります。
この方法は挿入、更新、削除の操作に適していますが、サードパーティに一意のシーケンス番号を渡す必要があり、データ検証に Redis を使用する必要があります。

3) データベースの主キーを利用して実装

ここでは、データベースの一意の主キーの制約機能が使用されます。この方法は、挿入中の冪等性に適しており、テーブル値に主キーを持つレコードが確実に格納されるようにできます。ここで使用される主キーは通常、分散 ID を指します。分散環境における ID のグローバルな一意性を確保します。
使用プロセスは次の図に示されています。

①クライアントは作成リクエストを実行し、サーバーインターフェースを呼び出します。
② サーバーはビジネス ロジックを実行し、挿入されたデータの主キーとして ID を使用して挿入操作を実行する分散 ID を生成します。ここでの ID 生成アルゴリズムには、スノーフレーク アルゴリズムを使用するか、データベース番号セグメント モードまたはRedis の自動インクリメント方式で分散式の一意の ID を生成します。
③サーバーはデータベースへの挿入を実行し、挿入が成功した場合は、インターフェイスが繰り返し呼び出されないことを意味します。主キー重複例外がスローされた場合、クライアントにエラー メッセージが返されます。

このメソッドは挿入および削除操作に適していますが、主キーを生成する必要があるという制限があります。

4) データベースの楽観的ロックを使用する

データベースのオプティミスティック ロックは通常、対応するデータベース テーブルにバージョン識別フィールドを追加することによって更新操作に使用され、更新ごとにバージョン識別値がチェックされます。
使用プロセスは以下に示すように非常に簡単です。
注意する必要があるのは、update ステートメントを実行するときに現在のバージョンを判断する条件がもう 1 つあることです。たとえば、
update my_table set Price=price+50, version=version+1 where id = 3 and version = 5 ;
このように、実行するたびにバージョンが変わりますが、繰り返し実行すると元のバージョン番号は反映されず、冪等性が確保されます。
このメソッドは更新操作にのみ使用でき、対応するデータベース テーブルにフィールドを追加する必要もあります。
最後に、冪等性の問題に対処するために一般的に使用される 4 つのバックエンド方法を次のようにまとめます。

上記の主な方法に加えて、次のような他の方法も使用できます。

5) ローカルロックの助けを借りて

ConcurrentHashMap 同時コンテナ putIfAbsent メソッドと ScheduledThreadPoolExecutor タイミング タスクが使用されます。guava キャッシュ メカニズムも使用できます。guava で有効時間をキャッシュすることも可能です。キーは Content-MD5 を通じて生成されます。Content-MD5 は一意です特定の範囲内で使用すると、ほぼ一意であるとみなされ、同時実行性の低い環境でキーとして使用できます。
もちろん、ローカル ロックは単一のマシンにデプロイされたアプリケーションにのみ適用できます。その単純な実装を見てみましょう:
設定アノテーション:

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Resubmit {
    /*
     * 延时时间,在延时多久后可以再次提交,单位为秒
     * */
    int delaySeconds() default 20;
}

ロックをインスタンス化します。

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;

import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

@Slf4j
public final class ResubmitLock {
    private static final ConcurrentHashMap LOCK_CACHE = new ConcurrentHashMap(200);
    private static final ScheduledThreadPoolExecutor EXECUTOR = new ScheduledThreadPoolExecutor(5, new ThreadPoolExecutor.DiscardPolicy());

    private ResubmitLock() {
    }

    /*
     * 静态内部类的单例模式
     * */
    private static class SingletonInstance {
        private static final ResubmitLock Instance = new ResubmitLock();
    }

    public static ResubmitLock getInstance() {
        return SingletonInstance.Instance;
    }

    public static String handleKey(String param) {
        return DigestUtils.md5Hex(param == null ? "" : param);
    }

    public boolean lock(final String key, Object value) {
        return Objects.isNull(LOCK_CACHE.putIfAbsent(key, value));
    }

    public void unlock(final boolean lock, final String key, final int delaySeconds) {
        if (lock) {
            EXECUTOR.schedule(() -> {
                LOCK_CACHE.remove(key);
            }, delaySeconds, TimeUnit.SECONDS);
        }
    }
}

AOP の側面:

import java.lang.reflect.Method;

@Log4j
@Aspect
@Component
public class ResubmitDataAspect {
    private final static String DATA = "data";
    private final static Object PRESENT = new Object();

    @Around("@annotation(com.cn.xxx.common.annotation.Resubmit)")
    public Object handleResubmit(ProceedingJoinPoint joinPoint) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        //获取注解信息
        Resubmit annotation = method.getAnnotation(Resubmit.class);
        int delaySeconds = annotation.delaySeconds();
        Object[] pointArgs = joinPoint.getArgs();
        String key = "";
        //获取第一个参数
        Object firstParam = pointArgs[0];
        if (firstParam instanceof RequestDTO) {
            //解析参数
            JSONObject requestDTO = JSONObject.parseObject(firstParam.toString());
            JSONObject data = JSONObject.parseObject(requestDTO.getString(DATA));

            if (data != null) {
                StringBuffer sb = new StringBuffer();
                data.forEach((k, v) -> {
                    sb.apperd(v);
                });
                key = ResubmitLock.handleKey(sb.toString());
            }
        }

        boolean lock = false;
        try {
            //设置解锁key
            lock = ResubmitLock.getInstance().lock(key, PRESENT);
            if (lock) {
                //放行
                return joinPoint.proceed();
            } else {
                //响应重复提交异常
                return new ResponseDTO<>(RespoinseCode.REPEAT_SUBMIT_OPERATION_EXCEPTION);
            }
        } finally {
            //设置解锁key和解锁时间
            ResubmitLock.getInstance().unLock(lock, key, delaySeconds);
        }
    }
}

使用上の注意:

public class ResponseToSavaPosts {

    @ApiOperation(value = "保存我的帖子接口", notes = "保存我的帖子接口")
    @PostMapping("/posts/save")
    @Resubmit(delaySeconds = 10)
    public void ResponseToSava(@RequestBody @Validated RequestDTOrequestDto) {
        return bbsPostsBizService.saveBbsPosts(requestDto);
    }
}
6) 分散 Redis ロックの助けを借りて

Redis に精通している人なら誰でも、Redis がスレッドセーフであることを知っています。opsForValue().setIfAbsent(key) などの機能を使用すると、分散ロックを簡単に実装できます。その機能は、キャッシュと、キャッシュがない場合に同時に返すことです。キャッシュ内の現在のキー true、キャッシュ後、システムのクラッシュやデッドロックの原因でロックが解放されるのを防ぐために、キーの有効期限を設定します。true が返された場合、ロックを取得したと考えることができます。ロックが解放されない場合は、例外を実行します。

7) データベースの悲観的ロックを使用する

更新には select ... を使用します。これは同期と同じ原理で、最初にロックし、次にチェックしてから更新または挿入操作を実行します。問題はデッドロックを回避する方法を考えることであり、効率は比較的悪いですが、この方法は 1 つのアプリケーションの同時実行性が小さい場合に使用できます。

8) フロントエンドページの保証

通常、投稿後は投稿ボタンのクリックを禁止する設定(通常は一定期間が設定されます)となります。

9) Post/リダイレクト/Get モードを使用する

このメソッドは、送信後にページ リダイレクトを実行する PRG (Post-Redirect-Get) モードです。
つまり、ユーザーがフォームを送信した後、クライアント側で送信が成功した情報ページへのリダイレクトが実行されます。これにより、ページの更新によって引き起こされる繰り返しの送信を回避でき、ブラウザ フォームの繰り返しの送信に関する警告が表示されなくなり、ブラウザの進むボタンと戻るボタンを押すことによって引き起こされる問題も排除できます。

おすすめ

転載: blog.csdn.net/baidu_38493460/article/details/132619338