Redis_データベースとキャッシュの二重書き込みの整合性を確保する方法の詳細な説明

序文

データベースおよびキャッシュ (redis など) における二重書き込みデータの整合性の問題は、開発言語とは関係のない公的な問題です。特に同時実行性が高いシナリオでは、この問題はさらに深刻になります。
面接でも仕事でも、この問題に遭遇する可能性は非常に高いので、あなたと話し合うことが非常に必要であることを、私には責任を持ってお伝えします。
今日の記事では、浅いところから深いところまで、データベースとキャッシュの二重書き込みデータの一貫性の問題に対する一般的な解決策、これらの解決策で考えられる落とし穴、最適な解決策とは何かについてお話します。

1. 共通スキーム

通常、キャッシュを使用する主な目的は、クエリのパフォーマンスを向上させることです。ほとんどの場合、キャッシュは次のように使用します。
ここに画像の説明を挿入
ユーザーがリクエストした後、まずキャッシュにデータがあるかどうかを確認し、存在する場合は直接データを返します。
キャッシュにデータがない場合は、データベースの確認を続けます。
データベースにデータがある場合は、クエリされたデータをキャッシュに入れてから、データを返します。
データベースにデータがない場合は、直接空を返します。

これはキャッシュの非常に一般的な使用法です。一見すると何の問題もないように見えます。
しかし、非常に重要な点を見落としていました。データベース内のデータがキャッシュに入れられた直後に更新された場合、キャッシュを更新するにはどうすればよいでしょうか?
キャッシュラインの更新が機能しないのでしょうか?

回答: もちろん、そうではありません。キャッシュが長期間 (キャッシュの有効期限に応じて) 更新されない場合、ユーザー リクエストはデータベースの最新の値ではなく、キャッシュから古い値を取得する可能性があります。これはデータの不整合の問題ではないでしょうか?

では、キャッシュを更新するにはどうすればよいでしょうか?
現在、解決策は 4 つあります。
最初にキャッシュを書き込み、次にデータベースを書き込み、
最初にデータベースを書き込み
、次にキャッシュを書き込みます。最初にキャッシュを削除し、次にデータベースを書き込みます。最初にデータベースに書き込み
、その後キャッシュを削除します
。 次に話しましょう。これら 4 つのソリューションについて詳しく説明します。

2. 最初にキャッシュを書き込み、次にデータベースを書き込みます

キャッシュを更新するスキームとして、多くの人が最初に考えるのは、書き込み操作中にキャッシュ (ライト キャッシュ) を直接更新することかもしれません。これはより簡単です。
ここで疑問が生じます。書き込み操作では、キャッシュを最初に書き込むべきですか、それともデータベースを最初に書き込むべきですか?
最初にキャッシュが書き込まれ、次にデータベースが書き込まれる状況について説明します。これには最も深刻な問題があるためです。
ここに画像の説明を挿入
特定のユーザーの書き込み操作ごとに、キャッシュに書き込みを行ったばかりの場合、突然ネットワークに異常が発生し、データベースへの書き込みが失敗します。その結果、キャッシュは最新のデータに更新されますが、データベースは最新のデータに更新されず、キャッシュ内のデータがダーティデータになってしまうことはありませんか?この時点でユーザーのクエリ要求がデータを読み取るだけの場合、データはデータベースにまったく存在しないため、問題が発生します。この問題は非常に深刻です。

キャッシュの主な目的は、データベース内のデータを一時的にメモリに保存することであることは誰もが知っています。これは、後続のクエリに便利で、クエリ速度を向上させます。
しかし、データの一部がデータベースに存在しない場合、この「偽のデータ」をキャッシュすることに何の意味があるのでしょうか?
したがって、最初にキャッシュを書き込んでからデータベースを書き込むことはお勧めできず、実際の作業ではあまり使用されません。

3. 最初にデータベースに書き込み、次にキャッシュに書き込みます。

上記のスキームは機能しないため、まずデータベースを書き込んでからキャッシュを書き込むスキームについて説明します。このスキームは同時実行性の低いプログラミングで使用されます (おそらく)。
ここに画像の説明を挿入
ユーザーの書き込み操作では、最初にデータベースに書き込み、次にキャッシュに書き込みます。これにより、以前の「偽データ」の問題を回避できます。しかし、それは新たな問題を引き起こしました。
どうしたの?

3.1 ライトキャッシュの失敗

