吃透 Redis 核心原理:内存模型、数据结构与持久化,从根上解决 90% 线上问题

0 阅读33分钟

很多开发者用Redis,停留在set、get的表层用法,线上遇到OOM、数据丢失、缓存雪崩、接口超时等问题时,往往只能靠临时搜索解决,却找不到根因。本质上,是没有吃透Redis的三大核心基石:内存模型、底层数据结构、持久化机制。

第一章 Redis内存模型:搞懂内存,才算真正入门Redis

Redis是基于内存的数据库,所有操作都在内存中完成,这是它高性能的核心前提。不理解内存模型,就无法从根本上优化Redis性能、解决线上内存问题。

1.1 Redis内存的完整划分

很多人误以为Redis的内存只用来存储业务数据,实则不然,Redis的内存占用分为五大核心部分,每一部分都可能成为线上问题的导火索:

  1. 数据内存:最核心的部分,存储所有的键值对数据,占比最高,也是我们主要优化的对象。
  2. 客户端缓冲区内存:分为普通客户端缓冲区、复制客户端缓冲区、Pub/Sub客户端缓冲区,用于缓存客户端的输入输出命令。如果客户端消费速度远慢于Redis发送速度,缓冲区会持续膨胀,最终导致OOM。
  3. 复制积压缓冲区:主从架构下,主节点用于缓存最近执行的写命令,解决从节点临时断连后的增量同步问题,避免全量同步的性能开销。
  4. Lua脚本内存:存储Lua脚本相关的内容,包括加载的脚本、脚本执行过程中的临时数据。
  5. 进程自身运行内存:Redis进程本身运行所需的内存,比如代码、常量、堆栈等,占比极低,基本无需关注。

1.2 内存分配器与内存碎片优化

Redis不会直接向操作系统申请/释放内存,而是通过内存分配器统一管理,默认使用jemalloc,同时支持tcmalloc、glibc malloc。

jemalloc的核心优势是内存碎片率极低,它将内存划分为不同大小的内存块,根据申请的内存大小分配最匹配的块,避免了大量细碎空闲内存的产生。

内存碎片的核心指标与排查

通过info memory命令可以查看内存核心指标,其中两个关键指标决定了碎片情况:

  • used_memory:Redis实际申请的内存总量,包含所有数据、缓冲区等。
  • used_memory_rss:操作系统给Redis进程分配的物理内存总量,包含内存碎片。
  • mem_fragmentation_ratio:内存碎片率,等于used_memory_rss / used_memory

碎片率的健康范围:

  • 1 < 碎片率 < 1.5:健康状态,属于正常的内存碎片。
  • 碎片率 > 1.5:碎片严重,会导致Redis占用过多物理内存,甚至触发OOM。
  • 碎片率 < 1:Redis内存被交换到了Swap分区,磁盘IO会导致Redis性能暴跌,线上必须杜绝。

内存碎片优化方案

  1. 开启内存碎片自动整理:Redis 4.0+支持,通过activedefrag yes开启,配合active-defrag-ignore-bytesactive-defrag-threshold-lower等参数控制整理触发条件,避免整理过程影响业务性能。
  2. 避免频繁更新短字符串:频繁的修改操作会产生大量内存碎片,尽量批量更新、减少无效修改。
  3. 重启Redis:极端情况下,重启Redis会重新加载数据,彻底消除内存碎片,适合低峰期操作。

1.3 键过期策略:过期key到底是怎么被删除的

给key设置过期时间是业务中最常用的操作,但很多人不知道,过期的key并不是到点就会被立即删除,Redis采用了惰性删除+定期删除的组合策略,平衡CPU性能和内存开销。

三种过期删除策略的对比

策略实现逻辑优点缺点
定时删除给每个设置过期时间的key创建定时器,到期立即删除内存最友好,过期key立即释放内存CPU极不友好,大量过期key同时到期时,会占用大量CPU资源,影响业务请求
惰性删除key到期时不处理,只有当客户端访问该key时,才检查是否过期,过期则删除CPU最友好,只在访问时处理,无额外CPU开销内存极不友好,大量过期key永远不会被访问,占用大量内存不释放
定期删除每隔一段时间,抽取一定数量设置了过期时间的key,删除其中过期的key平衡CPU和内存开销,可通过参数调整执行频率需合理配置参数,否则会出现CPU占用过高或内存泄漏问题

Redis的过期策略实现细节

Redis默认采用惰性删除+定期删除的组合策略,两者互补,既不会占用过多CPU,也不会导致大量过期key堆积。

  1. 惰性删除:所有读写命令执行前,都会先检查key是否过期,过期则删除并返回key不存在,该逻辑在命令执行的入口处统一处理,无额外性能开销。

  2. 定期删除:由Redis的定时任务serverCron执行,默认hz=10,即每秒执行10次,每次执行逻辑:

    • 从过期key字典中随机抽取20个key;
    • 删除其中已经过期的key;
    • 如果过期key的占比超过25%,重复上述抽取删除流程;
    • 单次执行时间不超过CPU时间的25%,避免阻塞业务请求。

