Redis基礎:データ型と使い分け

1. 概要

Redisはin-memory data storeですが、「キャッシュとしてだけ使うもの」と誤解されることが多いです。実は、Redisは様々なデータ構造を提供しており、各々が異なる問題に適しています。

この記事では、Redisの5つの主要データ型の使い方(Spring Data Redisを使用)と、より重要なことに、どのときにどのデータ型を選ぶべきかを学べます。最後には、「すべてにStringを使う」という典型的な間違いを避け、データ更新時に自分を苦しめることがないようになります。

2. 環境準備

プロジェクトにSpring Data Redisの依存関係を追加します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

application.ymlで接続を宣言します:

spring:
  data:
    redis:
      host: localhost
      port: 6379

Dockerを使ってRedisを素早く起動します:

docker run -d --name redis-dev -p 6379:6379 redis:7-alpine

以下の例では、Spring Bootが自動設定済みのRedisTemplate<String, String>を使用しており、手動でbeanを宣言する必要はありません。

3. String:最もシンプルな型

Stringは基本的な型で、keyに対して1つのvalueを保存します。valueはテキスト、数字、またはJSON化されたオブジェクトなどです。

@Service
public class SessionTokenService {

    private final RedisTemplate<String, String> redisTemplate;

    public SessionTokenService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void saveToken(String userId, String token) {
        redisTemplate.opsForValue()
            .set("session:" + userId, token, Duration.ofMinutes(30));
    }

    public String getToken(String userId) {
        return redisTemplate.opsForValue().get("session:" + userId);
    }
}
  • set(key, value, ttl): tokenを30分のTTL付きで保存し、Redisが自動で期限切れ時に削除します。
  • get(key): keyが存在しないか期限切れの場合はnullを返します。

期限切れのtokenでgetTokenを呼び出した場合の出力:

null

いつ使うか: sessionトークン、事前にserialize済みのオブジェクト(JSON)のキャッシュ、シンプルなカウンタ(INCR)、feature flag。Stringはatomic incrementもサポートします:

Long visits = redisTemplate.opsForValue().increment("page:views:home");

注意: 1つのfieldを更新するだけのために大きなオブジェクト全体をserialize/deserializeしないでください — それはHashに切り替えるべき兆候です。

4. Hash:多くのfieldを持つオブジェクト

Hashは1つのkey内のMap<String, String>のような構造で、object全体の読み書きなしに個別fieldの更新を可能にします。

@Service
public class UserProfileCache {

    private final RedisTemplate<String, String> redisTemplate;

    public UserProfileCache(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void saveProfile(String userId, Map<String, String> fields) {
        redisTemplate.opsForHash().putAll("user:" + userId, fields);
    }

    public void updateEmail(String userId, String newEmail) {
        redisTemplate.opsForHash().put("user:" + userId, "email", newEmail);
    }

    public Map<Object, Object> getProfile(String userId) {
        return redisTemplate.opsForHash().entries("user:" + userId);
    }
}
  • putAll: 複数のfieldを同時に書き込み、HSETに相当します。
  • putで1つのfield:email fieldのみを更新し、他のfield(name、ageなど)は影響されません。
  • entries: hashのすべてのfield-valueペアを取得します。

Stringとの比較:String + JSONを使う場合、emailを変更するたびにJSON全体をGETし、parse、編集、serialize、そしてSETする必要があります。Hashでは1つのHSETコマンドで済みます。

いつ使うか: 多くのfieldを持つ、または独立して更新されるobject — ユーザープロフィール、製品詳細、テナントごとの設定。

5. List:キューと順序付きの履歴

Listは順序付きリストで、両端のpush/popをサポートします。キューまたは最近のN個のitemを保存するのに適しています。

@Service
public class RecentActivityService {

    private final RedisTemplate<String, String> redisTemplate;

    public RecentActivityService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void logActivity(String userId, String activity) {
        String key = "activity:" + userId;
        redisTemplate.opsForList().leftPush(key, activity);
        redisTemplate.opsForList().trim(key, 0, 9); // 最近の10itemを保持
    }

    public List<String> getRecentActivities(String userId) {
        return redisTemplate.opsForList().range("activity:" + userId, 0, -1);
    }
}
  • leftPush: 新しいactivityをリストの先頭に追加します。
  • trim(key, 0, 9): リストをカットして最初の10要素のみを保持し、リストが無限に成長するのを防ぎます。
  • range(0, -1): すべての要素を取得し、インデックス-1は最後の要素を意味します。

いつ使うか: 最近の活動のtimeline、simple job queue(BLPOPを組み合わせてconsumerが待機)、undo stack。

注意: valueで検索する必要がある場合、または多くのランダムアクセスが必要な場合、Listは適切ではありません — 長いリストではindexによるアクセスの時間計算量はO(n)です。

6. Set:重複しない、順序なしの集合

Setは一意の値を保存し、集合演算(union、intersection、difference)を非常に高速にサポートします。

@Service
public class TagService {

    private final RedisTemplate<String, String> redisTemplate;

