Redis基础:数据类型及使用场景

1. 总体概述

Redis是内存数据库,通常被误解为“仅用于缓存”。实际上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。值可以是文本、数字或序列化的JSON。

@Service
public class SessionTokenService {

    private final RedisTemplate<String, String> redisTemplate;

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

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

    // 获取token
    public String getToken(String userId) {
        return redisTemplate.opsForValue().get("session:" + userId);
    }
}
  • set(key, value, ttl):保存token并设置30分钟的生存时间,Redis在过期时自动删除。
  • get(key):如果key不存在或已过期,返回null。

调用getToken获取已过期token时的输出:

null

何时使用: session token、缓存已序列化的对象(JSON)、简单计数器(INCR)、特性开关。String也支持原子递增:

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

注意: 不要为了更新1个字段而保存一个大对象并序列化/反序列化整个对象——这表明你应该转向Hash。

4. Hash:具有多个字段的对象

Hash类似于在一个key内的Map<String, String>,允许独立更新每个字段,无需读取/写入整个对象。

@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:同时写入多个字段,类似HSET。
  • put一个字段:仅更新email字段,其他字段(name、age等)不受影响。
  • entries:获取hash的所有field-value。

与String比较:如果使用String + JSON,每次更改电子邮件时,你必须GET整个JSON、解析、修改、重新序列化,然后SET。使用Hash,只需一个HSET命令。

何时使用: 具有多个字段或独立更新的对象——用户配置文件、产品详情、按租户配置。

5. List:队列和有序历史

List是有序列表,支持在两端进行push/pop,适合队列或保存最近N个项目。

@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);
        // 修剪列表,仅保留前10个元素
        redisTemplate.opsForList().trim(key, 0, 9);
    }

    // 获取最近活动
    public List<String> getRecentActivities(String userId) {
        return redisTemplate.opsForList().range("activity:" + userId, 0, -1);
    }
}
  • leftPush:将新活动添加到列表开头。
  • trim(key, 0, 9):修剪列表,仅保留前10个元素,防止列表无限增长。
  • range(0, -1):获取所有元素,索引-1表示最后一个元素。

何时使用: 最近的活动时间线、简单的任务队列(结合BLPOP让消费者等待)、撤销堆栈。

注意: 如果你需要按值搜索或频繁随机访问,List不适合——对于长列表,按索引访问的时间复杂度为O(n)。

6. Set:不重复的无序集合

Set存储唯一值,支持快速的集合操作(并集、交集、差集)。

@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:添加多个标签,Redis自动去除重复。
  • isMember:检查存在性,时间复杂度为O(1),比遍历List快得多。
  • intersect:找到两篇文章的共同标签——用于推荐相关文章。

何时使用: 标签/分类、按日期的唯一访问者列表(SADD visitors:2026-10-05 userId)、快速成员检查(例如:用户是否已注册事件)。

7. Sorted Set:按分数排序

Sorted Set(ZSet)类似Set,但每个成员都有一个score用于排序。这是排行榜问题的最强大类型。

@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) {
        // 从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):添加或更新玩家分数。
  • reverseRank:按分数降序获取排名(最高排名是最高分)。
  • reverseRange(0, 9):获取前10,Redis已自动排序,无需在应用程序中排序。

调用getTop10()时的示例输出:

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

何时使用: 排行榜、优先队列(score = priority)、时间窗口速率限制(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连接到正确的容器。
  • containsExactly检查确切顺序——Sorted Set保证结果始终按分数降序排列。

9. 快速对比表

类型 结构 使用场景
String key → value 缓存对象、计数器、session token
Hash key → field-value map 多字段对象、部分更新
List key → 有序列表 队列、最近项目、时间线
Set key → 不重复集合 标签、成员检查、集合操作
Sorted Set key → 带分数成员 排行榜、速率限制、优先队列

10. 常见错误

  • 对频繁部分更新的数据使用String:导致多个线程同时读-改-写JSON时的竞态条件。改用Hash可以原子更新单个字段。
  • List不修剪:列表无限增长,浪费RAM,LRANGE命令随时间变慢。
  • 忘记设置TTL:Redis默认不自动删除key,垃圾数据会不断积累。始终为临时数据(如session、cache)考虑expire。
  • 对需要顺序的数据使用Set:Set不保证迭代顺序,如果需要排序,使用Sorted Set。

注意: Redis是内存数据库——所有数据都在RAM中。不要将大型、长期或需要复杂ACID事务的数据(例如:银行账户余额)存储在Redis中作为唯一的真实源。

11. 结论

Redis不是“大缓存”,而是灵活的数据结构工具集。选择正确的数据类型——String用于单个值、Hash用于多字段对象、List用于队列、Set用于成员关系、Sorted Set用于排名——有助于代码更简洁、更少的命令,并利用Redis的原子操作的强大力量,而不是在应用层自己处理这些逻辑。

rediscachingspring-bootnosqlperformance