Java作業における並行性問題の処理方法の要約

長い間ブログを書いていなかったようですが、この余暇を利用して、ビジネスシステムの開発プロセスで発生した並行性の問題とその解決策をまとめたいと思います。

 

問題の再発

1.「デバイスAの奇妙なクローン」

昔、昔の深夜にさかのぼります。当時、私が開発したマルチメディア広告再生制御システムは、生産を開始してオンラインになりました。同社がオープンした最初のオフライン生鮮食品店では、数十の大型店がありました。小型のマルチメディアハードウェア機器は通常インターネットに接続されていましたが、その後、1つずつ登録して、すでにオンラインになっているマルチメディア広告放送制御システムに接続していました。

登録プロセスの簡単な説明は次のとおりです。

画像

各デバイスがシステムに登録されると、対応するレコードがデータベースデバイステーブルに追加され、このデバイスのさまざまな情報が保存されます。

デバイスAの登録がこの暗黙の静けさを壊すまで、すべてが整然と進んでいました...

デバイスAが登録された後、データベースデバイステーブル に2つの新しいレコードが追加され、それらが 2つの同一の レコードであることに突然 気付きました。

目がくらんだと思い始めました...

よく見ると、実際に2つの新しい追加があり、一意のデバイスID(取り消し線、後でテストするため)と作成時間もまったく同じであることがわかります。

画面を見ると、迷ってしまいました...

なぜ2つあるのですか?

私の登録ロジックでは、ライブラリを削除する前に、データベースはまずデバイスがすでに存在するかどうかを確認し、存在する場合は既存のデバイスを更新し、存在しない場合は追加します。

この論理によると、2番目の同一のデータはどこから来たのですか?

2.真実の背後にある同時要求

調査と検討の結果、問題は登録リクエストにある可能性があることがわかりました。

デバイスAがhttp登録リクエストをクラウドに送信すると、複数の同一のリクエストを同時に送信する場合があります。

クラウドサーバーは、同時に複数のDockerコンテナーにデプロイされていました。ログを確認したところ、2つのコンテナーがデバイスAから同時に登録要求を受信したことがわかりました。

これから、私は推測します:

デバイスAは2つの登録リクエストを同時に送信しました。これらの2つのリクエストは、クラウド内の異なるコンテナに同時に送信されました。私の登録ロジックによれば、2つのコンテナが登録リクエストを受信した後、2つのコンテナが同時にデバイステーブルをクエリしました。データベース。、現時点では、デバイステーブルにデバイスAのレコードがないため、2つのコンテナが新しい操作を実行しました。速度が非常に速いため、2つの新しいレコード は作成時間に正確に2番目に作成されます。  、およびそれは違いを反映していません。

3.同時に追加された拡張機能

同時新規操作は問題を引き起こすので、同時更新操作に問題はありますか?

 

解決

同時加算を解決する

1.データベースの一意のインデックス(UNIQUE INDEX)

データベーステーブルを作成するときは、一意のフィールド(上記の一意のデバイス識別子など)に一意のインデックスを作成するか、結合後に一意になる複数のフィールドに共同の一意のインデックスを作成します。

このように、同時加算中は、1つの加算が成功する限り、データベースによってスローされる例外(java.sql.SQLIntegrityConstraintViolationException)により、他の加算操作は失敗します。加算の失敗に対処するだけで済みます。

注意唯一索引的字段需要非空,因为字段值为空时会导致唯一索引约束失效

2.Java分散ロック

プログラムに分散ロックを導入することにより、新しい操作を実行する前に分散ロックを取得する必要があり、取得は正常に続行されます。そうでない場合、追加は失敗します。

これにより、同時挿入によるデータ重複の問題も解決できますが、分散ロックの導入により、システムの複雑さも増します。データベースに格納するデータに一意のフィールドがある場合は、一意のフィールドを使用することをお勧めします。インデックス方式。

分散ロックを構築する過程で、Redisを使用する必要があります。ここでは、デバイス登録時に使用される分散ロックを例として取り上げます。

分散ロックに関する簡単な質問と回答:

Q:ロックとは正確には何ですか?

A:ロックは基本的にRedisに保存される文字列であり、特定のルール(例では固定プレフィックス+一意のデバイス識別子)に基づいて生成されます。これは、デバイスを登録するときに対応する独自のロックを持つことと同じです。デバイスに同時に複数の同一の登録要求が来ている場合でも、ロックを取得した1つの要求だけが正常に続行できます。

