ポイントサービスやキャンペーンアプリでよくある「本日のチェックイン」機能を、Redis のビットマップ機能を使って高速・省メモリに実装する方法を解説します。Redis の SETBIT / BITFIELD を活用することで、1 ユーザーあたり月 31 日分のデータをわずか 4 byte で保持できます。
設計方針
- 1 ビット = 1 日の出欠を表現
- キーは
checkin:{userId}:{yyyyMM}の形式 - offset は「その月の 0 から始まる日数」
- 集計は
BITFIELDで一括取得し、ビットカウントで総数算出
チェックイン処理
public void recordCheckIn() {
Long uid = AuthContext.currentUserId();
LocalDate today = LocalDate.now();
String key = "checkin:" + uid + ":" + today.format(DateTimeFormatter.BASIC_ISO_DATE).substring(0, 6);
int offset = today.getDayOfMonth() - 1;
redisTemplate.opsForValue().setBit(key, offset, true);
}
上記コードでは、setBit を呼ぶだけで該当 offset に 1 を立てます。初回アクセス時に Redis は内部的に必要なバイト列を自動生成するため、事前の初期化は不要です。
当月のチェックイン日数を取得
public int checkedDaysThisMonth() {
Long uid = AuthContext.currentUserId();
LocalDate today = LocalDate.now();
String key = "checkin:" + uid + ":" + today.format(DateTimeFormatter.BASIC_ISO_DATE).substring(0, 6);
int dayLimit = today.getDayOfMonth();
List<Long> segment = redisTemplate.opsForValue()
.bitField(key, BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayLimit))
.valueAt(0));
if (segment == null || segment.isEmpty()) return 0;
long bits = segment.get(0);
return Long.bitCount(bits);
}
Long.bitCount を使うことで、ループ不要で立っているビット数(=チェックイン済み日数)を O(1) で取得できます。
連続チェックイン日数を求める例
public int consecutiveDays() {
Long uid = AuthContext.currentUserId();
LocalDate today = LocalDate.now();
String key = "checkin:" + uid + ":" + today.format(DateTimeFormatter.BASIC_ISO_DATE).substring(0, 6);
int dayLimit = today.getDayOfMonth();
List<Long> segment = redisTemplate.opsForValue()
.bitField(key, BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayLimit))
.valueAt(0));
if (segment == null || segment.isEmpty()) return 0;
long bits = segment.get(0);
int streak = 0;
while ((bits & 1L) == 1) {
streak++;
bits >>>= 1;
}
return streak;
}
ここでは最下位ビットから連続する 1 をカウントすることで、今日から逆算した連続チェックイン日数を得ます。
メモリ効率の目安
| 期間 | ビット数 | バイト数 |
|---|---|---|
| 1 ヶ月 | 31 | 4 |
| 1 年 | 365 | 46 |
| 100 万ユーザー×1 年 | 3.65 億 | 約 44 MB |
ビットマップはスパースでも内部的に intset のように圧縮されないため、あらかじめ想定ユーザーを見越したキー設計を行うと管理が楽になります。
応用例
- 曜日別出欠集計:月跨ぎで 7 ビットごとにマスクを取れば OK
- ミニゲームの「1 日 1 回」報告受付
- アドベントカレンダー形式の限定コンテンツ解放