Redisは、高速なインメモリデータストアとして、キャッシュ、セッション管理、リアルタイム機能など幅広い用途で活用されています。本記事では、Redisを使った効果的なキャッシュ戦略を実践的に解説します。
Redisの基本
接続設定
// redis.ts
import Redis from 'ioredis';
// シングルインスタンス
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD,
db: 0,
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: true,
});
// クラスター構成
const cluster = new Redis.Cluster([
{ host: 'redis-node-1', port: 6379 },
{ host: 'redis-node-2', port: 6379 },
{ host: 'redis-node-3', port: 6379 },
], {
redisOptions: {
password: process.env.REDIS_PASSWORD,
},
scaleReads: 'slave', // 読み取りをスレーブに分散
maxRedirections: 16,
});
// 接続イベント
redis.on('connect', () => console.log('Redis connected'));
redis.on('error', (err) => console.error('Redis error:', err));
redis.on('close', () => console.log('Redis connection closed'));
export { redis, cluster };
データ型の概要
| データ型 | 用途 |
|---|---|
| String | キャッシュ、カウンター、セッション |
| Hash | オブジェクトの保存、ユーザープロファイル |
| List | キュー、タイムライン、ログ |
| Set | タグ、ユニーク値、関係性 |
| Sorted Set | ランキング、スコアボード、タイムライン |
| Stream | イベントソーシング、メッセージング |
| JSON | 複雑なオブジェクト(RedisJSON) |
キャッシュパターン
Cache-Aside パターン
// cache-aside.ts
import { redis } from './redis';
interface CacheOptions {
ttl?: number; // 秒
prefix?: string;
}
class CacheAside<T> {
private prefix: string;
private defaultTtl: number;
constructor(prefix: string, defaultTtl: number = 3600) {
this.prefix = prefix;
this.defaultTtl = defaultTtl;
}
private getKey(id: string): string {
return `${this.prefix}:${id}`;
}
async get(id: string): Promise<T | null> {
const cached = await redis.get(this.getKey(id));
if (cached) {
return JSON.parse(cached);
}
return null;
}
async set(id: string, data: T, ttl?: number): Promise<void> {
const key = this.getKey(id);
const serialized = JSON.stringify(data);
await redis.setex(key, ttl || this.defaultTtl, serialized);
}
async getOrFetch(
id: string,
fetcher: () => Promise<T | null>,
ttl?: number
): Promise<T | null> {
// 1. キャッシュを確認
const cached = await this.get(id);
if (cached) {
return cached;
}
// 2. データソースから取得
const data = await fetcher();
if (data === null) {
return null;
}
// 3. キャッシュに保存
await this.set(id, data, ttl);
return data;
}
async invalidate(id: string): Promise<void> {
await redis.del(this.getKey(id));
}
async invalidatePattern(pattern: string): Promise<void> {
const keys = await redis.keys(`${this.prefix}:${pattern}`);
if (keys.length > 0) {
await redis.del(...keys);
}
}
}
// 使用例
interface User {
id: string;
name: string;
email: string;
}
const userCache = new CacheAside<User>('user', 3600);
async function getUser(id: string): Promise<User | null> {
return userCache.getOrFetch(id, async () => {
// データベースから取得
const user = await db.user.findUnique({ where: { id } });
return user;
});
}
async function updateUser(id: string, data: Partial<User>): Promise<User> {
// データベースを更新
const user = await db.user.update({
where: { id },
data,
});
// キャッシュを無効化
await userCache.invalidate(id);
return user;
}
Write-Through パターン
// write-through.ts
class WriteThrough<T extends { id: string }> {
private cache: CacheAside<T>;
constructor(
prefix: string,
private repository: Repository<T>,
ttl: number = 3600
) {
this.cache = new CacheAside<T>(prefix, ttl);
}
async get(id: string): Promise<T | null> {
return this.cache.getOrFetch(id, () => this.repository.findById(id));
}
async create(data: Omit<T, 'id'>): Promise<T> {
// 1. データベースに書き込み
const entity = await this.repository.create(data);
// 2. 同時にキャッシュにも書き込み
await this.cache.set(entity.id, entity);
return entity;
}
async update(id: string, data: Partial<T>): Promise<T> {
// 1. データベースを更新
const entity = await this.repository.update(id, data);
// 2. キャッシュも更新
await this.cache.set(id, entity);
return entity;
}
async delete(id: string): Promise<void> {
// 1. データベースから削除
await this.repository.delete(id);
// 2. キャッシュも削除
await this.cache.invalidate(id);
}
}
Write-Behind パターン
// write-behind.ts
import { redis } from './redis';
class WriteBehind<T extends { id: string }> {
private pendingWrites: Map<string, T> = new Map();
private flushInterval: NodeJS.Timeout;
constructor(
private prefix: string,
private repository: Repository<T>,
private batchSize: number = 100,
private flushIntervalMs: number = 5000
) {
// 定期的にバッチ書き込み
this.flushInterval = setInterval(
() => this.flush(),
flushIntervalMs
);
}
async write(entity: T): Promise<void> {
// 1. キャッシュに即座に書き込み
await redis.setex(
`${this.prefix}:${entity.id}`,
3600,
JSON.stringify(entity)
);
// 2. ペンディングキューに追加
this.pendingWrites.set(entity.id, entity);
// バッチサイズに達したらフラッシュ
if (this.pendingWrites.size >= this.batchSize) {
await this.flush();
}
}
async get(id: string): Promise<T | null> {
// ペンディングにあればそれを返す
if (this.pendingWrites.has(id)) {
return this.pendingWrites.get(id)!;
}
// キャッシュを確認
const cached = await redis.get(`${this.prefix}:${id}`);
if (cached) {
return JSON.parse(cached);
}
// データベースから取得
return this.repository.findById(id);
}
private async flush(): Promise<void> {
if (this.pendingWrites.size === 0) return;
const writes = Array.from(this.pendingWrites.values());
this.pendingWrites.clear();
try {
// バッチでデータベースに書き込み
await this.repository.upsertMany(writes);
console.log(`Flushed ${writes.length} writes to database`);
} catch (error) {
// 失敗した場合はペンディングに戻す
for (const entity of writes) {
this.pendingWrites.set(entity.id, entity);
}
console.error('Flush failed:', error);
}
}
async stop(): Promise<void> {
clearInterval(this.flushInterval);
await this.flush();
}
}
TTL戦略
適応型TTL
// adaptive-ttl.ts
interface CacheMetrics {
hits: number;
misses: number;
lastAccess: number;
}
class AdaptiveTTLCache<T> {
private metrics: Map<string, CacheMetrics> = new Map();
private readonly minTtl: number;
private readonly maxTtl: number;
private readonly baseTtl: number;
constructor(
private prefix: string,
options: { minTtl?: number; maxTtl?: number; baseTtl?: number } = {}
) {
this.minTtl = options.minTtl || 60; // 1分
this.maxTtl = options.maxTtl || 86400; // 24時間
this.baseTtl = options.baseTtl || 3600; // 1時間
}
private calculateTtl(key: string): number {
const metric = this.metrics.get(key);
if (!metric) {
return this.baseTtl;
}
const hitRate = metric.hits / (metric.hits + metric.misses + 1);
const timeSinceLastAccess = Date.now() - metric.lastAccess;
// ヒット率が高いほどTTLを長く
let ttl = this.baseTtl * (1 + hitRate);
// アクセス頻度が高いほどTTLを長く
if (timeSinceLastAccess < 60000) { // 1分以内
ttl *= 1.5;
}
return Math.min(Math.max(ttl, this.minTtl), this.maxTtl);
}
async get(key: string): Promise<T | null> {
const fullKey = `${this.prefix}:${key}`;
const cached = await redis.get(fullKey);
const metric = this.metrics.get(key) || { hits: 0, misses: 0, lastAccess: 0 };
if (cached) {
metric.hits++;
metric.lastAccess = Date.now();
this.metrics.set(key, metric);
return JSON.parse(cached);
}
metric.misses++;
this.metrics.set(key, metric);
return null;
}
async set(key: string, data: T): Promise<void> {
const fullKey = `${this.prefix}:${key}`;
const ttl = Math.round(this.calculateTtl(key));
await redis.setex(fullKey, ttl, JSON.stringify(data));
}
}
階層型キャッシュ
// tiered-cache.ts
class TieredCache<T> {
private l1: Map<string, { data: T; expiry: number }> = new Map();
private l1MaxSize: number;
private l1Ttl: number;
constructor(
private prefix: string,
private l2Ttl: number = 3600,
l1Options: { maxSize?: number; ttl?: number } = {}
) {
this.l1MaxSize = l1Options.maxSize || 1000;
this.l1Ttl = l1Options.ttl || 60;
}
async get(key: string): Promise<T | null> {
// L1 (インメモリ) チェック
const l1Entry = this.l1.get(key);
if (l1Entry && l1Entry.expiry > Date.now()) {
return l1Entry.data;
}
// L2 (Redis) チェック
const l2Data = await redis.get(`${this.prefix}:${key}`);
if (l2Data) {
const data = JSON.parse(l2Data);
// L1に昇格
this.setL1(key, data);
return data;
}
return null;
}
async set(key: string, data: T): Promise<void> {
// L1に保存
this.setL1(key, data);
// L2に保存
await redis.setex(
`${this.prefix}:${key}`,
this.l2Ttl,
JSON.stringify(data)
);
}
private setL1(key: string, data: T): void {
// LRUエビクション
if (this.l1.size >= this.l1MaxSize) {
const oldestKey = this.l1.keys().next().value;
this.l1.delete(oldestKey);
}
this.l1.set(key, {
data,
expiry: Date.now() + this.l1Ttl * 1000,
});
}
async invalidate(key: string): Promise<void> {
this.l1.delete(key);
await redis.del(`${this.prefix}:${key}`);
}
}
分散ロック
Redlock実装
// distributed-lock.ts
import Redlock from 'redlock';
const redlock = new Redlock([redis], {
driftFactor: 0.01,
retryCount: 10,
retryDelay: 200,
retryJitter: 200,
automaticExtensionThreshold: 500,
});
// 使用例
async function processOrderWithLock(orderId: string) {
let lock;
try {
// ロック取得
lock = await redlock.acquire([`lock:order:${orderId}`], 30000);
// クリティカルセクション
const order = await db.order.findUnique({ where: { id: orderId } });
if (order?.status !== 'pending') {
throw new Error('Order already processed');
}
await db.order.update({
where: { id: orderId },
data: { status: 'processing' },
});
// 処理実行...
await db.order.update({
where: { id: orderId },
data: { status: 'completed' },
});
} catch (error) {
if (error instanceof Redlock.LockError) {
console.log('Could not acquire lock, order is being processed');
}
throw error;
} finally {
// ロック解放
if (lock) {
await lock.release();
}
}
}
// シンプルなロック実装
class SimpleLock {
async acquire(key: string, ttl: number = 30000): Promise<string | null> {
const token = crypto.randomUUID();
const result = await redis.set(
`lock:${key}`,
token,
'PX',
ttl,
'NX'
);
return result === 'OK' ? token : null;
}
async release(key: string, token: string): Promise<boolean> {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, `lock:${key}`, token);
return result === 1;
}
async withLock<T>(
key: string,
fn: () => Promise<T>,
ttl: number = 30000
): Promise<T> {
const token = await this.acquire(key, ttl);
if (!token) {
throw new Error(`Could not acquire lock for ${key}`);
}
try {
return await fn();
} finally {
await this.release(key, token);
}
}
}
セッション管理
// session.ts
import { randomBytes } from 'crypto';
interface Session {
userId: string;
email: string;
role: string;
createdAt: number;
lastAccessedAt: number;
}
class RedisSessionStore {
private readonly prefix = 'session';
private readonly ttl = 24 * 60 * 60; // 24時間
async create(userId: string, userData: Partial<Session>): Promise<string> {
const sessionId = randomBytes(32).toString('hex');
const session: Session = {
userId,
email: userData.email || '',
role: userData.role || 'user',
createdAt: Date.now(),
lastAccessedAt: Date.now(),
};
const key = `${this.prefix}:${sessionId}`;
await redis.setex(key, this.ttl, JSON.stringify(session));
// ユーザーのセッション一覧を管理
await redis.sadd(`user_sessions:${userId}`, sessionId);
await redis.expire(`user_sessions:${userId}`, this.ttl);
return sessionId;
}
async get(sessionId: string): Promise<Session | null> {
const key = `${this.prefix}:${sessionId}`;
const data = await redis.get(key);
if (!data) return null;
const session: Session = JSON.parse(data);
// スライディングウィンドウ: アクセス時にTTLを延長
session.lastAccessedAt = Date.now();
await redis.setex(key, this.ttl, JSON.stringify(session));
return session;
}
async destroy(sessionId: string): Promise<void> {
const session = await this.get(sessionId);
if (session) {
await redis.srem(`user_sessions:${session.userId}`, sessionId);
}
await redis.del(`${this.prefix}:${sessionId}`);
}
async destroyAllUserSessions(userId: string): Promise<void> {
const sessionIds = await redis.smembers(`user_sessions:${userId}`);
if (sessionIds.length > 0) {
const keys = sessionIds.map(id => `${this.prefix}:${id}`);
await redis.del(...keys);
}
await redis.del(`user_sessions:${userId}`);
}
async getUserSessionCount(userId: string): Promise<number> {
return redis.scard(`user_sessions:${userId}`);
}
}
const sessionStore = new RedisSessionStore();
export { sessionStore };
レート制限
固定ウィンドウ
// rate-limiter.ts
class FixedWindowRateLimiter {
constructor(
private windowSizeSeconds: number,
private maxRequests: number
) {}
async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number }> {
const windowKey = `ratelimit:${key}:${Math.floor(Date.now() / 1000 / this.windowSizeSeconds)}`;
const current = await redis.incr(windowKey);
if (current === 1) {
await redis.expire(windowKey, this.windowSizeSeconds);
}
const allowed = current <= this.maxRequests;
const remaining = Math.max(0, this.maxRequests - current);
return { allowed, remaining };
}
}
スライディングウィンドウログ
class SlidingWindowLogRateLimiter {
constructor(
private windowSizeMs: number,
private maxRequests: number
) {}
async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number; retryAfter?: number }> {
const now = Date.now();
const windowStart = now - this.windowSizeMs;
const redisKey = `ratelimit:sliding:${key}`;
// トランザクションで実行
const multi = redis.multi();
// 古いエントリを削除
multi.zremrangebyscore(redisKey, 0, windowStart);
// 現在のウィンドウ内のリクエスト数をカウント
multi.zcard(redisKey);
// 結果を取得
const results = await multi.exec();
const currentCount = results![1][1] as number;
if (currentCount >= this.maxRequests) {
// 最も古いリクエストの時間を取得してリトライ時間を計算
const oldestRequest = await redis.zrange(redisKey, 0, 0, 'WITHSCORES');
const retryAfter = oldestRequest.length > 0
? Math.ceil((parseInt(oldestRequest[1]) + this.windowSizeMs - now) / 1000)
: 1;
return {
allowed: false,
remaining: 0,
retryAfter,
};
}
// 新しいリクエストを追加
await redis.zadd(redisKey, now, `${now}:${Math.random()}`);
await redis.expire(redisKey, Math.ceil(this.windowSizeMs / 1000));
return {
allowed: true,
remaining: this.maxRequests - currentCount - 1,
};
}
}
トークンバケット
class TokenBucketRateLimiter {
constructor(
private bucketSize: number, // バケットの最大容量
private refillRate: number, // 1秒あたりの補充トークン数
private refillIntervalMs: number = 1000
) {}
async isAllowed(key: string, tokensRequired: number = 1): Promise<{
allowed: boolean;
tokens: number;
retryAfter?: number;
}> {
const redisKey = `ratelimit:bucket:${key}`;
// Luaスクリプトでアトミックに処理
const script = `
local bucket_key = KEYS[1]
local bucket_size = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local refill_interval = tonumber(ARGV[3])
local tokens_required = tonumber(ARGV[4])
local now = tonumber(ARGV[5])
local bucket = redis.call('HMGET', bucket_key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or bucket_size
local last_refill = tonumber(bucket[2]) or now
-- トークンを補充
local time_passed = now - last_refill
local refills = math.floor(time_passed / refill_interval)
tokens = math.min(bucket_size, tokens + refills * refill_rate)
last_refill = last_refill + refills * refill_interval
if tokens >= tokens_required then
tokens = tokens - tokens_required
redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_refill', last_refill)
redis.call('EXPIRE', bucket_key, 3600)
return {1, tokens}
else
redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_refill', last_refill)
redis.call('EXPIRE', bucket_key, 3600)
local tokens_needed = tokens_required - tokens
local wait_time = math.ceil(tokens_needed / refill_rate * refill_interval)
return {0, tokens, wait_time}
end
`;
const result = await redis.eval(
script,
1,
redisKey,
this.bucketSize,
this.refillRate,
this.refillIntervalMs,
tokensRequired,
Date.now()
) as [number, number, number?];
return {
allowed: result[0] === 1,
tokens: result[1],
retryAfter: result[2] ? Math.ceil(result[2] / 1000) : undefined,
};
}
}
監視とメトリクス
// redis-metrics.ts
class RedisMetrics {
async getInfo(): Promise<Record<string, string>> {
const info = await redis.info();
const lines = info.split('\r\n');
const result: Record<string, string> = {};
for (const line of lines) {
if (line.includes(':')) {
const [key, value] = line.split(':');
result[key] = value;
}
}
return result;
}
async getKeyCount(): Promise<number> {
const info = await this.getInfo();
return parseInt(info['db0']?.split(',')[0]?.split('=')[1] || '0');
}
async getMemoryUsage(): Promise<{
used: number;
peak: number;
fragmentation: number;
}> {
const info = await this.getInfo();
return {
used: parseInt(info['used_memory'] || '0'),
peak: parseInt(info['used_memory_peak'] || '0'),
fragmentation: parseFloat(info['mem_fragmentation_ratio'] || '1'),
};
}
async getHitRate(): Promise<number> {
const info = await this.getInfo();
const hits = parseInt(info['keyspace_hits'] || '0');
const misses = parseInt(info['keyspace_misses'] || '0');
const total = hits + misses;
return total > 0 ? hits / total : 0;
}
async getSlowLogs(count: number = 10): Promise<any[]> {
return redis.slowlog('GET', count);
}
}
const redisMetrics = new RedisMetrics();
export { redisMetrics };
まとめ
Redisは、高速なデータアクセスを実現する強力なツールです。
キャッシュ戦略の選択
| パターン | ユースケース |
|---|---|
| Cache-Aside | 読み取り多、更新少 |
| Write-Through | 一貫性重視 |
| Write-Behind | 書き込み性能重視 |
ベストプラクティス
- 適切なTTL設計: データの特性に応じた設定
- キー設計: 名前空間とバージョニング
- 接続プール: コネクション再利用
- 監視: ヒット率とメモリ使用量
- フェイルオーバー: Redis Cluster/Sentinel
Redisを適切に活用することで、アプリケーションのパフォーマンスを大幅に向上させることができます。