Q:ロックの取得とは何ですか?

A:同じデバイスの場合、同じルールに基づいて生成された文字列(以下のテキストでは文字列をキーと呼びます)は常に同じです。新しい操作を実行する前に、Redisに移動してキーが存在するかどうかを確認してください。すでに存在する場合はロック取得に失敗したことを意味し、存在しない場合はキーがRedisに保存されます。ストレージが成功した場合はロック取得が成功したことを意味し、ストレージが失敗した場合は引き続きロックの取得に失敗しました。

Q:ロックはどのように機能しますか?

A:前述のように、同じデバイスに対して同じルールに基づいて生成された文字列(Key)は常に同じです。現在のスレッドが新しい操作を実行する前に、まずKeyがRedisに存在するかどうかを確認します。すでに存在する場合は、つまり、この時点で、別のスレッドがロックを正常に取得し、現在のスレッドが実行したい新しい操作を実行しているため、現在のスレッドは後続の操作を実行する必要がありません(はい、冗長です)

このキーが存在しない場合は、他のスレッドがロックを取得しておらず、現在のスレッドが次のステップに進むことができることを意味します-キーをRedisにすばやく保存します。キーの保存に失敗すると、別のスレッドがプリエンプトします。キーが保存され、ロックが正常に取得され、現在のスレッドが1ステップ遅れ、実行したい作業が他のユーザーに取って代わられます(現在のスレッドは廃止できます)

このキーをRedisに保存することも成功した場合にのみ、現在のスレッドが最終的にロックを正常に取得したことを意味し、次の新しい操作を安全に実行できます。期間中に、同じ新しい操作を実行したい他のスレッドロックを取得できません。シーンを離れてさようなら:wave :、現在のスレッドが実行された後にロックを解除することを忘れないでください(Redisからキーを削除してください)。

登録時に使用される分散ロックコードは次のとおりです。

public class LockUtil {

    // 对redis底层set/get方法进行了简单封装的工具类
    @Autowired
    private RedisService redisService;

    // 生成锁的固定前缀,从配置文件读取值
    @Value("${redis.register.prefix}")
    private String REDIS_REGISTER_KEY_PREFIX;

    // 锁过期时间:即获取锁后线程能进行操作的最长时间,超过该时间后锁自动被释放(失效),别人可以重新开始获取锁进行对应操作
    // 设定锁过期时间是为了防止某线程成功获取锁后在执行任务过程中发生意外挂掉了造成锁永远无法被释放
    @Value("${redis.register.timeout}")
    private Long REDIS_REGISTER_TIMEOUT;

    /**
     * 获取设备注册时的分布式锁
     * @param deviceMacAddress 设备的Mac地址
     * @return
     */
    public boolean getRegisterLock(String deviceMacAddress) {
        if (StringUtils.isEmpty(deviceMacAddress)) {
            return false;
        }

        // 获取设备对应锁的字符串(Key)
        String redisKey = getRegisterLockKey(deviceMacAddress);

        // 开始尝试获取锁
        // 如果当前任务锁key已存在,则表示当前时间内有其他线程正在对该设备执行任务,当前线程可以退下了
        if (redisService.exists(redisKey)){
            return false;
        }

        // 开始尝试加锁,注意此处需使用SETNX指令(因为可能存在多个线程同时到达这一步开始加锁,使用SETNX来确保有且仅有一个设置成功返回)
        boolean setLock = redisService.setNX(redisKey, null);

        // 开始尝试设置锁过期时间,到了过期时间线程还没有释放锁的话,由保存锁的Redis来确保锁最终被释放,以免出现死锁
        // 锁过期时间的设置上,可以评估线程执行任务的正常用时,在正常用时的基础上稍微再大一点
        boolean setExpire = redisService.expire(redisKey, REDIS_REGISTER_TIMEOUT);

        // 设置锁和设置过期时间均成功时才认为当前线程获取锁成功,否则认为获取锁失败
        if (setLock && setExpire) {
            return true;
        }

        // 当发生设置锁成功,但设置过期时间失败的情况时,手动清除刚刚设置的锁Key
        redisService.del(redisKey);
        return false;
    }