データベースへの書き込み操作とキャッシュへの書き込み操作が同じトランザクション内に配置されている場合、書き込みキャッシュが失敗したときに、データベースに書き込まれたデータをロールバックできます。
ここに画像の説明を挿入
同時実行性が比較的低く、インターフェイスのパフォーマンスに対する要件が低いシステムの場合は、このようにプレイできます。

ただし、同時実行性の高いビジネス シナリオでは、データベースへの書き込みとキャッシュへの書き込みは両方ともリモート操作です。大規模なトランザクションによって引き起こされるデッドロックの問題を防ぐために、通常は、同じトランザクション内でデータベースへの書き込みとキャッシュへの書き込みを行わないことをお勧めします。

つまり、このスキームでは、データベースへの書き込みは成功しても書き込みキャッシュが失敗した場合、データベースに書き込まれたデータはロールバックされません。
データベースは新しいデータですが、キャッシュは古いデータであり、両方のデータが矛盾していることが表示されます。

3.2 同時実行性が高い場合の問題

同時実行性の高いシナリオで、同じユーザーの同じデータに対して、a と b という 2 つのデータ書き込み要求があり、これらが同時にビジネス システムに要求されたとします。
次の図に示すように、このうちリクエスト a は古いデータを取得し、リクエスト b は新しいデータを取得します。

ここに画像の説明を挿入

データベースの書き込みが完了したばかりなので、a に先に来てもらいます。しかし、ネットワーク上の理由でしばらくフリーズし、キャッシュを書き込む時間がありませんでした。
このとき、リクエストbが来て、先にデータベースが書き込まれました。
次に、リクエスト b はキャッシュに正常に書き込みます。
この時点で、リクエスト a はスタックされ、キャッシュにも書き込まれます。
明らかに、このプロセス中に、キャッシュ内のリクエスト b の新しいデータがリクエスト a の古いデータによって上書きされます。
つまり、同時実行性の高いシナリオで、複数のスレッドが最初にデータベースに書き込み、次にキャッシュに書き込む操作を同時に実行すると、データベースには新しい値が含まれ、キャッシュには古い値が含まれる可能性があります。また、両側のデータが矛盾している可能性があります。

3.3. システムリソースの無駄遣い

このソリューションのもう 1 つの大きな問題は、各書き込み操作の後にデータベースがすぐにキャッシュに書き込まれ、システム リソースが無駄になることです。
なぜそんなことを言うのですか?
ライト キャッシュが単純なデータ コンテンツではなく、非常に複雑な計算の後の最終結果であることは想像できるでしょう。このように、キャッシュに書き込むたびに非常に複雑な計算が必要となり、システムリソースの無駄ではないでしょうか。
特にCPUとメモリリソース。
特殊なビジネス シナリオもいくつかあります。つまり、書き込み量を増やし、読み取り量を減らします。
このタイプのビジネス シナリオでは、使用される各書き込み操作を 1 回キャッシュに書き込む必要がある場合、損失が利益を上回ります。
最初にデータベースに書き込み、次にキャッシュに書き込むという同時実行性の高いシナリオでは、このソリューションには多くの問題があるため、推奨されないことがわかります。
すでに使っている方は急いでピットを踏んでみてはいかがでしょうか?

4. まずキャッシュを削除してから、データベースに書き込みます

上記の内容から、キャッシュを直接更新すると多くの問題が発生することがわかります。
では、なぜキャッシュを直接更新するのではなく、キャッシュを削除するという考え方を変えることができないのでしょうか?
キャッシュを削除するには、
最初にキャッシュを削除してからデータベースに書き込む方法と、
最初にデータベースに書き込んでからキャッシュを削除する方法の 2 つがあります。

一緒に見てみましょう。まずキャッシュを削除してから、データベースに書き込みます。

ここに画像の説明を挿入

率直に言うと、ユーザーの書き込み操作では、最初にキャッシュの削除操作が実行され、その後データベースが書き込まれます。この一連の計画は可能かもしれませんが、同じ問題が発生するでしょう。

4.1 同時実行性が高い場合の問題

同時実行性の高いシナリオで、同じユーザーの同じデータに対して、データ読み取り要求 c と別のデータ書き込み要求 d (更新操作) があり、その要求が次の時点でビジネス システムに送信されるとします。同じ時間です。以下の図に示すように、
ここに画像の説明を挿入
リクエスト d が最初に来て、キャッシュを削除します。ただし、ネットワーク上の理由により、データベースに書き込む前にしばらくフリーズしました。
このとき、リクエストcが来ました。まずキャッシュを確認するとデータがありませんでした。次にデータベースを確認すると、データはありますが古い値です。
c にデータベース内の古い値をキャッシュに更新するよう要求します。
この時点で、リクエストのフリーズは終了し、新しい値がデータベースに書き込まれます。
このプロセス中、リクエスト d の新しい値はリクエスト c によってキャッシュに書き込まれません。これにより、キャッシュとデータベースの間でデータの不整合が発生します。

