很多开发者用Redis,停留在set、get的表层用法,线上遇到OOM、数据丢失、缓存雪崩、接口超时等问题时,往往只能靠临时搜索解决,却找不到根因。本质上,是没有吃透Redis的三大核心基石:内存模型、底层数据结构、持久化机制。
第一章 Redis内存模型:搞懂内存,才算真正入门Redis
Redis是基于内存的数据库,所有操作都在内存中完成,这是它高性能的核心前提。不理解内存模型,就无法从根本上优化Redis性能、解决线上内存问题。
1.1 Redis内存的完整划分
很多人误以为Redis的内存只用来存储业务数据,实则不然,Redis的内存占用分为五大核心部分,每一部分都可能成为线上问题的导火索:
- 数据内存:最核心的部分,存储所有的键值对数据,占比最高,也是我们主要优化的对象。
- 客户端缓冲区内存:分为普通客户端缓冲区、复制客户端缓冲区、Pub/Sub客户端缓冲区,用于缓存客户端的输入输出命令。如果客户端消费速度远慢于Redis发送速度,缓冲区会持续膨胀,最终导致OOM。
- 复制积压缓冲区:主从架构下,主节点用于缓存最近执行的写命令,解决从节点临时断连后的增量同步问题,避免全量同步的性能开销。
- Lua脚本内存:存储Lua脚本相关的内容,包括加载的脚本、脚本执行过程中的临时数据。
- 进程自身运行内存: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性能暴跌,线上必须杜绝。
内存碎片优化方案
- 开启内存碎片自动整理:Redis 4.0+支持,通过
activedefrag yes开启,配合active-defrag-ignore-bytes、active-defrag-threshold-lower等参数控制整理触发条件,避免整理过程影响业务性能。 - 避免频繁更新短字符串:频繁的修改操作会产生大量内存碎片,尽量批量更新、减少无效修改。
- 重启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堆积。
-
惰性删除:所有读写命令执行前,都会先检查key是否过期,过期则删除并返回key不存在,该逻辑在命令执行的入口处统一处理,无额外性能开销。
-
定期删除:由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种内存淘汰策略,分为四大类:
-
不淘汰策略
noeviction:默认策略,内存达到maxmemory时,拒绝所有写命令,返回OOM错误,读命令正常执行。适合数据绝对不能丢失的场景,一般不推荐线上使用。
-
LRU淘汰策略(最近最少使用)
allkeys-lru:对所有key,使用近似LRU算法淘汰最近最少使用的key,适合绝大多数缓存场景,冷热数据区分明显的业务。volatile-lru:只对设置了过期时间的key,使用近似LRU算法淘汰,适合需要保留核心非过期数据的场景。
-
LFU淘汰策略(最不经常使用)
allkeys-lfu:对所有key,使用LFU算法淘汰访问频率最低的key,适合热点数据长期存在的场景,比如爆款商品缓存。volatile-lfu:只对设置了过期时间的key,使用LFU算法淘汰。
-
随机/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会自动根据数据内容选择最优编码,内存占用从低到高排序:
- int编码:当value是整数值,且范围在
long类型的范围内(64位系统为-2^63 ~ 2^63-1),Redis会直接将值存储在redisObject的ptr字段中,无需额外的内存分配,内存占用极低。 - embstr编码:当value是长度≤44字节的字符串,Redis会一次性分配连续的内存块,同时包含
redisObject和sds结构,内存连续,只需一次内存分配/释放,缓存命中率更高,性能更好。 - 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<Long> UNLOCK_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会自动根据字段数量和字段值长度切换:
- listpack编码:Redis 7.0+默认编码,替代了之前的ziplist,是一种紧凑的列表结构,将所有的field和value连续存储在一块内存中,内存占用极低,解决了ziplist的级联更新问题。当Hash的字段数量≤
hash-max-listpack-entries(默认512),且所有字段值的长度≤hash-max-listpack-value(默认64字节)时,使用该编码。 - 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<String, String, String> hashOps = stringRedisTemplate.opsForHash();
Map<String, String> userMap = 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<String, String> getUserFromHash(@Parameter(description = "用户ID", required = true) @PathVariable Long userId) {
if (ObjectUtils.isEmpty(userId)) {
throw new IllegalArgumentException("用户ID不能为空");
}
String cacheKey = USER_CACHE_PREFIX + userId;
HashOperations<String, String, String> hashOps = 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<String, String, String> hashOps = 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<String, String, String> hashOps = 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 + rpop或rpush + lpop实现先进先出的消息队列,支持生产者消费者模式。 - timeline/粉丝列表:按照插入顺序存储,支持分页查询,比如微博的动态列表、用户的粉丝列表。
- 栈结构:基于
lpush + lpop实现先进后出的栈结构。
2.5 Set:无序去重的集合实现
Set类型是无序的字符串集合,不允许重复元素,底层基于哈希表实现,所有操作的时间复杂度都是O(1),同时支持多个集合之间的交集、并集、差集操作。
底层编码结构
Set有两种底层编码,自动切换:
- intset编码:当集合中的所有元素都是整数,且元素数量≤
set-max-intset-entries(默认512)时,使用整数集合编码,内存占用极低,所有元素有序存储,支持二分查找。 - hashtable编码:当元素数量或类型超过上述阈值时,切换为哈希表编码,保证读写性能稳定。
核心适用场景
- 去重场景:比如用户点赞、文章浏览去重、标签系统等。
- 社交关系计算:比如共同好友(交集)、关注的人也关注了谁(差集)、可能认识的人(并集)等。
- 抽奖系统:基于
srandmember或spop命令实现随机抽奖,支持不重复抽奖。
2.6 Sorted Set:有序排名的核心底层跳表
Sorted Set(简称ZSet)是有序的字符串集合,每个元素都关联一个double类型的分数(score),Redis按照分数对元素进行排序,分数相同则按照元素字典序排序。ZSet兼顾了有序性和高性能,是排行榜、延时队列等场景的最优解。
底层编码结构
ZSet有两种底层编码,自动切换:
- listpack编码:当元素数量≤
zset-max-listpack-entries(默认128),且每个元素的长度≤zset-max-listpack-value(默认64字节)时,使用listpack紧凑存储,元素和分数连续存储,按分数有序排列,内存占用极低。 - skiplist+hashtable编码:当元素数量或长度超过阈值时,切换为该编码。其中hashtable用于存储元素到分数的映射,保证O(1)的元素分数查询;skiplist(跳表)用于存储分数和元素,保证O(logN)的范围查询和有序操作。
跳表的核心逻辑
很多人会问,为什么Redis用跳表而不用红黑树实现ZSet?核心原因有三点:
- 范围查询更友好:跳表的范围查询只需要找到起点,然后沿着链表遍历即可,实现简单,性能更优;红黑树的范围查询需要复杂的中序遍历,实现难度大。
- 实现与维护更简单:跳表的插入、删除操作只需要修改相邻节点的指针,无需像红黑树那样进行复杂的旋转操作,代码实现更简单,并发控制更容易。
- 性能相当:跳表的平均时间复杂度为O(logN),最坏为O(N),和红黑树相当,完全满足Redis的性能需求。
核心适用场景
- 排行榜系统:比如商品销量榜、文章阅读榜、用户积分榜,支持实时更新排名、查询TopN、查询用户排名等操作。
- 延时队列:以任务执行时间为score,任务ID为元素,定时扫描score小于当前时间的元素,实现延时任务执行。
- 范围查询:比如按分数范围查询用户、按时间范围查询数据等。
2.7 高频扩展数据结构与选型对比
除了上述5种核心数据类型,Redis还提供了4种高频扩展数据结构,覆盖更多业务场景:
- Bitmap:底层基于String类型实现,用位来存储数据,每个位只能是0或1,最大支持2^32位,也就是512MB。适合海量数据的签到、打卡、去重、状态统计等场景,1亿用户的签到数据只需要12MB内存。
- HyperLogLog:底层基于String类型实现,是一种概率算法,用于估算集合的基数(不重复元素数量),最大只占用12KB内存,就能估算2^64个元素的基数,标准误差为0.81%。适合海量UV统计、访问量统计等不需要绝对精确的去重计数场景。
- Geo:底层基于ZSet实现,用geohash算法将经纬度编码为整数,作为ZSet的score,支持地理位置的存储、距离计算、附近的人查询等场景,适合外卖、打车、同城社交等LBS业务。
- Stream:Redis 5.0+引入的专门为消息队列设计的数据结构,底层基于基数树实现,支持消息持久化、多消费组、消息确认、消息回溯等功能,解决了List做消息队列的消息丢失、无法多消费、无法回溯等问题,是Redis实现消息队列的最优解。
第三章 Redis持久化机制:解决数据丢失的终极方案
Redis所有操作都在内存中,一旦服务器宕机,内存中的所有数据都会丢失。持久化的本质,就是将内存中的数据写入磁盘,保证宕机后可以从磁盘恢复数据,是Redis数据可靠性的核心保障。
Redis提供了三种持久化方案:RDB快照、AOF日志、混合持久化,每种方案都有各自的优缺点,线上需根据业务场景选择合适的方案。
3.1 RDB快照:时间点全量数据备份
RDB是Redis默认开启的持久化方案,它会生成某个时间点内存中所有数据的全量快照,保存为一个二进制的RDB文件,该文件经过压缩,体积很小,非常适合备份、灾备、全量数据传输。
触发方式
RDB的触发分为手动触发和自动触发两种:
-
手动触发:
save命令:同步生成RDB文件,执行期间会阻塞Redis的所有业务请求,直到RDB文件生成完成,线上绝对禁止使用,只适合停机维护场景。bgsave命令:后台异步生成RDB文件,Redis会fork出一个子进程,由子进程负责生成RDB文件,父进程继续处理业务请求,只有fork的瞬间会短暂阻塞,是线上手动触发的唯一推荐方式。
-
自动触发:
- 配置文件中的
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的优缺点
-
优点:
- RDB是二进制压缩文件,体积小,非常适合全量备份、灾备、跨机房数据传输。
- 数据恢复速度极快,远快于AOF,适合宕机后快速恢复业务。
- 对Redis性能影响极小,bgsave只有fork瞬间短暂阻塞,后续子进程生成RDB文件不会影响父进程的业务处理。
-
缺点:
- 数据安全性差,无法做到实时持久化,两次RDB生成之间的所有数据,在宕机时都会丢失,比如配置
save 60 10000,最多会丢失60秒的数据。 - fork阻塞风险,当Redis内存很大时,fork需要复制大量的内存页表,阻塞时间会变长,甚至达到几百毫秒,导致Redis卡顿,影响业务。
- 全量生成成本高,每次bgsave都需要遍历全量内存,生成完整的RDB文件,频繁触发会带来大量的磁盘IO开销。
- 数据安全性差,无法做到实时持久化,两次RDB生成之间的所有数据,在宕机时都会丢失,比如配置
3.2 AOF日志:每一条写命令的可靠记录
AOF(Append Only File)持久化,是将Redis执行的每一条写命令,以Redis协议的文本格式,追加到AOF文件的末尾,Redis重启时,会重放AOF文件中的所有写命令,恢复内存中的数据。
AOF执行流程
AOF的执行分为四个核心步骤:命令写入、文件同步、文件重写、重启加载。
- 命令写入:Redis执行的所有写命令,都会先写入到
aof_buf内存缓冲区,而不是直接写入磁盘,避免每次写命令都触发磁盘IO,导致性能暴跌。 - 文件同步:根据配置的刷盘策略,将
aof_buf中的数据刷写到磁盘的AOF文件中,这是AOF数据安全性的核心。 - 文件重写:当AOF文件大小达到阈值时,触发AOF重写,压缩AOF文件的体积,避免文件无限膨胀。
- 重启加载:Redis重启时,会优先加载AOF文件(开启AOF时),重放所有写命令,恢复数据。
核心刷盘策略
AOF的刷盘策略由appendfsync参数控制,有三个可选值,平衡了数据安全性和性能:
- always:每一条写命令写入
aof_buf后,都会立即调用fsync刷写到磁盘,刷盘完成后命令才执行成功。数据安全性最高,宕机最多丢失一条命令的数据,但性能极差,磁盘IO压力巨大,只适合数据绝对不能丢失的金融场景。 - everysec:默认策略,每秒调用一次
fsync,将aof_buf中的数据刷写到磁盘,平衡了性能和数据安全性,宕机最多丢失1秒的数据,是线上绝大多数场景的推荐配置。 - 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_buf和aof_rewrite_buf两个缓冲区:
aof_buf的数据会按照刷盘策略正常刷写到旧的AOF文件,保证重写期间AOF的正常工作,宕机不会丢失数据。aof_rewrite_buf的数据会在子进程完成新AOF文件生成后,追加到新的AOF文件中,保证重写期间的新写命令不会丢失。
AOF的优缺点
-
优点:
- 数据安全性高,默认
everysec策略最多丢失1秒的数据,远高于RDB的安全性。 - 写入性能高,基于追加写的方式,无需磁盘随机IO,顺序写入性能极高。
- 容错性强,AOF文件是文本格式,即使出现文件末尾不完整的情况,也可以通过
redis-check-aof工具修复,恢复数据。
- 数据安全性高,默认
-
缺点:
- AOF文件体积远大于RDB文件,即使经过重写,仍然比RDB文件大很多。
- 恢复速度慢,重启时需要重放所有写命令,数据量越大,恢复时间越长,远慢于RDB。
- 性能影响更大,每秒的
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数据安全性高的优点。
混合持久化的优缺点
-
优点:
- 恢复速度极快,全量数据通过RDB加载,远快于重放全量AOF命令。
- 数据安全性高,和AOF一致,最多丢失1秒的数据。
- AOF文件体积更小,全量数据用RDB压缩存储,增量命令用AOF存储,体积远小于纯AOF文件。
-
缺点:
- AOF文件的可读性差,开头是RDB二进制格式,无法直接查看修改。
- 兼容性差,混合持久化生成的AOF文件,无法被Redis 4.0之前的版本识别加载。
3.4 持久化线上最佳实践与坑点避坑
线上推荐配置
- 开启混合持久化,
aof-use-rdb-preamble yes,兼顾性能和数据安全。 - 开启AOF持久化,
appendonly yes,appendfsync everysec,平衡性能和数据安全。 - 合理配置RDB自动触发规则,避免频繁bgsave,推荐
save 3600 1,即1小时至少1个key修改就触发bgsave,同时保留手动bgsave备份的能力。 - 配置
auto-aof-rewrite-percentage 100、auto-aof-rewrite-min-size 64mb,即AOF文件比上次重写后增长100%,且最小体积达到64MB时,自动触发AOF重写。 - 关闭AOF的
no-appendfsync-on-rewrite,设置为no,避免重写期间丢失数据,高并发场景可临时设置为yes,降低磁盘IO压力。
核心坑点避坑
-
fork阻塞问题
- 现象:Redis定期出现卡顿,耗时和Redis内存大小正相关。
- 根因:bgsave或bgrewriteaof触发fork,内存越大,复制的页表越多,fork阻塞时间越长。
- 解决方案:控制Redis单机内存不超过20GB;关闭Linux透明大页(THP),THP会导致COW复制的内存页变大,fork耗时变长;将持久化操作放到从节点执行,主节点不开启持久化,避免主节点fork阻塞影响业务。
-
AOF fsync延迟导致的卡顿
- 现象:高并发写场景下,Redis出现周期性卡顿,慢日志中出现大量简单命令耗时过长。
- 根因:磁盘IO压力过大,
fsync刷盘操作等待磁盘IO完成,导致Redis主线程阻塞。 - 解决方案:将AOF和RDB文件放到SSD磁盘,提升IO性能;避免同时触发bgsave和bgrewriteaof,减少磁盘IO峰值;配置
redis.conf中的stop-writes-on-bgsave-error no,避免bgsave失败后拒绝写命令。
-
数据丢失的核心场景
- 场景1:主从架构下,主节点宕机,还未同步到从节点的数据丢失。解决方案:配置主节点的
min-replicas-to-write 1、min-replicas-max-lag 10,保证至少有1个从节点延迟不超过10秒,否则拒绝写命令。 - 场景2:AOF刷盘策略为
everysec,Linux系统宕机,内核缓冲区中还未刷盘的1秒数据丢失。解决方案:关键业务配置appendfsync always,或使用主从多副本架构。 - 场景3:AOF重写期间,父进程宕机,新的AOF文件还未生成,旧的AOF文件没有重写期间的命令,导致数据丢失。解决方案:开启混合持久化,保证重写期间的命令写入
aof_buf,正常刷盘到旧的AOF文件。
- 场景1:主从架构下,主节点宕机,还未同步到从节点的数据丢失。解决方案:配置主节点的
总结
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(64) NOT NULL COMMENT '用户名',
`age` int DEFAULT NULL COMMENT '用户年龄',
`phone` varchar(11) DEFAULT NULL COMMENT '用户手机号',
`email` varchar(64) DEFAULT NULL COMMENT '用户邮箱',
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';