    /**
     * 删除设备注册时的分布式锁
     * @param deviceMacAddress 设备的Mac地址
     */
    public void delRegisterLock(String deviceMacAddress) {
        redisService.del(getRegisterLockKey(deviceMacAddress));
    }

    /**
     * 获取设备注册时分布式锁的key
     * @param deviceMacAddress 设备mac地址(每个设备的mac地址都是唯一的)
     * @return
     */
    private String getRegisterLockKey(String deviceMacAddress) {
        return REDIS_REGISTER_KEY_PREFIX + "_" + deviceMacAddress;
    }
}

通常の登録ロジックでロックを使用する例は次のとおりです。

public ReturnObj registry(@RequestBody String device){
        Devices deviceInfo = JSON.parseObject(device, Devices.class);

        // 开始注册前加锁
        boolean registerLock = lockUtil.getRegisterLock(deviceInfo.getMacAddress());
        if (!registerLock) {
            log.info("获取设备注册锁失败,当前注册请求失败!");
            return ReturnObj.createBussinessErrorResult();
        }

        // 加锁成功,开始注册设备
        ReturnObj result = registerDevice(deviceInfo);

        // 注册设备完成,删除锁
        lockUtil.delRegisterLock(deviceInfo.getMacAddress());

        return result;
    }

同時更新を解決する

1.同時更新は本当に問題を引き起こしますか?

同時更新または1つずつ更新してもビジネスに影響がない場合は、システムの複雑さが無駄にならないように、処理を行う必要はありません。

2.楽観的ロック

繰り返しの更新は、楽観的なロックによって回避できます。つまり、データベーステーブルに「バージョン番号」(バージョン)フィールドを追加し、更新操作を実行する前にレコードをクエリし、クエリバージョン番号を書き留めてから、実際の更新操作を実行します。以前にクエリされたバージョン番号が現在のデータベースのレコードのバージョン番号と一致しているかどうかを判断する場合、一致している場合は、クエリから現在のスレッドの更新までの期間中に他のスレッドがレコードを更新していないことを意味します。これは一貫性がないことを意味します。つまり、この期間中に他のスレッドがこのレコードを変更し、現在のスレッドの更新操作は安全ではなくなり、破棄することしかできません。

判断SQLの例:

update a_table set name=test1, age=12, version=version+1 where id = 3 and version = 1

オプティミスティックロックは、バージョン番号を使用して、データベースから読み取られたデータが他のユーザーによって変更されたかどうかを最後の更新時に判別します。現在のスレッドクエリと最後の更新の間の期間であるため、その効率はペシミスティックロックよりも高くなります。 。、他のスレッドは通常と同じレコードを読み取ることができ、プリエンプティブに更新できます。

悲観的なロック

ペシミスティックロックはオプティミスティックロックの反対です。現在のスレッドがこのデータを更新するようにクエリすると、このデータがロックされ、更新が完了する前に他のスレッドがデータを変更することはできません。

これselect … for update を使用 して、データベースに「このデータを更新してロックします」と伝えます。

注:FOR UPDATEはInnoDBにのみ適用可能であり、トランザクションで有効である必要があります。クエリ条件に明確な主キーがあり、このレコードがある場合、それは行ロック(行ロック、に従って配置されたデータの行のみをロックします)です。クエリ条件)、クエリ条件主キーがない場合、または主キーが明確でない場合は、テーブルロックです(テーブルロック、テーブル全体のロックにより、テーブル全体のデータを変更できなくなります。ロック期間)、したがって、悲観的ロックを使用する場合、クエリ条件は特定の行またはいくつかのOKに明確に配置する必要があり、完全なテーブルロックを引き起こさないでください

 

最後に特典を送信します

これらの資料はすべて、Javaの電子書籍、学習ノート、最新の学習ルート、筆記試験の質問、面接の質問、開発ツール、PDFドキュメントブックのチュートリアル、私が編集した基本から熟練したビデオコースまでです。過去数年間。、Javaジョブアプリケーション再開テンプレート、Javaプログラマーが直面するその他の学習資料、自由に共有できます。すべての資料は、私のJavaテクノロジーqq交換グループ:127522921にあります。ルーチンはありません。ダウンロードしてください。あなた自身!私はそれらの多くをお金で買いました。グループへの参加を歓迎します。テクノロジーについて話し合うこともできます。参加を歓迎します。

おすすめ

転載: blog.csdn.net/deqing271/article/details/114640191