Redisを使ったPVカウントに切り替える

この記事はNext.js版の内容です。現在はAstroで構築し直したため、情報が古い可能性があります。 当時のリポジトリは こちらあるので参考にしてください。

v1.7.0でPVカウント機能を実装した。

リリースしてから3ヶ月ほど経過したが、以下の問題が気になるようになってきた。

  1. 初期表示が遅い
  2. 1人のユーザが短時間にPVを増やせる(重複計測)

上記の問題を解決するために UpstashRedisを使った実装に切り替えることにした。

なぜRedisを使うのか

まず1つ目の問題はDBのリージョンに起因している。 利用しているサーバレスDBサービス Neon日本から最も近いリージョンはシンガポールである。 シンガポールから日本までのレイテンシは50ms ~ 100msほどなのでどうしても初期表示はそれ以上かかってしまう。

Upstashはリージョンに日本(AWSのap-northeast-1)を選択できるのでレイテンシを低く抑えられる。 また、Redisはインメモリデータベースであるため、ディスクI/Oのオーバーヘッドがなく高速なR/Wを行えるという利点がある。

2つ目の問題はRDBMSでも対応可能だが、定期的に重複排除のためのレコードを削除していく必要がある。 でないと、DBのサイズがどんどん膨れ上がってしまう。 RedisであればKey-Value Pairの有効期限を設定可能で、その期限を経過すると自動的に揮発するのでストレージを圧迫することもない。

以上のことからPVカウントの計測にRedisを使うのは有効と判断した。

実装方

Upstashのセットアップ

まずはUpstashの管理画面からAPIキーを取得して.envファイルに設定する。

.env
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

次にライブラリをインストールする。

Terminal window
pnpm add @upstash/redis

最後にRedisクライアントを作成すればセットアップは完了。 fromEnv使えば特に環境変数を読み取る処理を書かなくて良いのは楽だ。

packages/api/src/utils/redis.ts
import { Redis } from '@upstash/redis';
export const redis = Redis.fromEnv();

APIの作成

当サイトはAPI作成にtRPCを使っている。 tRPCを使っていない場合は適宜読み替えていただきたい。

まずは重複防止(デバウンス)のためにIPアドレスをキーにした有効期限付きのKey-Value Pairを登録する。 そして、重複がないことを確認できた場合のみincrコマンドを使ってPVをインクリメントする。

packages/api/src/router/page-view.ts
import type { TRPCRouterRecord } from '@trpc/server';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { env } from '../../env';
import { publicProcedure } from '../trpc';
import { getIpHash, redis } from '../utils';
export const pageViewRouter = {
incrementViews: publicProcedure.input(z.object({ slug: z.string() })).mutation(async ({ ctx, input: { slug } }) => {
if (!ctx.ip) {
throw new TRPCError({ code: 'BAD_REQUEST' });
}
const ip = ctx.ip;
const ipHash = await getIpHash(ip);
const isNew = await redis.set(['deduplicate', ipHash, slug].join(':'), true, {
nx: true,
ex: 24 * 60 * 60,
});
if (!isNew) {
return 'Deduplicated';
}
await redis.incr(['pageviews', env.NODE_ENV, slug].join(':'));
return 'Incremented';
}),
} satisfies TRPCRouterRecord;

お問い合わせ機能のレートリミットを設定したとき記事)と同様にセキュリティ対策としてIPアドレスはSHA-256でハッシュ化してからRedisに保存する。 デバウンス用のKey-Value Pairの有効期限は1日にしている。 つまり、1IPアドレスあたり1つの記事に対して1日に1回のみしかPVをインクリメントできないということになる。 また、nx: true設定することで、キーがすでに存在する場合は登録されない。

すでにPVをインクリメント済みの場合は'Deduplicated'返し、インクリメントに成功した場合は'Incremented'返す。 tRPCの良いところはAPIの型をクライアント側で定義しなくても良いところだ。 上記の場合だとincrementViews返り値の型は'Deduplicated' | 'Incremented'ユニオン型になる。 この値は次の章で使用する。

クライアント側から呼び出す

PVカウントをインクリメントするAPIを作成したので次はPVカウントを取得するAPIを作成していく。