では、このシナリオにおけるデータの不整合の問題は解決できるのでしょうか?

4.2 キャッシュの二重削除

上記のビジネス シナリオでは、1 つの読み取りデータ要求と 1 つの書き込みデータ要求があります。データ書き込みリクエストがキャッシュを削除すると、データ読み取りリクエストはその時点でデータベースからクエリされた古い値をキャッシュに書き込む可能性があります。
扱いにくいという意見もありますが、データベースを書き込んだ後に再度キャッシュの削除を要求すれば良いのではないでしょうか?

ここに画像の説明を挿入

これはキャッシュの二重削除と呼ばれるものです。つまり、データベースに書き込む前に一度削除し、データベースに書き込んだ後に再度削除します。
このソリューションの非常に重要な部分は、2 回目のキャッシュの削除は、すぐにではなく、一定の時間間隔後に行われることです。
次の同時実行性の高い読み取りデータ要求と書き込みデータ要求によって引き起こされるデータの不整合のプロセスをもう一度見てみましょう。
要求 d が最初に来て、キャッシュが削除されます。ただし、ネットワーク上の理由により、データベースに書き込む前にしばらくフリーズしました。

このとき、リクエストcが来ました。まずキャッシュを確認するとデータがありませんでした。次にデータベースを確認すると、データはありますが古い値です。
c にデータベース内の古い値をキャッシュに更新するよう要求します。
この時点で、リクエストのフリーズは終了し、新しい値がデータベースに書き込まれます。
一定の時間が経過した後 (例: 500 ミリ秒)、d にキャッシュの削除を要求します。

このようにして、キャッシュの不整合の問題を実際に解決できます。
では、なぜ一定期間後にキャッシュを削除する必要があるのでしょうか?
リクエスト d がフリーズし、新しい値をデータベースに書き込んだ後、リクエスト c はデータベース内の古い値をキャッシュに更新します。

この時点で、リクエスト d の削除が早すぎると、リクエスト c がデータベース内の古い値をキャッシュに更新する前にキャッシュが削除されてしまい、この削除は無意味になります。古い値が適時に削除されるように、c にキャッシュの更新を要求した後、キャッシュを削除する必要があります。
したがって、古い値がキャッシュに設定されている場合、リクエスト c またはリクエスト c に類似した他のリクエストが最終的にリクエスト d によって削除されることを保証するために、リクエスト d に時間間隔を追加する必要があります。
次に、2回目のキャッシュ削除時に削除に失敗した場合はどうすればよいでしょうか?
最初にちょっとした不安はここに置いておいて、それについては後ほど詳しく説明します。

5. 最初にデータベースに書き込み、次にキャッシュを削除します

上記のことからわかるように、最初にキャッシュを削除してからデータベースに書き込みますが、同時実行の場合は、キャッシュとデータベースの間でデータの不整合が発生する可能性もあります。
したがって、最終的な解決を期待することしかできません。
次に、最初にデータベースに書き込んでからキャッシュを削除するという解決策に焦点を当てましょう。
ここに画像の説明を挿入
同時実行性の高いシナリオでは、データの読み取りリクエストとデータの書き込みリクエストがあり、更新プロセスは次のとおりです:
リクエスト e は最初にデータベースに書き込みますが、ネットワーク上の理由によりしばらくフリーズします。キャッシュを削除する時間がありません。
f にキャッシュをクエリし、キャッシュ内のデータを検索し、データを直接返すようにリクエストします。

e にキャッシュの削除を要求します。
この処理では、リクエスト f のみが古いデータを一度読み出し、その後リクエスト e によって古いデータが削除されるため、大きな問題はないと思われます。
しかし、データ読み取りリクエストが最初に来た場合はどうなるでしょうか?

f にキャッシュをクエリし、キャッシュ内のデータを検索し、データを直接返すようにリクエストします。
最初に e にデータベースへの書き込みを要求します。
e にキャッシュの削除を要求します。
これで大丈夫でしょうか?

答え: はい。
しかし、次のような状況、つまりキャッシュ自体が失敗することが発生するのではないかと心配しています。以下に示すように:

ここに画像の説明を挿入

キャッシュの有効期限が切れると、自動的に無効になります。
f にキャッシュのクエリを要求します。キャッシュにはデータがなく、データベースの古い値がクエリされますが、ネットワーク上の理由により、キャッシュがスタックしており、キャッシュを更新する時間がありません。
リクエスト e は、まずデータベースに書き込み、次にキャッシュを削除します。
f に古い値をキャッシュに更新するよう要求します。
このとき、キャッシュとデータベースのデータも不整合になります。

ただし、この状況は比較的まれであり、次の条件が同時に満たされる必要があります:
キャッシュがたまたま自動的に無効化されるだけです。

f にデータベースから古い値を見つけてキャッシュを更新するようにリクエストするのは、e にデータベースに書き込んでキャッシュを削除するようにリクエストするよりも時間がかかります。
データベースへのクエリの速度は、データベースへの書き込み後のキャッシュの削除はもちろんのこと、データベースへの書き込みよりも一般的に速いことは誰もが知っています。したがって、ほとんどの場合、データ要求の書き込みはデータの読み取りよりも時間がかかります。

システムが上記 2 つの条件を同時に満たす確率は非常に低いことがわかります。
データ不整合の問題を 100% 回避できるわけではありませんが、他の解決策に比べて問題が発生する確率が最も低いため、データベースに書き込んでからキャッシュを削除する解決策を使用することをお勧めします。
しかし、このシナリオでキャッシュの削除に失敗したらどうなるでしょうか?

6. キャッシュの削除に失敗した場合はどうすればよいですか?

実際、最初にデータベースに書き込んでからキャッシュを削除するスキームは、キャッシュを二重削除するスキームと同じであり、共通のリスク ポイントがあります。つまり、キャッシュの削除に失敗すると、キャッシュ内のデータが失われるということです。そしてデータベースも不整合になります。
では、キャッシュの削除に失敗した場合はどうすればよいのでしょうか?

回答: 再試行メカニズムを追加する必要があります。
インターフェイスでは、データベースの更新は成功したが、キャッシュの更新に失敗した場合、すぐに 3 回再試行できます。いずれかが成功した場合は、成功が直接返されます。3 回とも失敗した場合は、後続の処理のためにデータベースに書き込まれます。
もちろん、インターフェイスの同時実行性が比較的高いときに、インターフェイスで直接同期的に再試行すると、インターフェイスのパフォーマンスがわずかに影響を受ける可能性があります。
このとき、非同期リトライに変更する必要があります。

非同期に再試行するにはさまざまな方法があります。たとえば、
毎回別のスレッドが開始され、このスレッドは再試行の作業専用になります。ただし、同時実行性が高いシナリオでは、作成されるスレッドが多すぎてシステム OOM の問題が発生する可能性があるため、使用することはお勧めできません。
再試行されたタスクはスレッド プールに引き渡されますが、サーバーが再起動すると一部のデータが失われる可能性があります。
再試行データをテーブルに書き込み、elastic-job およびその他のスケジュールされたタスクを使用して再試行します。
再試行されたリクエストをmqなどのメッセージミドルウェアに書き込み、mqのコンシューマで処理します。
mysqlのbinlogを購読し、その中でデータ更新リクエストがあった場合、該当するキャッシュを削除します。

7. スケジュールされたタスク

スケジュールされたタスクの再試行を使用する具体的なスキームは次のとおりです。
ユーザー操作がデータベースへの書き込みを終了したが、キャッシュの削除に失敗した場合、ユーザー データを再試行テーブルに書き込む必要があります。以下に示すように:

ここに画像の説明を挿入

スケジュールされたタスクで、再試行テーブル内のユーザー データを非同期的に読み取ります。再試行テーブルには再試行回数フィールドを記録する必要があります。初期値は 0 です。その後 5 回再試行し、継続的にキャッシュを削除し、再試行するたびにこのフィールドの値に 1 を加えます。いずれかが成功した場合は、success を返します。5 回再試行しても失敗する場合は、再試行テーブルに失敗ステータスを記録し、さらなる処理を待つ必要があります。

ここに画像の説明を挿入

