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:emailfieldのみを更新し、他の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の力を活用できます。