DELOGs
[自サーバでNext.jsアプリを動かす#5]リードレプリカ(スレーブ)DBの利用

自サーバでNext.jsアプリを動かす#5
リードレプリカ(スレーブ)DBの利用

SELECT系専用のリードレプリカ(スレーブ)DBとマスタDBへの接続を同居させる方法

初回公開日

最終更新日

アプライスDBのリードレプリカの作成でリード系専用のレプリカを作成しました。これをNext.jsのWebアプリケーションでどう設定すれば利用できるのか? なんとなくreadと関連のAPIとかサーバアクションのファイルだけ接続先を変える設定をするのかな? と思っていましたが違いました。
Prismaは本当にいたれり尽せりのツールです。ほとんどのファイルや記述に変更を加えることなく、この辺の処理をやってくれることがわかりました。その方法について記述します。
まず、環境の整理です。レプリカDBを増やして下記のような構成になっています。
レプリカ作成後のサーバ構成
これまでマスタDBしかない状態でNext.jsのWebアプリケーションを構築しています。DBはPostgreSQL13、Next.jsはver15、PrismaでDB接続と操作を行なっています。また、Next.jsはWebサーバではPM2を利用して動作させています。この状態からリード回りは全てレプリカDBへ接続して処理するように変更していきます。

1.接続設定

まずは接続設定を追加します。

ローカルPCから本番DBへの接続設定

○SSHトンネルの増設
ローカルPCから本番DBへの接続は下記のようにSSHトンネルを利用していると思います。
zsh
1ssh -L 5433:db-master.local:5432 -N -p 22022 -i "~/sever01.pem" user@xxx.xxx.xxx.xxx
db-master.localはマスタDBのIPもしくはサーバ側の/etc/hostsの設定になります。上記はマスタDBのトンネルですので、下記のようにレプリカDBへのトンネル接続を増やします。
zsh
1ssh -L 5434:db-read.local:5432 -N -p 22022 -i "~/sever01.pem" user@xxx.xxx.xxx.xxx
db-read.localはレプリカDBのIPもしくはサーバ側の/etc/hostsの設定
○Next.jsの.envファイルへの追加
現状:マスタDBのみの設定
zsh : .env
1DATABASE_URL="postgresql://dbuser:dbuserpassword@127.0.0.1:5433/dbname?schema=public"
これにレプリカDBのURLを追加
zsh : .env
1DATABASE_URL="postgresql://dbuser:dbuserpassword@127.0.0.1:5433/dbname?schema=public" 2DATABASE_URL_REPLICA="postgresql://dbuser:dbuserpassword@127.0.0.1:5434/dbname?schema=public"
レプリカDB用のトンネルのポート番号にします。
ローカル側の接続設定はこれで完了です。

サーバ側PM2の設定ファイルへの追加

PM2の詳細については自サーバでNext.jsアプリを動かす#2 PM2を利用して、快適にNext.jsアプリを動かすを参照してください。
PM2のエコシステムのファイルに環境設定をしています。そこにレプリカDB用のURLを追加します。
bash
1vi /home/user/pm2/ecosystem-example.config.js
diff : ecosystem-example.config.js
1module.exports = { 2 apps: [ 3 { 4 name: 'example-app', 5 cwd: '/var/www/example.com/html/', 6 script: 'npm', 7 args: 'start', 8 instances: 1, 9 autorestart: true, 10 watch: false, 11 max_memory_restart: '1G', 12 env: { 13 NODE_ENV: 'production', 14 NEXT_PUBLIC_SITE_NAME: 'example', 15 DATABASE_URL: 'postgresql://dbuser:yourpassword@db-master.local:5432/dbname?schema=public', 16+ DATABASE_URL_REPLICA: 'postgresql://dbuser:yourpassword@db-read.local:5432/dbname?schema=public' 17 } 18 } 19 ] 20}
env:{}の中の各種パラメータはカンマで区切って記述しますので、追加する際はDATABASE_URLの末尾にカンマを忘れずに。 これで、サーバ側に設定も完了です。
ここまでで、準備完了です。ここからが本番です。

2.@prisma/extension-read-replicasのインストール

extension-read-replicasがPrismaの便利ツールです。Prismaの拡張機能になります。 これは、findMany などの「読み取り」クエリを自動でレプリカにルーティングして、create, update, delete などの「書き込み」は常にマスターへルーティングしてくれます。
ローカルPCで作業します。
zsh
1npm install @prisma/extension-read-replicas
extension-read-replicasのマニュアルはhttps://github.com/prisma/extension-read-replicasへ

@prisma/extension-read-replicas を導入すると

Prisma に @prisma/extension-read-replicas を導入すると、readReplicas() という拡張機能が使えるようになります。
ts
1basePrisma.$extends( 2 readReplicas({ url: process.env.DATABASE_URL_REPLICA! }) 3)
このように使うことで、下記のように自動で切り替えてくれる便利機能を追加できます!
  • 読み取りクエリ(findManyなど)は レプリカDB へ
  • 書き込みクエリ(create, update, deleteなど)は マスタDB へ

3.src/lib/database.ts を修正

続けて、レプリカDBとextension-read-replicasを利用するための設定をdatabase.tsに追記します。ここはPrismaを利用する際の記述の仕方が異なるかもしれません。私は下記のようにして、サーバアクションなどでimport { prisma } from "@/lib/database";として呼び出すようにしています。
現状:
ts : database.ts
1import { PrismaClient, Prisma } from "@prisma/client"; 2 3// グローバル変数としてPrismaクライアントを定義 4const globalForPrisma = global as typeof globalThis & { prisma?: PrismaClient }; 5 6// Prismaクライアントを再利用(開発環境ではHot Reloadの影響を防ぐ) 7export const prisma = 8 globalForPrisma.prisma || 9 new PrismaClient({ 10 log: 11 process.env.NODE_ENV === "development" 12 ? ["query", "info", "warn", "error"] 13 : ["error"], 14 }); 15 16// 開発環境ではグローバル変数に設定 17if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 18 19// Prismaの型もエクスポート 20export { Prisma };
上記を下記のように変更します。Prismaの拡張設定でレプリカDBのURLを設定します。
ts : database.ts
1import { PrismaClient, Prisma } from "@prisma/client"; 2import { readReplicas } from "@prisma/extension-read-replicas"; 3 4const globalForPrisma = global as typeof globalThis & { prisma?: PrismaClient }; 5 6const basePrisma = new PrismaClient({ 7 log: 8 process.env.NODE_ENV === "development" 9 ? ["query", "info", "warn", "error"] 10 : ["error"], 11}); 12 13export const prisma = 14 globalForPrisma.prisma || 15 (basePrisma.$extends( 16 readReplicas({ 17 url: process.env.DATABASE_URL_REPLICA!, 18 }), 19 ) as unknown as PrismaClient); 20 21if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 22 23// Prismaの型もエクスポート 24export { Prisma };
as unknown as PrismaClientのところが変な感じですが、$extends() の戻り値は完全には PrismaClient と一致しないから、単純な as PrismaClient は TypeScript に拒否されるのを防ぐために、まず unknown にキャストしてから PrismaClient に変換することで、安全であることを明示するようにしています。ここら辺の独特な型チェックにはいつも悩まされます。。

$extends() の戻り値は「ちょっと違う型」

問題は $extends() を使ったときに戻ってくるのが、通常の PrismaClient とは厳密にはちょっと違う型であるという点です。
  • 通常の PrismaClient は公式が用意した「ちゃんとした型」
  • $extends() を使うと、動的に作られたクライアントになって、型が少し違うものになる
型が「微妙に違う」だけで、実際の動作には問題ないのですが、TypeScript はとても厳格なので:
「これは PrismaClient と完全に一致してない!危ないかも!」
と判断して、エラーを出してしまいます。
そこで登場するのがこの一行です。
ts
1(basePrisma.$extends(...) as unknown as PrismaClient)
これは TypeScript に対して:
「この値の型は一旦わからない (unknown) として受け止めておいて。 そのあとで、それが PrismaClient だと信じて扱って大丈夫だよ!」 という意思表示をするテクニックです。

サーバアクションなどで

これは元々ですが、私の場合、下記のようにdatabase.tsの内容を読み込んで利用しています。
tsx
1import { prisma } from "@/lib/database"; 2 3const categories = await prisma.category.findMany({・・・・
上記のような内容は全く変更は必要ありません。
@prisma/extension-read-replicasをインストールして、.$extendsでレプリカDBのURLを読み込むようにする。これだけで、なんと、
クエリ種別接続先
.findMany(), .findUnique() など読み取りレプリカDB(DATABASE_URL_REPLICA)
.create(), .update(), .delete() など書き込みマスターDB(DATABASE_URL)
という振り分けが自動化されます。Prismaの凄さ! schema.prismaや各種サーバアクションなど変更不要です!
あとはローカルPCでビルドしたデータをサーバへアップして、PM2を再起動して終わりです。 あっさりしすぎて怖いくらいです。これで念願の負荷分散ができました!
前回の確認はこちら...
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。