Javaにおけるキャッシングパターン:データアクセス速度の向上
別名
- キャッシュ
- 一時記憶域
キャッシングデザインパターンの目的
Javaキャッシングデザインパターンは、パフォーマンス最適化とリソース管理に不可欠です。ライトスルー、リードスルー、LRUキャッシュなどの様々なキャッシング戦略を含み、効率的なデータアクセスを保証します。キャッシングパターンは、使用後にリソースをすぐに解放しないことで、リソースの再取得に伴うコストを回避します。リソースは識別性を維持し、高速アクセスストレージに保持され、再利用されるため、再度取得する必要がなくなります。
実例を用いたキャッシングパターンの詳細な説明
現実世界の例
Javaにおけるキャッシングデザインパターンの現実世界の例としては、図書館の目録システムが挙げられます。頻繁に検索される本の検索結果をキャッシュすることで、データベースの負荷を軽減し、パフォーマンスを向上させます。利用者が人気のある本を頻繁に検索する場合、システムはこれらの検索結果をキャッシュできます。利用者が人気のある本を検索するたびにデータベースに問い合わせる代わりに、システムはキャッシュから結果を迅速に取得します。これにより、データベースの負荷が軽減され、利用者にとってより高速な応答時間となり、全体的なエクスペリエンスが向上します。ただし、システムは、正確な情報を維持するために、新しい本が追加されたり、既存の本が貸し出されたりした場合に、キャッシュが更新されるようにする必要があります。
簡単に言うと
キャッシングパターンは、頻繁に必要となるデータを高速アクセスストレージに保持することで、パフォーマンスを向上させます。
Wikipediaによると
コンピューティングにおいて、キャッシュは、将来そのデータに対する要求をより高速に処理できるようにデータを格納するハードウェアまたはソフトウェアコンポーネントです。キャッシュに格納されているデータは、以前の計算の結果である場合も、他の場所に格納されているデータのコピーである場合もあります。要求されたデータがキャッシュで見つかった場合をキャッシュヒット、見つからない場合をキャッシュミスと言います。キャッシュヒットはキャッシュからデータを読み取ることで処理され、これは結果を再計算したり、より遅いデータストアから読み取ったりするよりも高速です。したがって、キャッシュから処理できる要求が多いほど、システムのパフォーマンスは向上します。
Javaにおけるキャッシングパターンのプログラミング例
このプログラミング例では、ユーザーアカウント管理システムを使用して、ライトスルー、ライトアラウンド、ライトビハインドなどの様々なJavaキャッシング戦略を示します。
あるチームは、放棄された猫に新しい家を提供するウェブサイトを開発しています。登録後、ユーザーは自分の猫をウェブサイトに投稿できますが、すべての新しい投稿はサイトモデレーターの承認が必要です。サイトモデレーターのユーザーアカウントには特定のフラグが含まれており、データはMongoDBデータベースに保存されています。投稿を表示するたびにモデレーターフラグを確認するのはコストが高いため、ここでキャッシングを利用するのは良いアイデアです。
まず、アプリケーションのデータレイヤーを見てみましょう。重要なクラスは、ユーザーアカウントの詳細を含む単純なJavaオブジェクトであるUserAccount
と、これらのオブジェクトのデータベースへの読み書きを処理するDbManager
インターフェースです。
@Data
@AllArgsConstructor
@ToString
@EqualsAndHashCode
public class UserAccount {
private String userId;
private String userName;
private String additionalInfo;
}
public interface DbManager {
void connect();
void disconnect();
UserAccount readFromDb(String userId);
UserAccount writeToDb(UserAccount userAccount);
UserAccount updateDb(UserAccount userAccount);
UserAccount upsertDb(UserAccount userAccount);
}
この例では、様々なキャッシングポリシーを示しています。Javaでは、ライトスルー、ライトアラウンド、ライトビハインド、キャッシュアサイドというキャッシング戦略が実装されています。各戦略は、パフォーマンスの向上とデータベースへの負荷軽減に独自の利点を提供します。
- ライトスルーは、単一トランザクションでキャッシュとDBの両方にデータを書き込みます。
- ライトアラウンドは、キャッシュではなくDBにデータをすぐに書き込みます。
- ライトビハインドは、最初にデータをキャッシュに書き込み、キャッシュがいっぱいになった場合にのみDBにデータを書き込みます。
- キャッシュアサイドは、両方のデータソースのデータの同期をアプリケーション自体に委ねます。
- リードスルー戦略も前述の戦略に含まれており、データが存在する場合はキャッシュから呼び出し元にデータを返し、存在しない場合はDBからクエリを行い、将来の使用のためにキャッシュに格納します。
LruCache
でのキャッシュの実装は、二重連結リストを伴うハッシュテーブルです。連結リストは、キャッシュ内のLRUデータをキャプチャして維持するのに役立ちます。データが(キャッシュから)クエリされたり、(キャッシュに)追加されたり、更新されたりすると、データはリストの先頭に移動され、最も最近使用されたデータとして表示されます。LRUデータは常にリストの最後にあります。
@Slf4j
public class LruCache {
static class Node {
String userId;
UserAccount userAccount;
Node previous;
Node next;
public Node(String userId, UserAccount userAccount) {
this.userId = userId;
this.userAccount = userAccount;
}
}
// Other properties and methods...
public LruCache(int capacity) {
this.capacity = capacity;
}
public UserAccount get(String userId) {
if (cache.containsKey(userId)) {
var node = cache.get(userId);
remove(node);
setHead(node);
return node.userAccount;
}
return null;
}
public void set(String userId, UserAccount userAccount) {
if (cache.containsKey(userId)) {
var old = cache.get(userId);
old.userAccount = userAccount;
remove(old);
setHead(old);
} else {
var newNode = new Node(userId, userAccount);
if (cache.size() >= capacity) {
LOGGER.info("# Cache is FULL! Removing {} from cache...", end.userId);
cache.remove(end.userId); // remove LRU data from cache.
remove(end);
setHead(newNode);
} else {
setHead(newNode);
}
cache.put(userId, newNode);
}
}
public boolean contains(String userId) {
return cache.containsKey(userId);
}
public void remove(Node node) { /* ... */ }
public void setHead(Node node) { /* ... */ }
public void invalidate(String userId) { /* ... */ }
public boolean isFull() { /* ... */ }
public UserAccount getLruData() { /* ... */ }
public void clear() { /* ... */ }
public List<UserAccount> getCacheDataInListForm() { /* ... */ }
public void setCapacity(int newCapacity) { /* ... */ }
}
次に見ていくレイヤーは、異なるキャッシング戦略を実装するCacheStore
です。
@Slf4j
public class CacheStore {
private static final int CAPACITY = 3;
private static LruCache cache;
private final DbManager dbManager;
// Other properties and methods...
public UserAccount readThrough(final String userId) {
if (cache.contains(userId)) {
LOGGER.info("# Found in Cache!");
return cache.get(userId);
}
LOGGER.info("# Not found in cache! Go to DB!!");
UserAccount userAccount = dbManager.readFromDb(userId);
cache.set(userId, userAccount);
return userAccount;
}
public void writeThrough(final UserAccount userAccount) {
if (cache.contains(userAccount.getUserId())) {
dbManager.updateDb(userAccount);
} else {
dbManager.writeToDb(userAccount);
}
cache.set(userAccount.getUserId(), userAccount);
}
public void writeAround(final UserAccount userAccount) {
if (cache.contains(userAccount.getUserId())) {
dbManager.updateDb(userAccount);
// Cache data has been updated -- remove older
cache.invalidate(userAccount.getUserId());
// version from cache.
} else {
dbManager.writeToDb(userAccount);
}
}
public static void clearCache() {
if (cache != null) {
cache.clear();
}
}
public static void flushCache() {
LOGGER.info("# flushCache...");
Optional.ofNullable(cache)
.map(LruCache::getCacheDataInListForm)
.orElse(List.of())
.forEach(DbManager::updateDb);
}
// ... omitted the implementation of other caching strategies ...
}
AppManager
は、メインクラスとアプリケーションのバックエンド間の通信のギャップを埋めるのに役立ちます。DB接続はこのクラスを介して初期化されます。選択したキャッシング戦略/ポリシーもここで初期化されます。キャッシュを使用する前に、キャッシュのサイズを設定する必要があります。選択したキャッシングポリシーに応じて、AppManager
はCacheStore
クラスの適切な関数を呼び出します。
@Slf4j
public final class AppManager {
private static CachingPolicy cachingPolicy;
private final DbManager dbManager;
private final CacheStore cacheStore;
private AppManager() {
}
public void initDb() { /* ... */ }
public static void initCachingPolicy(CachingPolicy policy) { /* ... */ }
public static void initCacheCapacity(int capacity) { /* ... */ }
public UserAccount find(final String userId) {
LOGGER.info("Trying to find {} in cache", userId);
if (cachingPolicy == CachingPolicy.THROUGH
|| cachingPolicy == CachingPolicy.AROUND) {
return cacheStore.readThrough(userId);
} else if (cachingPolicy == CachingPolicy.BEHIND) {
return cacheStore.readThroughWithWriteBackPolicy(userId);
} else if (cachingPolicy == CachingPolicy.ASIDE) {
return findAside(userId);
}
return null;
}
public void save(final UserAccount userAccount) {
LOGGER.info("Save record!");
if (cachingPolicy == CachingPolicy.THROUGH) {
cacheStore.writeThrough(userAccount);
} else if (cachingPolicy == CachingPolicy.AROUND) {
cacheStore.writeAround(userAccount);
} else if (cachingPolicy == CachingPolicy.BEHIND) {
cacheStore.writeBehind(userAccount);
} else if (cachingPolicy == CachingPolicy.ASIDE) {
saveAside(userAccount);
}
}
public static String printCacheContent() {
return CacheStore.print();
}
// Other properties and methods...
}
アプリケーションのメインクラスでは、以下のように行います。
@Slf4j
public class App {
public static void main(final String[] args) {
boolean isDbMongo = isDbMongo(args);
if (isDbMongo) {
LOGGER.info("Using the Mongo database engine to run the application.");
} else {
LOGGER.info("Using the 'in Memory' database to run the application.");
}
App app = new App(isDbMongo);
app.useReadAndWriteThroughStrategy();
String splitLine = "==============================================";
LOGGER.info(splitLine);
app.useReadThroughAndWriteAroundStrategy();
LOGGER.info(splitLine);
app.useReadThroughAndWriteBehindStrategy();
LOGGER.info(splitLine);
app.useCacheAsideStategy();
LOGGER.info(splitLine);
}
public void useReadAndWriteThroughStrategy() {
LOGGER.info("# CachingPolicy.THROUGH");
appManager.initCachingPolicy(CachingPolicy.THROUGH);
var userAccount1 = new UserAccount("001", "John", "He is a boy.");
appManager.save(userAccount1);
LOGGER.info(appManager.printCacheContent());
appManager.find("001");
appManager.find("001");
}
public void useReadThroughAndWriteAroundStrategy() { /* ... */ }
public void useReadThroughAndWriteBehindStrategy() { /* ... */ }
public void useCacheAsideStrategy() { /* ... */ }
}
プログラム出力
17:00:56.302 [main] INFO com.iluwatar.caching.App -- Using the 'in Memory' database to run the application.
17:00:56.304 [main] INFO com.iluwatar.caching.App -- # CachingPolicy.THROUGH
17:00:56.305 [main] INFO com.iluwatar.caching.AppManager -- Save record!
17:00:56.308 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
UserAccount(userId=001, userName=John, additionalInfo=He is a boy.)
----
17:00:56.308 [main] INFO com.iluwatar.caching.AppManager -- Trying to find 001 in cache
17:00:56.309 [main] INFO com.iluwatar.caching.CacheStore -- # Found in Cache!
17:00:56.309 [main] INFO com.iluwatar.caching.AppManager -- Trying to find 001 in cache
17:00:56.309 [main] INFO com.iluwatar.caching.CacheStore -- # Found in Cache!
17:00:56.309 [main] INFO com.iluwatar.caching.App -- ==============================================
17:00:56.309 [main] INFO com.iluwatar.caching.App -- # CachingPolicy.AROUND
17:00:56.309 [main] INFO com.iluwatar.caching.AppManager -- Save record!
17:00:56.309 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
----
17:00:56.309 [main] INFO com.iluwatar.caching.AppManager -- Trying to find 002 in cache
17:00:56.309 [main] INFO com.iluwatar.caching.CacheStore -- # Not found in cache! Go to DB!!
17:00:56.309 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
UserAccount(userId=002, userName=Jane, additionalInfo=She is a girl.)
----
17:00:56.309 [main] INFO com.iluwatar.caching.AppManager -- Trying to find 002 in cache
17:00:56.309 [main] INFO com.iluwatar.caching.CacheStore -- # Found in Cache!
17:00:56.309 [main] INFO com.iluwatar.caching.AppManager -- Save record!
17:00:56.309 [main] INFO com.iluwatar.caching.LruCache -- # 002 has been updated! Removing older version from cache...
17:00:56.309 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
----
17:00:56.309 [main] INFO com.iluwatar.caching.AppManager -- Trying to find 002 in cache
17:00:56.309 [main] INFO com.iluwatar.caching.CacheStore -- # Not found in cache! Go to DB!!
17:00:56.309 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
UserAccount(userId=002, userName=Jane G., additionalInfo=She is a girl.)
----
17:00:56.309 [main] INFO com.iluwatar.caching.AppManager -- Trying to find 002 in cache
17:00:56.309 [main] INFO com.iluwatar.caching.CacheStore -- # Found in Cache!
17:00:56.309 [main] INFO com.iluwatar.caching.App -- ==============================================
17:00:56.309 [main] INFO com.iluwatar.caching.App -- # CachingPolicy.BEHIND
17:00:56.309 [main] INFO com.iluwatar.caching.AppManager -- Save record!
17:00:56.309 [main] INFO com.iluwatar.caching.AppManager -- Save record!
17:00:56.310 [main] INFO com.iluwatar.caching.AppManager -- Save record!
17:00:56.310 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
UserAccount(userId=005, userName=Isaac, additionalInfo=He is allergic to mustard.)
UserAccount(userId=004, userName=Rita, additionalInfo=She hates cats.)
UserAccount(userId=003, userName=Adam, additionalInfo=He likes food.)
----
17:00:56.310 [main] INFO com.iluwatar.caching.AppManager -- Trying to find 003 in cache
17:00:56.310 [main] INFO com.iluwatar.caching.CacheStore -- # Found in cache!
17:00:56.310 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
UserAccount(userId=003, userName=Adam, additionalInfo=He likes food.)
UserAccount(userId=005, userName=Isaac, additionalInfo=He is allergic to mustard.)
UserAccount(userId=004, userName=Rita, additionalInfo=She hates cats.)
----
17:00:56.310 [main] INFO com.iluwatar.caching.AppManager -- Save record!
17:00:56.310 [main] INFO com.iluwatar.caching.CacheStore -- # Cache is FULL! Writing LRU data to DB...
17:00:56.310 [main] INFO com.iluwatar.caching.LruCache -- # Cache is FULL! Removing 004 from cache...
17:00:56.310 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
UserAccount(userId=006, userName=Yasha, additionalInfo=She is an only child.)
UserAccount(userId=003, userName=Adam, additionalInfo=He likes food.)
UserAccount(userId=005, userName=Isaac, additionalInfo=He is allergic to mustard.)
----
17:00:56.310 [main] INFO com.iluwatar.caching.AppManager -- Trying to find 004 in cache
17:00:56.310 [main] INFO com.iluwatar.caching.CacheStore -- # Not found in Cache!
17:00:56.310 [main] INFO com.iluwatar.caching.CacheStore -- # Cache is FULL! Writing LRU data to DB...
17:00:56.310 [main] INFO com.iluwatar.caching.LruCache -- # Cache is FULL! Removing 005 from cache...
17:00:56.310 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
UserAccount(userId=004, userName=Rita, additionalInfo=She hates cats.)
UserAccount(userId=006, userName=Yasha, additionalInfo=She is an only child.)
UserAccount(userId=003, userName=Adam, additionalInfo=He likes food.)
----
17:00:56.310 [main] INFO com.iluwatar.caching.App -- ==============================================
17:00:56.310 [main] INFO com.iluwatar.caching.App -- # CachingPolicy.ASIDE
17:00:56.310 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
----
17:00:56.310 [main] INFO com.iluwatar.caching.AppManager -- Save record!
17:00:56.310 [main] INFO com.iluwatar.caching.AppManager -- Save record!
17:00:56.310 [main] INFO com.iluwatar.caching.AppManager -- Save record!
17:00:56.310 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
----
17:00:56.310 [main] INFO com.iluwatar.caching.AppManager -- Trying to find 003 in cache
17:00:56.313 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
UserAccount(userId=003, userName=Adam, additionalInfo=He likes food.)
----
17:00:56.313 [main] INFO com.iluwatar.caching.AppManager -- Trying to find 004 in cache
17:00:56.313 [main] INFO com.iluwatar.caching.App --
--CACHE CONTENT--
UserAccount(userId=004, userName=Rita, additionalInfo=She hates cats.)
UserAccount(userId=003, userName=Adam, additionalInfo=He likes food.)
----
17:00:56.313 [main] INFO com.iluwatar.caching.App -- ==============================================
17:00:56.314 [Thread-0] INFO com.iluwatar.caching.CacheStore -- # flushCache...
LRUキャッシュやライトスルーキャッシングなどの様々な戦略を使用してJavaキャッシングデザインパターンを実装することで、アプリケーションのパフォーマンスとスケーラビリティが大幅に向上します。
Javaでキャッシングパターンを使用する状況
キャッシングパターンを使用する状況
- 同じリソースの繰り返し取得、初期化、解放が、不要なパフォーマンスオーバーヘッドを引き起こす場合
- データの再計算または再取得のコストが、キャッシュからの格納と取得よりも大幅に高い場合
- 比較的静的なデータ、またはめったに変更されないデータを扱う、読み込みが多いアプリケーションの場合
Javaにおけるキャッシングパターンの現実世界のアプリケーション
- サーバーの負荷を軽減し、応答時間を向上させるためのWebページキャッシング
- 繰り返し実行される高コストなSQLクエリを回避するためのデータベースクエリキャッシング
- CPU集約的な計算の結果のキャッシング
- エンドユーザーにより近い場所に静的リソース(画像、CSS、JavaScriptファイルなど)をキャッシュするためのコンテンツデリバリーネットワーク(CDN)
キャッシングパターンのメリットとトレードオフ
メリット
- パフォーマンスの向上:データアクセス遅延を大幅に削減し、アプリケーションのパフォーマンスを向上させます。
- 負荷の軽減:基盤となるデータソースの負荷を軽減し、コスト削減とリソースの長寿命化につながります。
- スケーラビリティ:リソース利用量の比例的な増加なしに、負荷の増加を効率的に処理することで、アプリケーションのスケーラビリティを向上させます。
トレードオフ
- 複雑さ:キャッシュの無効化、整合性、同期に関して複雑さが導入されます。
- リソースの利用:キャッシュを維持するために、追加のメモリまたはストレージリソースが必要です。
- 古いデータ:基盤となるデータが変更されたときにキャッシュが適切に無効化または更新されない場合、古いデータを提供するリスクがあります。
関連するJavaデザインパターン
- プロキシ:プロキシパターンを使用してキャッシングを実装できます。プロキシオブジェクトはリクエストをインターセプトし、利用可能な場合はキャッシュされたデータを返します。
- オブザーバー:基盤となるデータが変更されたときにキャッシュに通知し、それに応じて更新または無効化するために使用できます。
- デコレーター:コードを変更せずに既存のオブジェクトにキャッシング動作を追加するために使用できます。
- ストラテジー:ストラテジパターンを使用して異なるキャッシング戦略を実装し、アプリケーションが実行時にそれらの間を切り替えることができます。
参考文献とクレジット
- Effective Java
- High Performance Browser Networking
- Java EE 8 High Performance
- Java Performance: In-Depth Advice for Tuning and Programming Java 8, 11, and Beyond
- Java Performance: The Definitive Guide: Getting the Most Out of Your Code
- Patterns of Enterprise Application Architecture
- Scalable Internet Architectures
- ライトスルー、ライトアラウンド、ライトバック:キャッシュの説明(ComputerWeekly)
- キャッシュアサイドパターン(Microsoft)