同時実行性の高いシナリオでは、スケジュールされたタスクに elastic-job を使用することをお勧めします。xxl-jobなどのタイミングタスクに比べて、分割して処理できるため処理速度が向上します。同時に、各スライスの間隔を 1、2、3、5、7 秒などに設定できます。
タイミング タスクにもっと興味がある場合は、現在最も主流のタイミング タスクをリストした私の別の記事「これら 10 種類のタイミング タスクを学ぶ、私は少し飛んでいます」を読むことができます。
スケジュールされたタスクを使用してリトライする場合、リアルタイム性がそれほど高くないという欠点があり、特にリアルタイム性の要求が高いビジネス シナリオにはこのソリューションは適していません。ただし、一般的なシナリオでは引き続き使用できます。
ただし、データはデータベースに保存されるため、データが失われることはないという大きな利点があります。

8.mq

同時実行性の高いビジネス シナリオでは、mq (メッセージ キュー) は不可欠なテクノロジの 1 つです。非同期的に分離できるだけでなく、山を切り取って谷を埋めることもできます。システムの安定性を確保することは非常に有意義です。
MQ に興味のある友人は、私の別の記事「MQ に関する壊れたもの」を読んでください。
mq のプロデューサーは、メッセージを生成した後、指定されたトピックを通じてメッセージを mq サーバーに送信します。次に、mq コンシューマーはトピックのメッセージをサブスクライブし、メッセージ データを読み取り、ビジネス ロジック処理を実行します。
mq retry を使用する具体的なスキームは次のとおりです。
ここに画像の説明を挿入

ユーザー操作がデータベースへの書き込みを終了してもキャッシュの削除に失敗すると、mq メッセージが生成され、mq サーバーに送信されます。
mq コンシューマーは mq メッセージを読み取り、キャッシュの削除を 5 回再試行します。いずれかが成功した場合は、success を返します。5 回再試行しても失敗した場合は、デッドレターキューに書き込まれます。
mq には rocketmq を使用することをお勧めします。再試行メカニズムとデッドレターキューはデフォルトでサポートされています。非常に使いやすく、連続メッセージ、遅延メッセージ、トランザクション メッセージなどのさまざまなビジネス シナリオもサポートします。
もちろん、このソリューションでは、キャッシュの削除を完全に非同期で行うことができます。つまり、ユーザーの書き込み操作では、データベースへの書き込み直後にキャッシュを削除する必要はありません。代わりに、mq メッセージを mq サーバーに直接送信すると、mq コンシューマがキャッシュを削除するタスクのみを担当します。
mq のリアルタイム パフォーマンスは比較的高いため、改良されたソリューションも良い選択です。

9. ビンログ

前に説明したように、スケジュールされたタスクであっても mq (メッセージ キュー) であっても、再試行メカニズムはビジネスにとってやや煩わしいものです。
時限タスクを利用する仕組みでは、業務コードにロジックを追加する必要があり、キャッシュの削除に失敗した場合はリトライテーブルにデータを書き込む必要があります。
mqを利用した仕組みでは、キャッシュの削除に失敗した場合、業務コード内でmqサーバにmqメッセージを送信する必要があります。
実際には、canal などのミドルウェアを使用するなど、binlog を監視する、より洗練された実装があります。
具体的な計画は次のとおりです。
ここに画像の説明を挿入
ビジネス インターフェイスにデータベースを書き込んだ後は問題なく、直接成功を返します。
mysql サーバーは、変更されたデータを自動的に binlog に書き込みます。
binlog サブスクライバは変更されたデータを取得し、キャッシュを削除します。
このソリューションのビジネス インターフェイスにより、いくつかのプロセスが大幅に簡素化されます。データベースの操作に気を配り、binlog サブスクライバーでキャッシュの削除作業を行うだけで済みます。
ただし、図のスキームに従ってキャッシュを削除し、一度だけ削除すると、失敗する可能性があります。

この問題を解決するにはどうすればよいでしょうか?
回答: これには、前述した再試行メカニズムを追加する必要があります。キャッシュの削除に失敗した場合は、再試行テーブルに書き込み、スケジュールされたタスクを使用して再試行します。または、mq に書き込み、mq が自動的に再試行できるようにします。
ここでは、mq 自動再試行メカニズムを使用することをお勧めします。

ここに画像の説明を挿入

binlog サブスクライバでキャッシュの削除が失敗した場合、mq メッセージが mq サーバーに送信され、mq コンシューマは自動的に 5 回再試行します。成功した場合は、直接成功を返します。5 回再試行しても失敗する場合、メッセージは自動的にデッドレターキューに入れられ、後で手動介入が必要になる場合があります。

おすすめ

転載: blog.csdn.net/chuige2013/article/details/129093974