packages/api/src/router/page-view.ts
import type { TRPCRouterRecord } from '@trpc/server';
import { z } from 'zod';
import { env } from '../../env';
import { publicProcedure } from '../trpc';
import { redis } from '../utils';
export const pageViewRouter = {
bySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ input: { slug } }) => (await redis.get<number>(['pageviews', env.NODE_ENV, slug].join(':'))) ?? 0),
} satisfies TRPCRouterRecord;

セットしたときのキーと同じ値をgetすることでPVを取得できる。 クライアント側から以下のようにAPIを呼び出す。

apps/web/src/ui/post/view-counter/index.tsx
'use client';
import * as React from 'react';
import { Skeleton } from '@kkhys/ui';
import { api } from '#/lib/trpc/react';
export const ViewCounter = ({ slug }: { slug: string }) => {
const utils = api.useUtils();
const { mutate } = api.pageView.incrementViews.useMutation({
onSuccess: async (status) => {
if (status === 'Incremented') {
await utils.pageView.bySlug.invalidate({ slug });
}
},
});
const { data } = api.pageView.bySlug.useQuery({ slug }, { staleTime: 60 * 1000 });
React.useEffect(() => mutate({ slug }), [mutate, slug]);
if (!data) return <ViewCounterSkeleton />;
return <p className='font-sans text-sm text-muted-foreground'>{data.toLocaleString()} views</p>;
};
export const ViewCounterSkeleton = () => <Skeleton className='h-4 w-14' />;

ポイントは2点ある。

まず1つ目はuseMutation成功した場合onSuccess)、PVを更新したときのみPVカウントをinvalidateするということである。 デバウンスの対象となるリクエストであってonSuccess処理が走るため、何もしなければ無駄にAPIがリクエストされてしまう。 それを防ぐためにincrementViews返り値がIncremented場合のみPVを更新する。

2つ目のポイントはuseQuerystaleTime設定することである。 staleTime設定しないと何度もリロードが行われた場合、その回数分だけリクエストが送られてしまう。 Upstashは従量課金制なので不要なリクエストは送られないようにする1

データベースの移行

今まではNeonのPostgreSQLにPVカウントのデータを保存していた。 UpstashのRedisに移行するにあたり、PostgreSQLに保存されているデータを移さなければならない。

Next.jsにRuby on RailsのRakeタスクみたいな機能があれば良いのだが、残念ながら無いのでAPIとして定義してそれをクライアント側から呼び出すことにした。

apps/web/src/ui/post/view-counter/index.tsx
import type { TRPCRouterRecord } from '@trpc/server';
import { publicProcedure } from '../trpc';
import { redis } from '../utils';
export const pageViewRouter = {
import: publicProcedure.mutation(async ({ ctx }) => {
const posts = await ctx.db.query.posts.findMany();
posts.forEach((post) => void redis.setnx(['pageviews', 'production', post.slug].join(':'), post.views));
}),
} satisfies TRPCRouterRecord;

本番用のデータベースに接続して全てのPVカウントを取得する。 そして、それをforEachで回してRedisにセットしていけば完了である。

冪等性(サービスを停止させた状態で)が保たれるようにsetnx使っている。

念の為、ちゃんと登録されたか確認してみる。

まずはPostgreSQL側のデータ。