1.4 内存淘汰策略:内存满了Redis会怎么办

当Redis的内存使用量达到maxmemory阈值时,会触发内存淘汰策略,选择符合规则的key进行删除,保证新的写命令可以正常执行。很多人会把过期策略和淘汰策略混淆,这里明确区分:过期策略处理的是已经过期的key,淘汰策略处理的是内存不足时,还未过期的key

Redis 7.0提供了8种内存淘汰策略,分为四大类:

  1. 不淘汰策略

    • noeviction:默认策略,内存达到maxmemory时,拒绝所有写命令,返回OOM错误,读命令正常执行。适合数据绝对不能丢失的场景,一般不推荐线上使用。
  2. LRU淘汰策略(最近最少使用)

    • allkeys-lru:对所有key,使用近似LRU算法淘汰最近最少使用的key,适合绝大多数缓存场景,冷热数据区分明显的业务。
    • volatile-lru:只对设置了过期时间的key,使用近似LRU算法淘汰,适合需要保留核心非过期数据的场景。
  3. LFU淘汰策略(最不经常使用)

    • allkeys-lfu:对所有key,使用LFU算法淘汰访问频率最低的key,适合热点数据长期存在的场景,比如爆款商品缓存。
    • volatile-lfu:只对设置了过期时间的key,使用LFU算法淘汰。
  4. 随机/TTL淘汰策略

    • allkeys-random:对所有key,随机淘汰,适合所有key访问概率均等的场景。
    • volatile-random:只对设置了过期时间的key,随机淘汰。
    • volatile-ttl:只对设置了过期时间的key,淘汰剩余TTL最短的key,优先淘汰即将过期的key。

核心算法细节

  • 近似LRU算法:Redis没有采用传统的LRU双向链表,因为链表会占用额外内存,且插入删除有性能开销。Redis的近似LRU会随机抽取N个key(默认5个),淘汰其中最久未被访问的key,通过调整采样数量,可以无限接近全局LRU的效果,同时性能开销极低。
  • LFU算法:Redis 4.0+引入,给每个key维护一个访问计数器,计数器采用对数增长模式,访问次数越多,计数器增长越慢,同时会定期衰减计数器,避免历史热点数据长期占用内存。

1.5 线上内存问题排查核心指标

通过info memory命令,可快速定位线上内存问题,核心指标解读:

  • used_memory_peak:Redis历史内存峰值,可判断是否有过内存突增的情况。
  • used_memory_lua:Lua脚本占用的内存,若持续增长,说明存在Lua脚本内存泄漏。
  • used_memory_overhead:所有非数据内存的总和,若该值过高,需排查客户端缓冲区、复制积压缓冲区是否溢出。
  • maxmemory_policy:当前生效的内存淘汰策略,线上需确认是否符合业务预期。

第二章 Redis核心数据结构:选对结构,性能提升10倍

Redis的高性能,不仅来自于内存操作,更来自于其精心设计的底层数据结构。很多业务性能问题,本质上是用错了数据结构。比如用String存储用户全量信息,每次更新单个字段都要全量序列化反序列化,性能极差,而用Hash结构就能完美解决。

2.1 Redis对象系统:所有数据类型的顶层抽象

Redis对外提供了8种数据类型,而所有数据类型的顶层都是redisObject对象,其结构决定了Redis数据类型的核心特性:

typedef struct redisObject {
    unsigned type:4;        // 数据类型,4bit,对应String、Hash、List等
    unsigned encoding:4;    // 底层编码,4bit,对应该数据类型的底层实现结构
    unsigned lru:24;        // LRU/LFU相关信息,24bit,记录访问时间/频率
    int refcount;           // 引用计数,用于内存回收和对象共享
    void *ptr;              // 指针,指向底层实际存储数据的结构
} robj;

核心字段解读:

  • type:对外暴露的数据类型,我们常用的String、Hash、List、Set、Sorted Set等,通过type key命令可查看。
  • encoding:底层实际的编码结构,同一个type可以对应多种encoding,Redis会根据数据的长度、数量、类型自动选择最优的编码,极大节省内存、提升性能,通过object encoding key命令可查看。
  • refcount:引用计数,当对象被引用时计数+1,引用解除时计数-1,计数为0时对象被回收,同时Redis会共享0-9999的整数对象,减少内存开销。
  • lru:记录对象的访问信息,用于内存淘汰策略。

2.2 String:最常用却最容易用错的类型

String是Redis最基础的数据类型,最大存储容量为512MB,支持字符串、整数、浮点数三种格式,同时支持位运算、原子增减等操作。

底层编码结构

String有三种底层编码,Redis会自动根据数据内容选择最优编码,内存占用从低到高排序:

  1. int编码:当value是整数值,且范围在long类型的范围内(64位系统为-2^63 ~ 2^63-1),Redis会直接将值存储在redisObject的ptr字段中,无需额外的内存分配,内存占用极低。
  2. embstr编码:当value是长度≤44字节的字符串,Redis会一次性分配连续的内存块,同时包含redisObject和sds结构,内存连续,只需一次内存分配/释放,缓存命中率更高,性能更好。
  3. raw编码:当value是长度>44字节的字符串,Redis会分两次分配内存,分别存储redisObject和sds结构,适合长字符串存储。

