この記事の要点
• Upstash Redisでサーバーレス環境に最適化されたRedisを構築
• Next.js App Routerと統合し、エッジランタイムでキャッシュとレート制限を実装
• REST APIモードでCloudflare Workers等でも利用可能
Upstash Redisとは
Upstash Redisは、サーバーレス環境向けに最適化されたRedis互換データベースです。従量課金制で、接続プーリング不要、HTTPベースのアクセスが可能です。
Upstashの特徴
| 機能 | 説明 |
|---|---|
| サーバーレス対応 | コールドスタートなし、接続管理不要 |
| グローバルレプリケーション | 複数リージョンに自動レプリカ配置 |
| REST API | HTTP経由でアクセス(エッジ対応) |
| 従量課金 | 使った分だけ課金、無料枠あり |
| Redis互換 | 標準Redisコマンドをサポート |
ポイント: Upstashは無料プランで1日10,000コマンド利用でき、個人プロジェクトに最適です。
プロジェクトセットアップ
# Next.jsプロジェクト作成
npx create-next-app@latest my-app --typescript --app
cd my-app
# Upstash Redisクライアントのインストール
npm install @upstash/redis
npm install @upstash/ratelimit
Upstashデータベース作成
- https://console.upstash.com/ にアクセス
- “Create Database” をクリック
- リージョン選択(
us-east-1等、アプリケーションに近い場所) - “TLS Enabled” を有効化
- “UPSTASH_REDIS_REST_URL” と “UPSTASH_REDIS_REST_TOKEN” を取得
環境変数
# .env.local
UPSTASH_REDIS_REST_URL=https://xxxxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=xxxxx
実践メモ: REST APIモードはエッジランタイム(Vercel Edge、Cloudflare Workers)で動作します。
Redis クライアントの初期化
// lib/redis.ts
import { Redis } from '@upstash/redis';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
基本操作
文字列操作
// app/actions/cache.ts
'use server';
import { redis } from '@/lib/redis';
// セット
export async function setCache(key: string, value: string, ttl?: number) {
if (ttl) {
await redis.setex(key, ttl, value);
} else {
await redis.set(key, value);
}
}
// ゲット
export async function getCache(key: string) {
return await redis.get<string>(key);
}
// 削除
export async function deleteCache(key: string) {
await redis.del(key);
}
// 複数セット
export async function setCacheMultiple(entries: Record<string, string>) {
const pipeline = redis.pipeline();
Object.entries(entries).forEach(([key, value]) => {
pipeline.set(key, value);
});
await pipeline.exec();
}
JSON操作
// app/actions/user.ts
'use server';
import { redis } from '@/lib/redis';
interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
export async function cacheUser(userId: string, user: User) {
await redis.set(`user:${userId}`, JSON.stringify(user), {
ex: 3600, // 1時間
});
}
export async function getCachedUser(userId: string): Promise<User | null> {
const cached = await redis.get<string>(`user:${userId}`);
return cached ? JSON.parse(cached) : null;
}
// または型安全なJSON操作
export async function cacheUserTyped(userId: string, user: User) {
await redis.set<User>(`user:${userId}`, user, { ex: 3600 });
}
export async function getCachedUserTyped(userId: string) {
return await redis.get<User>(`user:${userId}`);
}
注意: JSON.stringifyを使う場合、DateオブジェクトはISO文字列に変換されます。再取得時にnew Date()で復元してください。
ハッシュ操作
// セッション管理
export async function setSession(sessionId: string, data: Record<string, string>) {
await redis.hset(`session:${sessionId}`, data);
await redis.expire(`session:${sessionId}`, 86400); // 24時間
}
export async function getSession(sessionId: string) {
return await redis.hgetall<Record<string, string>>(`session:${sessionId}`);
}
export async function updateSessionField(sessionId: string, field: string, value: string) {
await redis.hset(`session:${sessionId}`, { [field]: value });
}
export async function deleteSession(sessionId: string) {
await redis.del(`session:${sessionId}`);
}
リスト操作
// 最近のアクティビティログ
export async function addActivityLog(userId: string, activity: string) {
await redis.lpush(`activity:${userId}`, activity);
await redis.ltrim(`activity:${userId}`, 0, 99); // 最新100件のみ保持
}
export async function getActivityLogs(userId: string, limit = 10) {
return await redis.lrange<string>(`activity:${userId}`, 0, limit - 1);
}
セット操作
// タグ管理
export async function addTagToPost(postId: string, tag: string) {
await redis.sadd(`post:${postId}:tags`, tag);
}
export async function getPostTags(postId: string) {
return await redis.smembers<string>(`post:${postId}:tags`);
}
export async function removeTagFromPost(postId: string, tag: string) {
await redis.srem(`post:${postId}:tags`, tag);
}
// 2つの投稿の共通タグ
export async function getCommonTags(postId1: string, postId2: string) {
return await redis.sinter<string>(`post:${postId1}:tags`, `post:${postId2}:tags`);
}
レート制限
// lib/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { redis } from './redis';
// API レート制限(10リクエスト/10秒)
export const apiRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '10 s'),
analytics: true,
});
// ログインレート制限(5試行/1分)
export const loginRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '1 m'),
analytics: true,
});
// グローバルレート制限(1000リクエスト/1時間)
export const globalRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(1000, '1 h'),
analytics: true,
});
// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { apiRatelimit } from '@/lib/ratelimit';
export async function GET(request: NextRequest) {
const ip = request.ip ?? '127.0.0.1';
const { success, limit, reset, remaining } = await apiRatelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
}
);
}
// 通常の処理
return NextResponse.json({ data: 'success' });
}
ポイント: @upstash/ratelimitは、トークンバケット、固定ウィンドウ、スライディングウィンドウの3つのアルゴリズムをサポートします。
ユーザー別レート制限
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { apiRatelimit } from '@/lib/ratelimit';
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api')) {
const { userId } = auth();
const identifier = userId ?? request.ip ?? '127.0.0.1';
const { success } = await apiRatelimit.limit(identifier);
if (!success) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
);
}
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
キャッシュ戦略
データベースクエリのキャッシュ
// lib/cache.ts
import { redis } from './redis';
import { db } from './db';
export async function getCachedPost(postId: string) {
// キャッシュを確認
const cached = await redis.get<any>(`post:${postId}`);
if (cached) {
console.log('Cache hit');
return cached;
}
// キャッシュミス時はDBから取得
console.log('Cache miss');
const post = await db.post.findUnique({
where: { id: postId },
include: { author: true, comments: true },
});
if (!post) {
return null;
}
// キャッシュに保存(5分間)
await redis.set(`post:${postId}`, post, { ex: 300 });
return post;
}
// 更新時にキャッシュを無効化
export async function updatePost(postId: string, data: any) {
const updated = await db.post.update({
where: { id: postId },
data,
});
// キャッシュを削除
await redis.del(`post:${postId}`);
return updated;
}
Stale-While-Revalidate
// lib/swr-cache.ts
import { redis } from './redis';
interface CacheEntry<T> {
data: T;
timestamp: number;
}
export async function swrCache<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number,
staleTime: number
): Promise<T> {
const cached = await redis.get<CacheEntry<T>>(key);
if (cached) {
const age = Date.now() - cached.timestamp;
// フレッシュな場合はそのまま返す
if (age < staleTime) {
return cached.data;
}
// Staleだが返しつつ、バックグラウンドで更新
if (age < ttl) {
// 非同期で再検証
fetcher().then((fresh) => {
redis.set(key, { data: fresh, timestamp: Date.now() }, { ex: ttl });
});
return cached.data;
}
}
// キャッシュなし、または期限切れの場合は同期取得
const fresh = await fetcher();
await redis.set(key, { data: fresh, timestamp: Date.now() }, { ex: ttl });
return fresh;
}
実践メモ: Stale-While-Revalidateは、古いキャッシュを返しつつバックグラウンドで更新し、レスポンス速度を維持します。
セッション管理
// lib/session.ts
import { redis } from './redis';
import { cookies } from 'next/headers';
import { randomUUID } from 'crypto';
interface SessionData {
userId: string;
email: string;
role: string;
}
export async function createSession(userId: string, email: string, role: string) {
const sessionId = randomUUID();
const sessionData: SessionData = { userId, email, role };
// Redisに保存(7日間)
await redis.setex(`session:${sessionId}`, 604800, JSON.stringify(sessionData));
// Cookieに保存
cookies().set('session', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 604800,
});
return sessionId;
}
export async function getSession(): Promise<SessionData | null> {
const sessionId = cookies().get('session')?.value;
if (!sessionId) {
return null;
}
const sessionData = await redis.get<string>(`session:${sessionId}`);
if (!sessionData) {
return null;
}
return JSON.parse(sessionData);
}
export async function destroySession() {
const sessionId = cookies().get('session')?.value;
if (sessionId) {
await redis.del(`session:${sessionId}`);
}
cookies().delete('session');
}
export async function extendSession() {
const sessionId = cookies().get('session')?.value;
if (sessionId) {
await redis.expire(`session:${sessionId}`, 604800);
}
}
Pub/Sub
// リアルタイム通知
import { redis } from './redis';
// パブリッシュ
export async function publishNotification(userId: string, message: string) {
await redis.publish(`user:${userId}:notifications`, message);
}
// サブスクライブ(長時間実行プロセス用)
export async function subscribeToNotifications(userId: string, callback: (message: string) => void) {
// Note: REST API版では継続的なサブスクリプションは非推奨
// WebSocketやServer-Sent Eventsと組み合わせる
}
注意: Upstash RedisのREST APIモードでは、SUBSCRIBEは長時間接続に適していません。リアルタイム機能はWebSocketやSSEと組み合わせてください。
パイプライン処理
// 複数操作を一度に実行
export async function incrementMultipleCounters(counters: string[]) {
const pipeline = redis.pipeline();
counters.forEach((counter) => {
pipeline.incr(counter);
});
const results = await pipeline.exec();
return results;
}
// トランザクション的な処理
export async function transferPoints(fromUserId: string, toUserId: string, points: number) {
const pipeline = redis.pipeline();
pipeline.decrby(`user:${fromUserId}:points`, points);
pipeline.incrby(`user:${toUserId}:points`, points);
const results = await pipeline.exec();
return results;
}
ポイント: pipelineを使うと、複数コマンドを1回のHTTPリクエストで実行でき、レイテンシを削減できます。
ソート済みセット(ランキング)
// ランキング管理
export async function updateScore(userId: string, score: number) {
await redis.zadd('leaderboard', { score, member: userId });
}
export async function getTopPlayers(limit = 10) {
return await redis.zrange<string>('leaderboard', 0, limit - 1, {
rev: true,
withScores: true,
});
}
export async function getUserRank(userId: string) {
return await redis.zrevrank('leaderboard', userId);
}
export async function getUserScore(userId: string) {
return await redis.zscore('leaderboard', userId);
}
Geo位置情報
// 位置情報ベースの検索
export async function addLocation(storeId: string, longitude: number, latitude: number) {
await redis.geoadd('stores', { longitude, latitude, member: storeId });
}
export async function findNearbyStores(longitude: number, latitude: number, radiusKm: number) {
return await redis.georadius<string>(
'stores',
longitude,
latitude,
radiusKm,
'km',
{ withDist: true, withCoord: true, count: 10 }
);
}
エッジランタイムでの使用
// app/api/edge/route.ts
import { redis } from '@/lib/redis';
export const runtime = 'edge';
export async function GET(request: Request) {
const url = new URL(request.url);
const key = url.searchParams.get('key');
if (!key) {
return Response.json({ error: 'Missing key' }, { status: 400 });
}
const value = await redis.get(key);
return Response.json({ value });
}
実践メモ: Upstash RedisはHTTPベースなので、edge runtimeでも動作します。
Analytics
// ページビューカウント
export async function trackPageView(slug: string) {
await redis.incr(`pageviews:${slug}`);
}
export async function getPageViews(slug: string) {
return (await redis.get<number>(`pageviews:${slug}`)) ?? 0;
}
// 日別統計
export async function trackDailyVisit(date: string, userId: string) {
await redis.pfadd(`visits:${date}`, userId);
}
export async function getDailyUniqueVisitors(date: string) {
return await redis.pfcount(`visits:${date}`);
}