SELECT * FROM me_post ORDER BY slug;
+-------+-----+--------------------------+--------------------------+
|slug |views|created_at |updated_at |
+-------+-----+--------------------------+--------------------------+
|p128uug|47 |2024-07-07 06:05:55.569318|2024-07-27 00:10:42.336000|
|p143t9d|8 |2024-07-27 07:44:32.707432|2024-07-27 08:21:13.394000|
|p15e6x7|105 |2024-05-19 12:40:25.512419|2024-07-27 00:10:11.409000|
|p164vu8|45 |2024-06-15 13:50:02.707545|2024-07-27 00:10:30.951000|
|p16ceda|38 |2024-04-07 04:04:19.174891|2024-07-27 08:01:10.329000|
|p16vfnq|59 |2024-04-07 04:04:31.853837|2024-07-27 03:29:25.920000|
|p18vcqd|43 |2024-04-07 04:04:33.979543|2024-07-27 03:28:57.797000|
|p1a95jw|118 |2024-05-15 14:00:43.888764|2024-07-22 04:34:23.322000|
|p1c8jpk|58 |2024-06-09 15:00:00.573275|2024-07-27 00:10:16.745000|
|p1e0lpm|37 |2024-04-07 04:04:58.937393|2024-07-27 03:29:37.042000|
|p1eemm6|64 |2024-04-07 04:04:37.635623|2024-07-27 06:07:07.161000|
|p1fw2ts|54 |2024-04-07 04:04:39.854620|2024-07-27 03:03:01.710000|
|p1g6z2d|26 |2024-04-07 04:04:25.338530|2024-07-07 03:52:33.672000|
|p1gvayx|48 |2024-06-20 14:18:37.999824|2024-07-27 00:10:39.531000|
|p1kc29z|32 |2024-07-14 10:23:41.045142|2024-07-26 12:58:25.458000|
|p1kqv7s|48 |2024-07-12 10:42:57.077724|2024-07-24 15:32:21.075000|
|p1n03k6|58 |2024-06-10 14:42:21.595135|2024-07-27 03:17:41.104000|
|p1r60de|157 |2024-04-07 04:03:58.912900|2024-07-27 08:16:17.285000|
|p1rklfz|31 |2024-04-07 04:04:47.664132|2024-07-24 14:27:28.827000|
|p1srf75|55 |2024-04-24 15:19:48.949461|2024-07-06 08:29:28.758000|
|p1t6el8|83 |2024-06-16 13:03:18.070267|2024-07-27 03:15:35.024000|
|p1ua4wh|40 |2024-05-14 15:17:23.575571|2024-07-25 10:40:58.893000|
|p1uchql|203 |2024-05-18 06:53:17.430213|2024-07-27 00:09:55.951000|
|p1v00e8|136 |2024-04-20 13:28:10.858223|2024-07-27 03:21:25.136000|
|p1v9jvx|72 |2024-04-23 15:11:34.886525|2024-07-27 09:19:21.205000|
|p1y4nft|52 |2024-04-07 04:04:42.417156|2024-07-22 04:33:42.593000|
|p1ys5j8|93 |2024-04-07 04:04:03.583977|2024-07-27 03:28:49.794000|
+-------+-----+--------------------------+--------------------------+

次にRedis側のデータ。

Terminal window
redis-cli --tls -u redis://default:password@hostname:port "pageviews:production:*" | sort -n | while read key; do echo "$key: $(redis-cli --tls -u redis://default:password@hostname:port get $key)"; done
pageviews:production:p128uug: 47
pageviews:production:p143t9d: 8
pageviews:production:p15e6x7: 105
pageviews:production:p164vu8: 45
pageviews:production:p16ceda: 38
pageviews:production:p16vfnq: 59
pageviews:production:p18vcqd: 43
pageviews:production:p1a95jw: 118
pageviews:production:p1c8jpk: 58
pageviews:production:p1e0lpm: 37
pageviews:production:p1eemm6: 64
pageviews:production:p1fw2ts: 54
pageviews:production:p1g6z2d: 26
pageviews:production:p1gvayx: 48
pageviews:production:p1kc29z: 32
pageviews:production:p1kqv7s: 48
pageviews:production:p1n03k6: 58
pageviews:production:p1r60de: 157
pageviews:production:p1rklfz: 31
pageviews:production:p1srf75: 55
pageviews:production:p1t6el8: 83
pageviews:production:p1ua4wh: 40
pageviews:production:p1uchql: 203
pageviews:production:p1v00e8: 136
pageviews:production:p1v9jvx: 72
pageviews:production:p1y4nft: 52
pageviews:production:p1ys5j8: 93

PVが一致しているため問題なし。

バックアップ

Upstashは毎日バックアップをとってくれるオプションがあるため有効化している。 リストアもワンクリックで行えるため簡単。

Upstashの管理画面
Upstashの管理画面

さいごに

Upstashを使うと面倒なRedisのセットアップも一瞬で終わった。 便利すぎる。

PVをデバウンスさせることで全体的にPVは減るだろうが多くなれば良いというわけでもないので問題ない。 正確なPV数を算出できるので楽しみだ。


  1. 従量課金制とはいっても1日10,000コマンドまでは無料なのでそこまで心配はいらない。 仮にオーバーしてしまっても100,000コマンドあたり0.2ドルしか支払いは発生しないソース