44字节阈值的由来:64位系统下,jemalloc的最小内存块为64字节,redisObject固定占用16字节,sds的sdshdr8头部占用3字节,字符串结尾的\0占用1字节,剩余可存储有效数据的长度为64-16-3-1=44字节。

核心适用场景

  • 通用缓存:比如文章内容、商品详情、配置信息等。
  • 原子计数器:比如文章阅读量、点赞数、接口限流计数,基于incr/decr命令实现原子操作,无并发问题。
  • 分布式锁:基于set nx ex命令实现,配合Lua脚本保证加解锁的原子性。
  • 位图操作:基于setbit/getbit命令实现Bitmap,适合海量数据的去重、签到、状态统计等场景。

实战代码示例

package com.jam.demo.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import com.google.common.collect.Lists;

import java.util.concurrent.TimeUnit;

/**
 * Redis String类型操作接口
 *
 * @author ken
 * @date 2026-03-20
 */
@Slf4j
@RestController
@RequestMapping("/redis/string")
@Tag(name = "Redis String操作", description = "Redis String类型核心操作示例")
public class RedisStringController {

    private final StringRedisTemplate stringRedisTemplate;

    private static final DefaultRedisScript<LongUNLOCK_SCRIPT;

    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setScriptText("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end");
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    public RedisStringController(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 缓存数据写入
     *
     * @param key   缓存key
     * @param value 缓存value
     * @param expire 过期时间,单位秒
     * @return 操作结果
     */
    @PostMapping("/set")
    @Operation(summary = "写入String缓存", description = "写入带过期时间的String类型缓存数据")
    public String setCache(
            @Parameter(description = "缓存key", required = true) @RequestParam String key,
            @Parameter(description = "缓存value", required = true) @RequestParam String value,
            @Parameter(description = "过期时间(秒)", required = true) @RequestParam Long expire) {
        if (!StringUtils.hasText(key)) {
            return "缓存key不能为空";
        }
        if (!StringUtils.hasText(value)) {
            return "缓存value不能为空";
        }
        if (expire == null || expire <= 0) {
            return "过期时间必须大于0";
        }
        stringRedisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
        return "success";
    }

    /**
     * 缓存数据读取
     *
     * @param key 缓存key
     * @return 缓存value
     */
    @GetMapping("/get")
    @Operation(summary = "读取String缓存", description = "读取String类型的缓存数据")
    public String getCache(@Parameter(description = "缓存key", required = true) @RequestParam String key) {
        if (!StringUtils.hasText(key)) {
            return "缓存key不能为空";
        }
        return stringRedisTemplate.opsForValue().get(key);
    }

    /**
     * 原子计数器递增
     *
     * @param key 计数器key
     * @return 递增后的值
     */
    @PostMapping("/incr")
    @Operation(summary = "原子计数器递增", description = "实现String类型的原子递增操作,适用于计数、限流场景")
    public Long incrCounter(@Parameter(description = "计数器key", required = true) @RequestParam String key) {
        if (!StringUtils.hasText(key)) {
            throw new IllegalArgumentException("计数器key不能为空");
        }
        return stringRedisTemplate.opsForValue().increment(key);
    }

    /**
     * 分布式锁加锁
     *
     * @param lockKey 锁key
     * @param requestId 请求唯一标识,用于解锁校验
     * @param expireTime 锁过期时间,单位秒
     * @return 加锁结果
     */
    @PostMapping("/lock")
    @Operation(summary = "分布式锁加锁", description = "基于String类型实现的分布式锁,保证原子性")
    public boolean tryLock(
            @Parameter(description = "锁key", required = true) @RequestParam String lockKey,
            @Parameter(description = "请求唯一标识", required = true) @RequestParam String requestId,
            @Parameter(description = "锁过期时间(秒)", required = true) @RequestParam Long expireTime) {
        if (!StringUtils.hasText(lockKey)) {
            throw new IllegalArgumentException("锁key不能为空");
        }
        if (!StringUtils.hasText(requestId)) {
            throw new IllegalArgumentException("请求唯一标识不能为空");
        }
        if (expireTime == null || expireTime <= 0) {
            throw new IllegalArgumentException("过期时间必须大于0");
        }
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, requestId, expireTime, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(result);
    }

    /**
     * 分布式锁解锁
     *
     * @param lockKey 锁key
     * @param requestId 请求唯一标识,必须与加锁时一致
     * @return 解锁结果
     */
    @PostMapping("/unlock")
    @Operation(summary = "分布式锁解锁", description = "基于Lua脚本实现的原子解锁,防止误解锁")
    public boolean unlock(
            @Parameter(description = "锁key", required = true) @RequestParam String lockKey,
            @Parameter(description = "请求唯一标识", required = true) @RequestParam String requestId) {
        if (!StringUtils.hasText(lockKey)) {
            throw new IllegalArgumentException("锁key不能为空");
        }
        if (!StringUtils.hasText(requestId)) {
            throw new IllegalArgumentException("请求唯一标识不能为空");
        }
        Long result = stringRedisTemplate.execute(UNLOCK_SCRIPT, Lists.newArrayList(lockKey), requestId);
        return result != null && result == 1;
    }
}

2.3 Hash:结构化数据存储的最优解

Hash类型是一个键值对集合,类似于Java中的HashMap,适合存储结构化的对象数据,比如用户信息、商品详情等。相比String序列化存储对象,Hash可以单独更新/读取单个字段,无需全量序列化反序列化,性能更高,内存占用更低。

底层编码结构

Hash有两种底层编码,Redis会自动根据字段数量和字段值长度切换:

  1. listpack编码:Redis 7.0+默认编码,替代了之前的ziplist,是一种紧凑的列表结构,将所有的field和value连续存储在一块内存中,内存占用极低,解决了ziplist的级联更新问题。当Hash的字段数量≤hash-max-listpack-entries(默认512),且所有字段值的长度≤hash-max-listpack-value(默认64字节)时,使用该编码。
  2. hashtable编码:当Hash的字段数量或字段值长度超过上述阈值时,Redis会自动切换为hashtable编码,也就是哈希表,保证读写性能稳定在O(1)。

核心适用场景

  • 结构化对象存储:比如用户信息、商品信息、配置信息等,支持单字段读写,更新成本极低。
  • 购物车场景:以用户ID为key,商品ID为field,商品数量为value,完美支持添加、修改、删除、清空购物车等操作。
  • 多维度计数:比如文章的点赞数、评论数、阅读数,可分别用不同的field存储,批量更新读取。

代码示例

用户实体类

package com.jam.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.io.Serializable;

/**
 * 用户实体类
 *
 * @author ken
 * @date 2026-03-20
 */
@Data
@TableName("t_user")
@Schema(description = "用户实体")
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.AUTO)
    @Schema(description = "用户ID")
    private Long userId;

    @Schema(description = "用户名")
    private String userName;

    @Schema(description = "用户年龄")
    private Integer age;

    @Schema(description = "用户手机号")
    private String phone;

    @Schema(description = "用户邮箱")
    private String email;
}

用户Mapper接口

package com.jam.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.User;
import org.apache.ibatis.annotations.Mapper;

/**
 * 用户Mapper接口
 *
 * @author ken
 * @date 2026-03-20
 */
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

Hash操作接口

package com.jam.demo.controller;

import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.jam.demo.entity.User;
import com.jam.demo.mapper.UserMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

/**
 * Redis Hash类型操作接口
 *
 * @author ken
 * @date 2026-03-20
 */
@Slf4j
@RestController
@RequestMapping("/redis/hash")
@Tag(name = "Redis Hash操作", description = "Redis Hash类型核心操作示例")
public class RedisHashController {

    private final StringRedisTemplate stringRedisTemplate;

    private final UserMapper userMapper;

    private static final String USER_CACHE_PREFIX = "user:info:";

