概要
「NeonのDBが眠ったことを死と捉えるたまごっちに、みんなで餌を与えて延命させるプロジェクト」
があったらどう実装すればいいか?
今回は上記の課題を定義して、
サーバーレスDB「Neon」と、NextJSの 「revalidateTag」 を使って実装しました。
なお、Neonは5分間リクエストがないとリソースを停止するようになっているので、
「だれも5分以内に餌をあげない = たまごっち(ネオン君)が死ぬ」 仕様で設計を行います。
実装したもの
こちらにリリースしました。
GitHubも公開しています。
実装方針:NeonDBの安眠(ネオン君の死)を守る
「餌をあげる = DB延命」にこだわりたいので、ページを見ただけならDBを起こしたくない。
しかし「生きているかどうか」は判断したいので、以下の通りrevalidateTagと、NeonDBで責務を分けます。
- NextJS(revalidateTag)
- 全世界のユーザーに共通のキャッシュを見せて生死を判断させる
- 餌やり時刻をキャッシュして、Fetchの時はDBを見ない(Neonを起こさない)
- 餌をあげた時だけ、キャッシュを更新してDBを呼ぶ(Neonが一時的に起きる)
- 最後の餌(もしくは転生)から5分が経過していたら、Neonは寝たと見なす
- NeonDB
- 起きて眠るのが役目
- 餌の履歴の記録(キャッシュ破棄した時の餌の記録取得のため)
revalidateTagを使わないと、ネオン君が死んでるかどうかを確認したいのに、
確認したら復活してしまうという量子力学のような状態になってしまいます。
実装に必要なコード
NextJSのキャッシュや、ポーリング、動的なコンポーネントの調整は色々躓きやすいポイントがあります。
1. ページの動的化
Next.js は、ビルド時にその時の DB の状態を読み取って「静的な HTML」として固定しようとします。これをしてしまうと、Vercel 上では**「一生、ビルド時の秒数のまま止まっているネオンくん(静的な死)」がデプロイされてしまいます。
回避するには、force-dynamic は、「このページに固定のデータはない。アクセスされるたびに毎回計算し直せ」という宣言を加えます。
export const dynamic = 'force-dynamic'; // ページを常に最新の状態で生成させる
export const revalidate = 0; // キャッシュの保持時間を0にする
一方で、↓の項目では DBアクセスだけを unstable_cache でキャッシュしています。
2. 芋づる式キャッシュ更新
生存時刻、履歴のFetch関数に同じ tags: ['gotchi'] を付与します。こうすることで、エサやり(書き込み)が起きた時だけ世界のキャッシュデータが更新され、それ以外の「見るだけ」のアクセスは、Vercelのキャッシュで完結します。
// app/page.tsx
// ネオン君のデータを取得する関数
const getGotchiData = unstable_cache(
async () => {
const sql = neon(process.env.DATABASE_URL!);
const result = await sql`SELECT * FROM neon_gotchi WHERE id = 1`;
return result[0];
},
['gotchi-key'],
{ tags: ['gotchi'] } // 同じタグにしておけばエサやり時に一緒に更新される
);
// 餌のログ取得用の関数
const getGotchiLogs = unstable_cache(
async () => {
const sql = neon(process.env.DATABASE_URL!);
return await sql`SELECT * FROM gotchi_logs ORDER BY created_at DESC LIMIT 5`;
},
['gotchi-logs-key'],
{ tags: ['gotchi'] } // 同じタグにしておけばエサやり時に一緒に更新される
);
餌を上げるときのサーバーアクション
// app/action.ts
'use server'
import { neon } from '@neondatabase/serverless';
import { revalidateTag, revalidatePath } from 'next/cache';
export async function feedNeonGotchi() {
const sql = neon(process.env.DATABASE_URL!);
// 1. 今の状態を確認(生きているか?)
const [current] = await sql`SELECT last_fed_at, is_alive FROM neon_gotchi WHERE id = 1`;
const diff = (Date.now() - new Date(current.last_fed_at).getTime()) / 1000;
const wasDead = diff > 300 || !current.is_alive;
// 2. 本体を更新
await sql`
UPDATE neon_gotchi
SET last_fed_at = NOW(), is_alive = TRUE
WHERE id = 1
`;
// 3. ログをアップデート
const actionType = wasDead ? 'REBIRTH' : 'FEED';
const message = wasDead ? 'ネオンくんが奇跡の復活を遂げた!' : 'ネオンくんにエサが与えられた。';
await sql`
INSERT INTO gotchi_logs (action_type, message)
VALUES (${actionType}, ${message})
`;
// ここでキャッシュを破棄するが、revalidatePathが即リロードすれば、
// page.tsx 側でfetchして再びキャッシュされる。
revalidateTag('gotchi', 'max');
revalidatePath('/');
}
3. ポーリング設計
今回作成したGotchiTimerは、
- 1秒ごとに時間をカウント&キャッシュの時刻とブラウザの時間差を計算
- さらに5秒ごとに
router.refreshでキャッシュを再取得をするという設計になっています
これにより、他のユーザーが餌をあげれば5秒以内にキャッシュから情報が入ってきます。
router.refresh() は現在のページのサーバーコンポーネントだけを再実行し、
必要なデータ差分だけを更新してくれるのでGotchiTimer(クライアントコンポーネント)
はカクつかないで済みます。
// app/GotchiTimer.tsx
// 3. 同期(5秒おきにサーバーに聞きに行く)
useEffect(() => {
const sync = setInterval(() => {
router.refresh()
}, 5000)
return () => clearInterval(sync)
}, [router])
ダッシュボードで確認してみる
ネオン君が死んでいるとき、NeonDBも止まっているのか気になったため、
Neonのダッシュボードを見て、本当に止まっているか確認してみました。
↑ネオン君が死んでいるときは、ページをリロードしても波形が動きません。
ちゃんと止まっているようです。

↑転生から5分放置しているとネオンくんは星になり、波形も0になりました。
まとめ
今回はrevalidateTagによるキャッシュ戦略で、DBへの負荷を減らすことができました。
ネオン君はいつ救われるのか?
Neonの無料枠は容量500MB。テキストベースのログなら数百万件は収まると思います。
いつかDB(お腹)が一杯になったとき、静かな救済が訪れるのかもしれません。