    public TagService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void addTags(String articleId, String... tags) {
        redisTemplate.opsForSet().add("article:tags:" + articleId, tags);
    }

    public boolean hasTag(String articleId, String tag) {
        return redisTemplate.opsForSet()
            .isMember("article:tags:" + articleId, tag);
    }

    public Set<String> commonTags(String articleId1, String articleId2) {
        return redisTemplate.opsForSet()
            .intersect("article:tags:" + articleId1, "article:tags:" + articleId2);
    }
}
  • add: 複数のtagを追加し、Redisが自動的に重複を除去します。
  • isMember: 存在確認をO(1)の時間計算量で行い、Listの走査よりもはるかに高速です。
  • intersect: 2つの記事間の共通tagを見つけます — 関連記事の推奨に使用します。

いつ使うか: tag/category、日ごとのユニークvisitorのリスト(SADD visitors:2026-10-05 userId)、高速なmembership確認(例:userがイベントに登録済みか)。

7. Sorted Set:スコアでのランキング

Sorted Set(ZSet)はSetに似ていますが、各memberがscoreを持ち、ソートされます。これはleaderboard問題の最強のデータ型です。

@Service
public class LeaderboardService {

    private final RedisTemplate<String, String> redisTemplate;

    public LeaderboardService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void addScore(String playerId, double score) {
        redisTemplate.opsForZSet().add("leaderboard:weekly", playerId, score);
    }

    public Long getRank(String playerId) {
        // rank 0始まり、存在しない場合はnull
        return redisTemplate.opsForZSet().reverseRank("leaderboard:weekly", playerId);
    }

    public Set<String> getTop10() {
        return redisTemplate.opsForZSet()
            .reverseRange("leaderboard:weekly", 0, 9);
    }
}
  • add(key, member, score): playerのscoreを追加または更新します。
  • reverseRank: scoreの降順でrankを取得します(最高rankが最高score)。
  • reverseRange(0, 9): top 10を取得し、Redisが既にソート済みのため、application層でのsortは不要です。

getTop10()を呼び出した場合の出力例:

["playerA", "playerC", "playerB"]

いつ使うか: leaderboard、priority queue(score = priority)、time windowベースのrate limiting(score = timestamp)。

8. JUnit 5とTestcontainersでの動作確認

実データに影響を与えずにRedis関連のロジックをテストするために、Testcontainersを使用します。

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class LeaderboardServiceTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);

    @DynamicPropertySource
    static void redisProps(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }

    @Autowired
    private LeaderboardService leaderboardService;

    @Test
    void shouldReturnTopPlayersInOrder() {
        leaderboardService.addScore("playerA", 100);
        leaderboardService.addScore("playerB", 50);
        leaderboardService.addScore("playerC", 80);

        Set<String> top = leaderboardService.getTop10();

        assertThat(top).containsExactly("playerA", "playerC", "playerB");
    }
}
  • @Containerは各テストクラスのためにDocker内で実際のRedisを初期化します。
  • @DynamicPropertySourceはhost/portを上書きし、Spring Bootが正しいcontainerに接続するようにします。
  • containsExactlyは正確な順序を確認します — Sorted Setは結果が常にscoreの降順でソートされることを保証します。

9. 簡易比較表

型 構造 使用時期
String key → value objectのキャッシュ、カウンタ、sessionトークン
Hash key → field-valueマップ 多くのfieldを持つobject、部分更新
List key → 順序付きリスト キュー、最近のitems、timeline
Set key → 重複なし集合 tag、membership確認、集合演算
Sorted Set key → scoreを持つmember leaderboard、rate limiting、priority queue

10. よくある間違い

  • 頻繁に部分更新するデータにStringを使う: 複数のthreadが同時にJSON全体を読み書きするときに競合状態が発生します。個別fieldのatomic更新のためにHashに切り替えます。
  • Listをtrimしない: listが無限に増殖し、RAMを浪費し、LRANGEコマンドが時間とともに遅くなります。
  • TTLを設定し忘れる: Redisはデフォルトではkeyを自動削除しないため、ゴミデータが蓄積し続けます。sessionやcacheなどの一時的なデータについては常にexpireを検討してください。
  • 順序が必要なデータにSetを使う: Setは反復順序を保証しないため、sortが必要な場合はSorted Setを使用してください。

注意: Redisはin-memory — すべてのデータはRAM内にあります。大規模、長期保存、または複雑なACIDトランザクション(例:銀行口座残高)が必要なデータを、Redis単独を唯一の真実のソース(source of truth)として保存しないでください。

11. まとめ

Redisは単なる「大きなキャッシュ」ではなく、柔軟なdata structureのツールキットです。正しいデータ型を選ぶこと — 単一値にはString、多くのfieldを持つobjectにはHash、キューにはList、membershipにはSet、ランキングにはSorted Set — により、コードがシンプルになり、コマンド数が減り、application層でロジックを自分で処理する代わりにRedisのatomic operationの力を活用できます。

rediscachingspring-bootnosqlperformance