    public RedisHashController(StringRedisTemplate stringRedisTemplate, UserMapper userMapper) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.userMapper = userMapper;
    }

    /**
     * 保存用户信息到Hash缓存
     *
     * @param userId 用户ID
     * @return 操作结果
     */
    @PostMapping("/save/user/{userId}")
    @Operation(summary = "保存用户信息到Hash缓存", description = "从MySQL查询用户数据,存入Redis Hash结构,支持单字段更新")
    public String saveUserToHash(@Parameter(description = "用户ID", required = true) @PathVariable Long userId) {
        if (ObjectUtils.isEmpty(userId)) {
            return "用户ID不能为空";
        }
        User user = userMapper.selectById(userId);
        if (ObjectUtils.isEmpty(user)) {
            return "用户不存在";
        }
        String cacheKey = USER_CACHE_PREFIX + userId;
        HashOperations<StringStringStringhashOps = stringRedisTemplate.opsForHash();
        Map<StringStringuserMap = JSON.parseObject(JSON.toJSONString(user), Map.class);
        hashOps.putAll(cacheKey, userMap);
        return "success";
    }

    /**
     * 从Hash缓存获取用户全量信息
     *
     * @param userId 用户ID
     * @return 用户信息
     */
    @GetMapping("/get/user/{userId}")
    @Operation(summary = "获取用户全量信息", description = "从Hash缓存中读取用户的全量字段信息")
    public Map<StringStringgetUserFromHash(@Parameter(description = "用户ID", required = true) @PathVariable Long userId) {
        if (ObjectUtils.isEmpty(userId)) {
            throw new IllegalArgumentException("用户ID不能为空");
        }
        String cacheKey = USER_CACHE_PREFIX + userId;
        HashOperations<StringStringStringhashOps = stringRedisTemplate.opsForHash();
        return hashOps.entries(cacheKey);
    }

    /**
     * 更新用户单个字段信息
     *
     * @param userId 用户ID
     * @param field 字段名
     * @param value 字段值
     * @return 操作结果
     */
    @PostMapping("/update/user/field")
    @Operation(summary = "更新用户单个字段", description = "更新Hash缓存中的单个字段,无需全量序列化,性能更优")
    public String updateUserSingleField(
            @Parameter(description = "用户ID", required = true) @RequestParam Long userId,
            @Parameter(description = "字段名", required = true) @RequestParam String field,
            @Parameter(description = "字段值", required = true) @RequestParam String value) {
        if (ObjectUtils.isEmpty(userId)) {
            return "用户ID不能为空";
        }
        if (!StringUtils.hasText(field)) {
            return "字段名不能为空";
        }
        if (!StringUtils.hasText(value)) {
            return "字段值不能为空";
        }
        String cacheKey = USER_CACHE_PREFIX + userId;
        HashOperations<StringStringStringhashOps = stringRedisTemplate.opsForHash();
        hashOps.put(cacheKey, field, value);
        return "success";
    }

    /**
     * 获取用户单个字段信息
     *
     * @param userId 用户ID
     * @param field 字段名
     * @return 字段值
     */
    @GetMapping("/get/user/field")
    @Operation(summary = "获取用户单个字段", description = "从Hash缓存中读取单个字段的值,无需传输全量数据")
    public String getUserSingleField(
            @Parameter(description = "用户ID", required = true) @RequestParam Long userId,
            @Parameter(description = "字段名", required = true) @RequestParam String field) {
        if (ObjectUtils.isEmpty(userId)) {
            throw new IllegalArgumentException("用户ID不能为空");
        }
        if (!StringUtils.hasText(field)) {
            throw new IllegalArgumentException("字段名不能为空");
        }
        String cacheKey = USER_CACHE_PREFIX + userId;
        HashOperations<StringStringStringhashOps = stringRedisTemplate.opsForHash();
        return hashOps.get(cacheKey, field);
    }
}

2.4 List:双向链表的工程化优化

List类型是有序的字符串列表,按照插入顺序排序,支持两端插入和弹出操作,类似于Java中的LinkedList,时间复杂度方面,头尾操作是O(1),中间元素操作是O(N)。

底层编码结构

Redis 3.2+之后,List的底层统一使用quicklist编码,替代了之前的ziplist和linkedlist。quicklist是一个双向链表,链表的每个节点都是一个listpack紧凑列表,结合了链表和紧凑列表的优势:

  • 双向链表保证了头尾插入弹出的O(1)性能,支持快速的前后遍历。
  • 每个节点的listpack紧凑存储,减少了链表的指针内存开销,降低了内存碎片,同时避免了大listpack的级联更新问题。

通过list-max-listpack-size参数控制每个quicklist节点的listpack大小,默认值为-2,代表每个listpack的大小不超过8KB,平衡了内存占用和读写性能。

核心适用场景

  • 简单消息队列:基于lpush + rpoprpush + lpop实现先进先出的消息队列,支持生产者消费者模式。
  • timeline/粉丝列表:按照插入顺序存储,支持分页查询,比如微博的动态列表、用户的粉丝列表。
  • 栈结构:基于lpush + lpop实现先进后出的栈结构。

2.5 Set:无序去重的集合实现

Set类型是无序的字符串集合,不允许重复元素,底层基于哈希表实现,所有操作的时间复杂度都是O(1),同时支持多个集合之间的交集、并集、差集操作。

底层编码结构

Set有两种底层编码,自动切换:

  1. intset编码:当集合中的所有元素都是整数,且元素数量≤set-max-intset-entries(默认512)时,使用整数集合编码,内存占用极低,所有元素有序存储,支持二分查找。
  2. hashtable编码:当元素数量或类型超过上述阈值时,切换为哈希表编码,保证读写性能稳定。

核心适用场景

  • 去重场景:比如用户点赞、文章浏览去重、标签系统等。
  • 社交关系计算:比如共同好友(交集)、关注的人也关注了谁(差集)、可能认识的人(并集)等。
  • 抽奖系统:基于srandmemberspop命令实现随机抽奖,支持不重复抽奖。

2.6 Sorted Set:有序排名的核心底层跳表

Sorted Set(简称ZSet)是有序的字符串集合,每个元素都关联一个double类型的分数(score),Redis按照分数对元素进行排序,分数相同则按照元素字典序排序。ZSet兼顾了有序性和高性能,是排行榜、延时队列等场景的最优解。

底层编码结构

ZSet有两种底层编码,自动切换:

  1. listpack编码:当元素数量≤zset-max-listpack-entries(默认128),且每个元素的长度≤zset-max-listpack-value(默认64字节)时,使用listpack紧凑存储,元素和分数连续存储,按分数有序排列,内存占用极低。
  2. skiplist+hashtable编码:当元素数量或长度超过阈值时,切换为该编码。其中hashtable用于存储元素到分数的映射,保证O(1)的元素分数查询;skiplist(跳表)用于存储分数和元素,保证O(logN)的范围查询和有序操作。

跳表的核心逻辑

很多人会问,为什么Redis用跳表而不用红黑树实现ZSet?核心原因有三点:

  1. 范围查询更友好:跳表的范围查询只需要找到起点,然后沿着链表遍历即可,实现简单,性能更优;红黑树的范围查询需要复杂的中序遍历,实现难度大。
  2. 实现与维护更简单:跳表的插入、删除操作只需要修改相邻节点的指针,无需像红黑树那样进行复杂的旋转操作,代码实现更简单,并发控制更容易。
  3. 性能相当:跳表的平均时间复杂度为O(logN),最坏为O(N),和红黑树相当,完全满足Redis的性能需求。

核心适用场景

  • 排行榜系统:比如商品销量榜、文章阅读榜、用户积分榜,支持实时更新排名、查询TopN、查询用户排名等操作。
  • 延时队列:以任务执行时间为score,任务ID为元素,定时扫描score小于当前时间的元素,实现延时任务执行。
  • 范围查询:比如按分数范围查询用户、按时间范围查询数据等。

2.7 高频扩展数据结构与选型对比

除了上述5种核心数据类型,Redis还提供了4种高频扩展数据结构,覆盖更多业务场景:

  1. Bitmap:底层基于String类型实现,用位来存储数据,每个位只能是0或1,最大支持2^32位,也就是512MB。适合海量数据的签到、打卡、去重、状态统计等场景,1亿用户的签到数据只需要12MB内存。
  2. HyperLogLog:底层基于String类型实现,是一种概率算法,用于估算集合的基数(不重复元素数量),最大只占用12KB内存,就能估算2^64个元素的基数,标准误差为0.81%。适合海量UV统计、访问量统计等不需要绝对精确的去重计数场景。
  3. Geo:底层基于ZSet实现,用geohash算法将经纬度编码为整数,作为ZSet的score,支持地理位置的存储、距离计算、附近的人查询等场景,适合外卖、打车、同城社交等LBS业务。
  4. Stream:Redis 5.0+引入的专门为消息队列设计的数据结构,底层基于基数树实现,支持消息持久化、多消费组、消息确认、消息回溯等功能,解决了List做消息队列的消息丢失、无法多消费、无法回溯等问题,是Redis实现消息队列的最优解。

第三章 Redis持久化机制:解决数据丢失的终极方案

Redis所有操作都在内存中,一旦服务器宕机,内存中的所有数据都会丢失。持久化的本质,就是将内存中的数据写入磁盘,保证宕机后可以从磁盘恢复数据,是Redis数据可靠性的核心保障。

Redis提供了三种持久化方案:RDB快照、AOF日志、混合持久化,每种方案都有各自的优缺点,线上需根据业务场景选择合适的方案。

3.1 RDB快照:时间点全量数据备份

RDB是Redis默认开启的持久化方案,它会生成某个时间点内存中所有数据的全量快照,保存为一个二进制的RDB文件,该文件经过压缩,体积很小,非常适合备份、灾备、全量数据传输。

触发方式

RDB的触发分为手动触发和自动触发两种:

  1. 手动触发

    • save命令:同步生成RDB文件,执行期间会阻塞Redis的所有业务请求,直到RDB文件生成完成,线上绝对禁止使用,只适合停机维护场景。
    • bgsave命令:后台异步生成RDB文件,Redis会fork出一个子进程,由子进程负责生成RDB文件,父进程继续处理业务请求,只有fork的瞬间会短暂阻塞,是线上手动触发的唯一推荐方式。
  2. 自动触发

    • 配置文件中的save m n规则:比如save 900 1代表900秒内至少有1个key被修改,就自动触发bgsave,可配置多条规则,满足任意一条就会触发。
    • 主从全量同步:主节点会自动触发bgsave,生成RDB文件发送给从节点,完成全量数据同步。
    • 正常关闭Redis:执行shutdown命令时,会自动触发bgsave,生成RDB文件。

bgsave执行流程

写时复制(COW)核心机制

很多人会问,bgsave执行期间,父进程修改了数据,子进程生成的RDB文件会不会包含新修改的数据?答案是不会,RDB文件是fork那一刻的内存全量快照,这得益于操作系统的写时复制(Copy On Write) 机制。

fork之后,父子进程共享同一块虚拟内存空间,对应的物理内存页是共享的,只有当父进程修改某一个物理内存页的数据时,操作系统才会复制该物理页,生成一个新的副本,父进程操作新的副本,子进程仍然使用原来的物理页,保证了子进程生成的RDB文件是fork那一刻的内存视图,不会被父进程的修改影响。

RDB的优缺点

  • 优点

    1. RDB是二进制压缩文件,体积小,非常适合全量备份、灾备、跨机房数据传输。
    2. 数据恢复速度极快,远快于AOF,适合宕机后快速恢复业务。
    3. 对Redis性能影响极小,bgsave只有fork瞬间短暂阻塞,后续子进程生成RDB文件不会影响父进程的业务处理。
  • 缺点

    1. 数据安全性差,无法做到实时持久化,两次RDB生成之间的所有数据,在宕机时都会丢失,比如配置save 60 10000,最多会丢失60秒的数据。
    2. fork阻塞风险,当Redis内存很大时,fork需要复制大量的内存页表,阻塞时间会变长,甚至达到几百毫秒,导致Redis卡顿,影响业务。
    3. 全量生成成本高,每次bgsave都需要遍历全量内存,生成完整的RDB文件,频繁触发会带来大量的磁盘IO开销。

3.2 AOF日志:每一条写命令的可靠记录

AOF(Append Only File)持久化,是将Redis执行的每一条写命令,以Redis协议的文本格式,追加到AOF文件的末尾,Redis重启时,会重放AOF文件中的所有写命令,恢复内存中的数据。

AOF执行流程

AOF的执行分为四个核心步骤:命令写入、文件同步、文件重写、重启加载。

  1. 命令写入:Redis执行的所有写命令,都会先写入到aof_buf内存缓冲区,而不是直接写入磁盘,避免每次写命令都触发磁盘IO,导致性能暴跌。
  2. 文件同步:根据配置的刷盘策略,将aof_buf中的数据刷写到磁盘的AOF文件中,这是AOF数据安全性的核心。
  3. 文件重写:当AOF文件大小达到阈值时,触发AOF重写,压缩AOF文件的体积,避免文件无限膨胀。
  4. 重启加载:Redis重启时,会优先加载AOF文件(开启AOF时),重放所有写命令,恢复数据。

核心刷盘策略

AOF的刷盘策略由appendfsync参数控制,有三个可选值,平衡了数据安全性和性能:

  1. always:每一条写命令写入aof_buf后,都会立即调用fsync刷写到磁盘,刷盘完成后命令才执行成功。数据安全性最高,宕机最多丢失一条命令的数据,但性能极差,磁盘IO压力巨大,只适合数据绝对不能丢失的金融场景。
  2. everysec:默认策略,每秒调用一次fsync,将aof_buf中的数据刷写到磁盘,平衡了性能和数据安全性,宕机最多丢失1秒的数据,是线上绝大多数场景的推荐配置。
  3. no:不主动调用fsync,由操作系统控制何时将数据刷写到磁盘,Linux系统默认30秒刷盘一次。性能最好,但数据安全性最差,宕机最多丢失30秒的数据,线上绝对不推荐使用。

AOF重写机制

随着Redis的运行,AOF文件会越来越大,比如同一个key被set了100次,AOF文件中会记录100条set命令,而实际恢复数据时,只需要最后一条set命令即可。AOF重写的核心目的,就是压缩AOF文件的体积,删除无效的命令,减少磁盘占用,加快重启恢复速度。

AOF重写不是读取旧的AOF文件进行修改,而是直接遍历Redis当前内存中的所有数据,为每个key生成对应的写命令,写入到新的AOF临时文件中,重写完成后,用新的AOF文件替换旧的文件,极大压缩了文件体积。

AOF重写执行流程

重写期间,父进程的新写命令会同时写入aof_bufaof_rewrite_buf两个缓冲区:

  • aof_buf的数据会按照刷盘策略正常刷写到旧的AOF文件,保证重写期间AOF的正常工作,宕机不会丢失数据。
  • aof_rewrite_buf的数据会在子进程完成新AOF文件生成后,追加到新的AOF文件中,保证重写期间的新写命令不会丢失。

AOF的优缺点

  • 优点

    1. 数据安全性高,默认everysec策略最多丢失1秒的数据,远高于RDB的安全性。
    2. 写入性能高,基于追加写的方式,无需磁盘随机IO,顺序写入性能极高。
    3. 容错性强,AOF文件是文本格式,即使出现文件末尾不完整的情况,也可以通过redis-check-aof工具修复,恢复数据。
  • 缺点

    1. AOF文件体积远大于RDB文件,即使经过重写,仍然比RDB文件大很多。
    2. 恢复速度慢,重启时需要重放所有写命令,数据量越大,恢复时间越长,远慢于RDB。
    3. 性能影响更大,每秒的fsync刷盘会带来一定的磁盘IO开销,高并发写场景下,可能会出现fsync延迟,导致Redis卡顿。

3.3 混合持久化:兼顾性能与安全的最优解

RDB和AOF都有各自无法解决的缺点,RDB丢数据多,AOF恢复慢。Redis 4.0+推出了混合持久化方案,结合了RDB和AOF的优点,完美解决了两者的痛点,Redis 7.0默认开启该功能。

混合持久化的核心原理

混合持久化基于AOF重写实现,开启aof-use-rdb-preamble yes后,AOF重写时,会将重写这一刻的全量内存数据,以RDB二进制格式写入到新的AOF文件的开头,然后将重写期间的增量写命令,以AOF文本格式追加到AOF文件的末尾。

最终的AOF文件,前半部分是RDB格式的全量数据,后半部分是AOF格式的增量命令。Redis重启时,会先加载开头的RDB全量数据,再重放后面的增量AOF命令,兼顾了RDB恢复速度快和AOF数据安全性高的优点。

混合持久化的优缺点

  • 优点

    1. 恢复速度极快,全量数据通过RDB加载,远快于重放全量AOF命令。
    2. 数据安全性高,和AOF一致,最多丢失1秒的数据。
    3. AOF文件体积更小,全量数据用RDB压缩存储,增量命令用AOF存储,体积远小于纯AOF文件。
  • 缺点

    1. AOF文件的可读性差,开头是RDB二进制格式,无法直接查看修改。
    2. 兼容性差,混合持久化生成的AOF文件,无法被Redis 4.0之前的版本识别加载。

3.4 持久化线上最佳实践与坑点避坑

线上推荐配置

  1. 开启混合持久化,aof-use-rdb-preamble yes,兼顾性能和数据安全。
  2. 开启AOF持久化,appendonly yesappendfsync everysec,平衡性能和数据安全。
  3. 合理配置RDB自动触发规则,避免频繁bgsave,推荐save 3600 1,即1小时至少1个key修改就触发bgsave,同时保留手动bgsave备份的能力。
  4. 配置auto-aof-rewrite-percentage 100auto-aof-rewrite-min-size 64mb,即AOF文件比上次重写后增长100%,且最小体积达到64MB时,自动触发AOF重写。
  5. 关闭AOF的no-appendfsync-on-rewrite,设置为no,避免重写期间丢失数据,高并发场景可临时设置为yes,降低磁盘IO压力。

核心坑点避坑

  1. fork阻塞问题

    • 现象:Redis定期出现卡顿,耗时和Redis内存大小正相关。
    • 根因:bgsave或bgrewriteaof触发fork,内存越大,复制的页表越多,fork阻塞时间越长。
    • 解决方案:控制Redis单机内存不超过20GB;关闭Linux透明大页(THP),THP会导致COW复制的内存页变大,fork耗时变长;将持久化操作放到从节点执行,主节点不开启持久化,避免主节点fork阻塞影响业务。
  2. AOF fsync延迟导致的卡顿

    • 现象:高并发写场景下,Redis出现周期性卡顿,慢日志中出现大量简单命令耗时过长。
    • 根因:磁盘IO压力过大,fsync刷盘操作等待磁盘IO完成,导致Redis主线程阻塞。
    • 解决方案:将AOF和RDB文件放到SSD磁盘,提升IO性能;避免同时触发bgsave和bgrewriteaof,减少磁盘IO峰值;配置redis.conf中的stop-writes-on-bgsave-error no,避免bgsave失败后拒绝写命令。
  3. 数据丢失的核心场景

    • 场景1:主从架构下,主节点宕机,还未同步到从节点的数据丢失。解决方案:配置主节点的min-replicas-to-write 1min-replicas-max-lag 10,保证至少有1个从节点延迟不超过10秒,否则拒绝写命令。
    • 场景2:AOF刷盘策略为everysec,Linux系统宕机,内核缓冲区中还未刷盘的1秒数据丢失。解决方案:关键业务配置appendfsync always,或使用主从多副本架构。
    • 场景3:AOF重写期间,父进程宕机,新的AOF文件还未生成,旧的AOF文件没有重写期间的命令,导致数据丢失。解决方案:开启混合持久化,保证重写期间的命令写入aof_buf,正常刷盘到旧的AOF文件。

总结

Redis的三大核心基石,决定了它的高性能和高可靠性:

  • 内存模型是Redis的基础,理解内存划分、分配器、过期策略、淘汰策略,才能从根本上解决内存OOM、碎片、性能问题。
  • 数据结构是Redis的核心,选对底层数据结构,才能最大化发挥Redis的性能,用最低的成本解决业务问题。
  • 持久化机制是Redis的保障,理解RDB、AOF、混合持久化的底层逻辑,才能避免数据丢失,保证业务的高可用。

只有吃透这三大核心原理,才能从表层的set/get用法,深入到Redis的底层逻辑,真正做到从根上解决线上90%的Redis问题,成为真正的Redis高手。

项目依赖pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>
    <groupId>com.jam</groupId>
    <artifactId>redis-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>redis-demo</name>
    <properties>
        <java.version>17</java.version>
        <mybatis-plus.version>3.5.6</mybatis-plus.version>
        <fastjson2.version>2.0.52</fastjson2.version>
        <guava.version>33.1.0-jre</guava.version>
        <springdoc.version>2.5.0</springdoc.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>${springdoc.version}</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastjson2.version}</version>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.30</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

MySQL表结构

CREATE TABLE `t_user` (
  `user_id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `user_name` varchar(64NOT NULL COMMENT '用户名',
  `age` int DEFAULT NULL COMMENT '用户年龄',
  `phone` varchar(11DEFAULT NULL COMMENT '用户手机号',
  `email` varchar(64DEFAULT NULL COMMENT '用户邮箱',
  PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';