Redis
数据类型
Redis是一个键值对(key-value)数据库,它的value支持多种结构
string
最基础的类型,一个key对应一个value(可以是文本、数字、甚至是图片的二进制数据)
SET key value:设置key的值GET key:获取key的值MSET KEY VALUE \[KEY VALUE...]:批量添加多个String类型的valueMGET KEY \[KEY ...]:根据多个key获取多个String类型的valueINCR KEY:让一个整型的key自增1INCRBYFLOAT:让一个浮点类型的数字自增并指定步长INCRBY KEY number:让一个整形的key增加numberDEL key:删除keyEXISTS key:判断key是否存在SETNX:添加一个String类型的键值对,如果这个key不存在才执行SETEX:添加一个String类型的键值对,并且指定有效期
hash
哈希表,一个key对应多个field-value对,适合存储对象的多个属性,可以单独修改某个字段不影响其他字段
HSET key field value:设置哈希表字段的值HGET key field:获取哈希表字段的值HDEL key field:删除哈希表字段HGETALL key:返回哈希表中的所有字段值对HMSET key field value \[field value ...]:批量设置多个字段(Redis 4.0 起HSET本身就支持一次传多组 field-value,HMSET已废弃)HMGET key field \[field ...]:批量获取多个字段的值HKEYS key:返回所有字段名HVALS key:返回所有字段值HLEN key:返回字段数量HEXISTS key field:判断字段是否存在HINCRBY key field increment:让整型字段自增指定步长HSETNX key field value:字段不存在时才设置
为什么用 hash 存对象:把整个对象序列化成 JSON 塞进一个 string,改其中一个字段也得整体读出来再整体写回去;用 hash 可以
HSET改单个字段,也可以单独HGET某个字段。代价是不能嵌套——字段值只能是字符串,对象里带数组或子对象时还得自己再序列化一层
字段少、值小时,Redis 用紧凑编码(
listpack)存,内存比真正的哈希表小很多;超过hash-max-listpack-entries(默认 128)或hash-max-listpack-value(默认 64 字节)就转成hashtable,转过去之后不会再转回来。Redis 7.0 之前这两个配置叫hash-max-ziplist-entries/hash-max-ziplist-value。用OBJECT ENCODING key能看到当前编码
list
一个有序的字符串列表,底层是双向链表,支持头插、尾插和弹出元素
命令
LPUSH key value:头插元素到列表中RPUSH key value:尾插元素到列表中LPOP key:弹出列表头元素并返回RPOP key:弹出列表尾元素并返回LRANGE key start end:返回列表中指定范围的元素LREM key count value:删除列表中指定值的元素LSET key index value:设置列表中指定索引的元素
set
一个无序且元素唯一的字符串集合。支持数学上的交集、并集、差集等运算
命令
SADD key value:添加元素到集合中SREM key value:删除集合中的元素SMEMBERS key:返回集合中的所有元素SCARD key:返回集合中元素的数量SISMEMBER key member:判断一个元素是否存在于set中SINTER key1 key2 ...:返回多个集合的交集SUNION key1 key2 ...:返回多个集合的并集SDIFF key1 key2 ...:返回多个集合的差集
sorted set
一个有序的字符串集合,每个元素都有一个关联的分数。支持按分数排序和范围查询
ZADD key score member:添加元素到有序集合中ZREM key member:删除有序集合中的元素ZSCORE key member:获取有序集合中指定元素的score值ZRANK key member:获取有序集合中指定元素的排名ZCOUNT key min max:统计score值在范围内的元素个数ZRANGE key start end:返回有序集合中指定排名范围的元素ZRANGEBYSCORE key min max:返回有序集合中指定分数范围的元素ZCARD key:返回有序集合中元素的数量
所以排名默认是升序,降序则在命令的Z后面添加
REV即可
滚动分页
把score当作游标来翻页,而不是用排名偏移量。典型场景是朋友圈/Feed流:score存发布时间戳,按时间倒序往下拉。
为什么不用偏移量分页
ZRANGE key start end是按排名取元素的,翻页期间只要有人发了新动态,排名就会整体后移:
第一页拿到:[A, B, C]
期间有人发了新动态D,D插到了最前面
第二页 start=3:拿到的是C、D...,C被重复读取了滚动分页的做法
每次用上一页最后一条的score作为下一页的查询边界。这里的坑是score会重复(同一毫秒发布的多条动态分数相同),所以除了minTime还要带一个offset,用来跳过上一页里分数等于minTime的那几条
第一页:ZREVRANGEBYSCORE feed:1 +inf 0 WITHSCORES LIMIT 0 3
返回 score:1000、999、999
最后一条 score = 999,其中等于 999 的有 2 条 → minTime=999,offset=2
第二页:ZREVRANGEBYSCORE feed:1 999 0 WITHSCORES LIMIT 2 3
offset=2 跳过已经拿过的那两条 999max是闭区间(包含等于minTime的元素),所以必须靠offset去重;只有当score确定不会重复时,才能改用开区间(999并配合offset=0
对应到 Spring Data Redis 的 API
ZSetOperations里的方法签名:
Set<ZSetOperations.TypedTuple<String>> reverseRangeByScoreWithScores(
K key, double min, double max, long offset, long count)它包装的就是ZREVRANGEBYSCORE key max min WITHSCORES LIMIT offset count,五个参数一一对应:min/max是score的上下界(闭区间),offset跳过匹配到的前几条,count是页大小。所以reverseRangeByScoreWithScores(key, 0, max, offset, 2)就是“score在0~max之间、按score倒序、跳过前offset条、最多取2条,并且带上score”
min写0是因为score是时间戳,远大于0,相当于不设下界max就是游标minTime,首次请求传当前时间戳offset就是上一页算出来的os,首次传0
返回的Set<TypedTuple<String>>里每个元素是一个成员:getValue()拿member(blogId),getScore()拿分数(Double)。方法名里的WithScores就是把score一起返回,没有它就拿不到下一页的游标(只返回member的reverseRangeByScore做不了滚动分页)
注意参数顺序是反的:原生命令先写
max再写min(因为是倒序),Spring的方法签名是min在前、max在后。传反了结果直接为空
返回类型名义上是
Set,但Spring内部用的是LinkedHashSet保序,遍历顺序就是score从高到低。下面的循环依赖这个顺序,别把结果收集进HashSet
Java里算出下一页游标的逻辑:
public ScrollResult scroll(Long max, Integer offset) {
String key = "feed:" + userId;
// 按 score 倒序取,max 为上一页最后一条的 score(首次请求传当前时间戳),offset 首次传 0
Set<ZSetOperations.TypedTuple<String>> tuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 3);
if (tuples == null || tuples.isEmpty()) {
return new ScrollResult(); // 没有更多数据,说明到底了
}
List<Long> ids = new ArrayList<>(tuples.size());
long minTime = 0;
int os = 1; // 下一页要跳过的数量
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
ids.add(Long.valueOf(tuple.getValue()));
long time = tuple.getScore().longValue();
if (time == minTime) {
os++; // 和上一条分数相同,跳过量 +1
} else {
minTime = time; // 分数变了,从这一条重新计数
os = 1;
}
}
// 把 minTime 和 os 返回给前端,作为下一页的 max 和 offset
return new ScrollResult(ids, minTime, os);
}前端拿到minTime和offset后原样回传,就能一直往下滚
对比:
ZRANGE key start end是偏移量分页,数据一变动就错位;滚动分页用score定位,只要没有比minTime更新的数据插入,翻页就稳定。代价是不支持跳页,只能“下一页/滚动加载”
Redis 6.2+ 也可以用统一命令写:
ZRANGE key max min BYSCORE REV WITHSCORES LIMIT offset count,等价于ZREVRANGEBYSCORE
stream
Redis 5.0 新增的类型,是一个持久化、只能追加的消息日志,可以把它理解成一个消息队列。每条消息有唯一的 ID(格式为时间戳-序号,如1526919030474-55),写入后不可修改
特点
- 可回溯:消息持久化保存,可以从任意位置重新读取
- 多消费者:同一条消息可以被多个消费者组分别读取(区别于 list 的“取走就没了”)
- 阻塞读:没有新消息时可以阻塞等待,而不是空轮询
- 消费者组 + ACK:支持
XACK确认,未确认的消息可以通过XPENDING找回并重新投递
命令
XADD key * field value ...:添加一条消息,*表示由 Redis 自动生成 ID,也可以写成XADD key 1-1 field value手动指定 IDXLEN key:返回 Stream 中的消息数量XRANGE key start end [COUNT count]:按 ID 范围读取消息,-表示最小 ID,+表示最大 IDXREVRANGE key end start:倒序读取消息XDEL key id ...:删除指定 ID 的消息XTRIM key MAXLEN count:裁剪 Stream,只保留最新的若干条消息(也可以在写入时裁剪:XADD key MAXLEN ~ 1000 * field value)XREAD [COUNT count] [BLOCK ms] STREAMS key ... id:从指定 ID 之后读取消息,BLOCK 0表示永久阻塞直到有新消息,$表示只读取最新消息XGROUP CREATE key group id [MKSTREAM]:创建消费者组,MKSTREAM表示 Stream 不存在时自动创建;ID 为$表示从最新消息开始消费,0表示从头开始XREADGROUP GROUP group consumer [COUNT count] [BLOCK ms] STREAMS key ... id:以消费者组成员的身份读取消息,>表示读取从未投递给该组的新消息XACK key group id ...:确认消息已处理完成XPENDING key group:查看已投递但还没确认的消息(PEL,Pending Entries List)XCLAIM key group consumer min-idle-time id ...:把闲置超过指定时间的消息转交给其他消费者处理XAUTOCLAIM key group consumer min-idle-time start:XCLAIM的自动版,一次扫描并认领多条闲置消息XINFO STREAM key:查看 Stream 的详细信息XINFO GROUPS key:查看所有消费者组的信息
与 list 做消息队列的区别:list 用
LPUSH+BRPOP只能做到“一条消息一个消费者”,弹出后就没了;Stream 支持消费者组和 ACK 确认,同一条消息可以分发给多个组,消费失败还能重新投递
geo
Redis 3.2 新增,用来存经纬度并做「附近的人」「附近的店」这类范围查询。它没有自己的底层结构——member 是地点名或用户 ID,score 是经纬度经 geohash 编码后的 52 位整数,所以本质就是一个 sorted set,ZREM、ZCARD、ZRANGE 都能直接作用在 geo key 上
GEOADD key longitude latitude member [longitude latitude member ...]:添加一个或多个坐标点。经度在前、纬度在后,写反了纬度多半会超出 85.05 而报错,凑巧两个值都在合法范围内则会静默存错——这类 bug 只有等查询结果不对才会发现GEOPOS key member [member ...]:返回成员的经纬度GEODIST key member1 member2 [M|KM|FT|MI]:计算两个成员之间的距离,不指定单位默认是米GEOHASH key member [member ...]:返回标准的 11 位 geohash 字符串,可以拿去和其他 geohash 工具对接GEOSEARCH key <FROMMEMBER member | FROMLONLAT longitude latitude> <BYRADIUS radius unit | BYBOX width height unit> [ASC|DESC] [COUNT count [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH]:范围查询。FROM*指定中心点(按成员或按坐标),BY*指定是圆形还是矩形范围,后面三个WITH*决定要不要把坐标、距离、geohash 一并返回GEOSEARCHSTORE destination source <...>:参数和GEOSEARCH一样,但把结果写进另一个 key;加STOREDIST可以把距离当作 score 存下来,方便后续再排序ZREM key member:删除一个点(没有专门的GEODEL命令)
Redis 6.2 之前用的是
GEORADIUS key longitude latitude radius unit和GEORADIUSBYMEMBER key member radius unit,这俩把「按坐标」和「按成员」拆成了两个命令,6.2 起已废弃,新代码直接用GEOSEARCH
三个容易踩的点:纬度范围是
-85.05112878 ~ 85.05112878(Web 墨卡托的边界),不是-90 ~ 90,超出会报错;GEODIST返回的是字符串,要参与数值计算得先转 double;距离按**球面(Haversine)**算,地球被当成正球体,长距离下和真实值有零点几个百分点的偏差
GEOSEARCH的COUNT只表示「取最近的 n 个」,没有 offset 参数,所以做不到跳页;要翻页只能把COUNT放大再自己裁,或者用GEOSEARCHSTORE把结果落到另一个 key 里再查
bitmap
位图,本质还是 string,只是把 value 当成一个 bit 数组来看:每个 bit 只有 0 和 1,用偏移量(offset)当下标。「某个用户今天有没有签到」这种布尔值只占 1 bit,比存一个"1"省 8 倍以上
SETBIT key offset value:把第 offset 位设为 0 或 1。key 不存在会新建,offset 超出当前长度会自动补 0GETBIT key offset:取第 offset 位,越界或 key 不存在都返回 0BITCOUNT key \[start end \[BYTE | BIT]]:统计值为 1 的位数,不传范围就是整个 key,BYTE/BIT决定 start/end 是按字节还是按位BITPOS key bit \[start \[end \[BYTE | BIT]]]:找第一个等于 bit(0 或 1)的位,返回它的下标BITOP AND|OR|XOR|NOT destkey key \[key ...]:对多个 bitmap 做位运算,结果写进 destkey(NOT只接一个 key)BITFIELD key \[GET encoding offset] \[SET encoding offset value] \[INCRBY encoding offset increment] \[OVERFLOW WRAP|SAT|FAIL]:把字符串当成一组定长整数来读写,一条命令可以带多个操作
记录某个用户这个月的签到:
SETBIT sign:1001:202609 20 1 # 20 号签到(下标从 0 开始)
SETBIT sign:1001:202609 21 1
BITCOUNT sign:1001:202609 # 返回 2,本月签到 2 天最大 offset 是
2^32 - 1(约 42.9 亿),因为 Redis 的 value 上限是 512MB,512MB × 8 正好是 2^32 位
offset 是按最大偏移量分配内存的:只写
SETBIT key 10000000 1,Redis 也立刻分配 10000000 ÷ 8 ≈ 1.25MB。所以稀疏场景(1 万个用户里只有 3 个签到)用 bitmap 反而不省内存,这种情况用 set 或者 HyperLogLog 更合适
BITCOUNT的 start/end 默认按字节索引,想按位必须显式加BIT;而BITFIELD的 offset 默认就按位算,想按整个字段跳(第 n 个字段而不是第 n 位)要加#前缀,如BITFIELD key GET u8 #0
BITFIELD的OVERFLOW决定越界行为:WRAP回绕(默认)、SAT饱和到最大/最小值、FAIL直接返回 nil。它只影响后面的SET和INCRBY,对GET无效
因为底层就是 string,
TYPE返回的类型是string而不是bitmap,GET出来的也是二进制字符串
常见场景:签到打卡、日活/月活统计、用户布尔标签(性别、是否会员)、布隆过滤器的底层存储
HyperLogLog
用来做基数统计(一个集合里有多少个不重复元素)的概率型结构。它不保存元素本身,只维护内部寄存器,所以结果是估算值,标准误差 0.81%
PFADD key \[element ...]:添加一个或多个元素,返回 1 表示基数估算值变了,返回 0 表示没变PFCOUNT key \[key ...]:返回估算出的基数;传多个 key 时返回它们的并集基数PFMERGE destkey \[sourcekey ...]:把多个 HyperLogLog 合并进 destkey
PFADD uv:2026-09-20 user:1 user:2 user:3
PFADD uv:2026-09-21 user:2 user:3 user:4
PFCOUNT uv:2026-09-20 # ≈3
PFMERGE uv:total uv:2026-09-20 uv:2026-09-21
PFCOUNT uv:total # ≈4,两天去重后的 UV内存是固定的:最坏情况 12KB(16384 个 6-bit 桶),跟元素数量无关。起步用稀疏编码(sparse)省内存,超过
hll-sparse-max-bytes(默认 3000 字节)才转成稠密编码(dense),此时固定占用 12KB
不能做的事:判断某个元素是否存在(没有类似
SISMEMBER的命令)、把元素取回来、删除单个元素(要删只能删整个 key)。它的定位就是「只关心去重后的总数」
结果是估算值,别拿它当账单或者库存的计数。它是给「UV 这种差一点无所谓、但量大到 set 存不下」的场景用的
PFCOUNT传多个 key 时会把它们临时合并再估算,复杂度从 O(1) 变成 O(N);需要反复算同一个并集,用PFMERGE先落一个 key 更划算
和 bitmap 一样,
TYPE返回的也是string
与 set 对比(统计 1 亿个不重复用户):
| 结构 | 是否存元素 | 内存占用 | 能否判断元素是否存在 | 误差 |
|---|---|---|---|---|
| set | 存 | GB 级 | 能 | 无 |
| HyperLogLog | 不存 | 固定 12KB | 不能 | 0.81% |
通用命令
对所有数据类型都适用的命令,主要分为键操作、过期时间和数据库/统计三类
键操作
DEL key \[key ...]:删除key,返回实际删除的数量UNLINK key \[key ...]:异步删除,只把key从键空间摘除,内存回收交给后台线程。删除大key时用它,避免阻塞主线程EXISTS key \[key ...]:判断key是否存在,返回存在的数量(可以一次传多个key)TYPE key:返回key的类型(string/hash/list/set/zset/stream),key不存在时返回noneKEYS pattern:按模式匹配查找key,如KEYS user:*。生产环境禁用,它是全量扫描,key多的时候会阻塞RedisSCAN cursor \[MATCH pattern] \[COUNT count]:渐进式遍历,每次返回一小批key和一个新的cursor,cursor为0表示遍历结束,是KEYS的生产替代方案RENAME key newkey:重命名keyRANDOMKEY:随机返回一个keyCOPY source destination \[DB destination-db]:复制key到目标key(或目标库)
过期时间
EXPIRE key seconds:给key设置过期时间(秒)PEXPIRE key milliseconds:毫秒级设置过期时间EXPIREAT key timestamp:指定到期的时间戳(秒),PEXPIREAT为毫秒版TTL key:返回剩余存活时间(秒)。-1表示没设置过期时间,-2表示key不存在PTTL key:毫秒版TTLPERSIST key:移除过期时间,让key永久有效
用
SET覆盖value时,过期时间会被一起清掉;而INCR、LPUSH、HSET这类只修改value的命令不会清除过期时间(Redis 2.6+)
数据库与统计
SELECT index:切换数据库,默认有0~15共16个库(集群模式下不可用)DBSIZE:返回当前库的key数量,O(1),不会遍历所有keyFLUSHDB \[ASYNC]:清空当前库FLUSHALL \[ASYNC]:清空所有库INFO \[section]:查看服务运行信息(内存、连接数、命中率等)OBJECT ENCODING key:查看key底层的编码(如int、ziplist、hashtable、intset),排查内存占用时很有用HELP command:查看命令的帮助和用法
加
ASYNC可以让DEL/FLUSHDB/FLUSHALL在后台线程完成内存回收。FLUSHALL、KEYS *这类命令不要在生产环境随手执行
缓存三大问题
缓存穿透
概念:查询一个缓存和数据库中都不存在的数据。每次请求都直接打到数据库,导致数据库压力过大,甚至被打垮
产生原因:恶意攻击者故意请求不存在的数据(如不存在的 id),或业务逻辑查询了不存在的记录
解决方案:
- 缓存空值:查询数据库结果为空时,也把空值写入缓存,设置一个较短的过期时间
Object value = redisTemplate.opsForValue().get("user:" + id);
if (value == null) {
// 缓存中没有,查询数据库
User user = userMapper.selectById(id);
if (user == null) {
// 数据库也没有,缓存空值,过期时间设置短一些
redisTemplate.opsForValue().set("user:" + id, "", 5, TimeUnit.MINUTES);
return null;
}
// 写入缓存
redisTemplate.opsForValue().set("user:" + id, user, 30, TimeUnit.MINUTES);
return user;
}布隆过滤器:在缓存之前加一层布隆过滤器,用一组哈希函数快速判断 key 是否存在,不存在则直接拦截
参数校验:对非法参数(如 id <= 0)直接拒绝,不做查询
缓存击穿
概念:一个热点 key 在缓存过期的那一瞬间,大量并发请求同时打向数据库,导致数据库压力骤增
与穿透的区别:穿透查的是不存在的 key,击穿查的是存在但缓存刚好过期的热点 key
解决方案:
- 互斥锁
当缓存过期时,只有一个线程去查询数据库并重建缓存,其他线程等待
查缓存
├─ 命中(有效数据)→ 直接返回
├─ 命中(空字符串)→ 返回 null(穿透的空值)
└─ 未命中 → 抢互斥锁
├─ 抢到 → 查库 → 写缓存 → 释放锁 → 返回
└─ 没抢到 → 睡 50ms → 重试整个流程// 解决缓存穿透 + 使用互斥锁解决缓存击穿(优化版:while + double-check + UUID/Lua 校验)
public Shop queryWithMutex(Long id) {
String key = "shop:" + id;
// 1. 第一次查缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
// 缓存命中有效数据,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
if (shopJson != null) {
// 缓存命中空字符串(穿透兜底),返回 null
return null;
}
// 锁的 value 存唯一标识,释放时校验归属,防止误删别人的锁
String lockValue = UUID.randomUUID().toString();
Shop shop = null;
int retry = 0;
int maxRetry = 5; // 重试上限,防止死循环
while (retry++ < maxRetry) {
try {
// 2. 尝试获取锁(SET NX EX 原子操作,3 秒自动过期防死锁)
boolean lock = stringRedisTemplate
.opsForValue()
.setIfAbsent("lock:shop:" + id, lockValue, 3, TimeUnit.SECONDS);
if (!lock) {
Thread.sleep(50); // 没抢到,等持有者重建完
continue; // 循环重试,而不是递归(防栈溢出)
}
// 3. double-check:拿到锁后二次查缓存,可能上一个线程已重建完,就不用再查库
shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
// 4. 查库重建
shop = getById(id);
Thread.sleep(200); // 模拟重建延时(演示用,生产删除)
if (shop == null) {
// 穿透兜底,缓存空串(10 分钟)
stringRedisTemplate.opsForValue().set(key, "", 10, TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30, TimeUnit.MINUTES);
return shop;
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断标志,别吞掉
throw new RuntimeException(e);
} finally {
// 5. 释放锁前校验 value 是否是自己(防止误删别人的锁)
unLock("lock:shop:" + id, lockValue);
}
}
return shop; // 重试耗尽,返回 null 或抛异常
}配套的 unLock(Lua 保证"比对 + 删除"原子性):
// 用 Lua 脚本保证"比对 + 删除"原子性,防止删锁瞬间锁刚好过期被别人抢走
public void unLock(String lockKey, String lockValue) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(lockKey),
lockValue
);
}注:原来的
tryLock(lockKey)/unLock(lockKey)是无校验的单参数版本,用优化版时需要同步改成带过期时间和 UUID 校验的实现
- 逻辑过期
key不设物理过期时间,而是把过期时间存在value里,发现过期后不阻塞等待,先返回数据,再异步重建缓存
/**
* 根据 id 查询缓存:命中未过期直接返回;已过期则返回旧数据并异步重建;
* 缓存不存在时查库写入(逻辑过期方案 key 常驻,首次访问也要有数据)
* @param keyPrefix 缓存键前缀
* @param id 缓存键的ID部分
* @param type 缓存值的类型
* @param dbFallback 缓存不存在时的数据库查询方法
* @param time 缓存的过期时间
* @param unit 时间单位
* @param <R>
* @param <ID>
*/
public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 从redis中读取缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 首次访问:缓存不存在 → 查库并写入,而不是直接返回 null
if (StrUtil.isBlank(shopJson)) {
R first = dbFallback.apply(id);
if (first != null) {
this.setWithLogicalExpire(key, first, time, unit);
}
return first;
}
// 命中:反序列化并判断是否逻辑过期
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
R r = JSONUtil.toBean(redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回
return r;
}
// 已过期:需要缓存重建(锁 key 跟 keyPrefix 走,避免通用方法下锁粒度错乱)
String lockKey = "lock:" + keyPrefix + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
try {
// double-check:可能抢锁前其他线程已重建完
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
RedisData rd = JSONUtil.toBean(json, RedisData.class);
if (rd.getExpireTime().isAfter(LocalDateTime.now())) {
return JSONUtil.toBean(rd.getData(), type);
}
}
// 开启独立线程异步重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R r1 = dbFallback.apply(id);
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
log.error("缓存重建失败, key={}", key, e); // 需要 @Slf4j
} finally {
unLock(lockKey);
}
});
} catch (Exception e) {
// submit 抛异常(如线程池拒绝)也要释放锁,防止锁永远不释放
unLock(lockKey);
throw e;
}
}
// 已过期:先返回旧数据
return r;
}代价/注意点
数据短暂不一致:过期后一段时间拿到的是旧数据,直到异步查询重建完成,对时效性要求高的不适合(如库存、价格)
额外线程:需要线程池来异步重建,重建失败要有重试、补偿机制,否则永远是旧数据
重建要快:异步重建期间所有请求都会命中旧数据,如果重建很慢,那么旧数据就会顶很久
逻辑过期时间要设置合理:如果设10分钟,那么意味着最多有10分钟的数据延迟窗口
缓存雪崩
概念:大量 key 在同一时间段内集中过期,或者 Redis 服务宕机,导致大量请求直接打到数据库,数据库扛不住压力崩溃
产生原因:设置的过期时间相同(如都设置为凌晨 0 点过期)、Redis 集群节点宕机
解决方案:
- 过期时间加随机值:让 key 的过期时间分散开,避免同一时间集中过期
// 过期时间 = 基础时间 + 随机数,避免同一时间大量 key 同时过期
long baseTime = 30;
long randomTime = ThreadLocalRandom.current().nextLong(1, 10);
redisTemplate.opsForValue().set("key:" + i, value, baseTime + randomTime, TimeUnit.MINUTES);多级缓存:Redis 之前再加一层本地缓存(如 Caffeine、本地 Map),部分请求直接在本地缓存命中
Redis 高可用:搭建主从复制 + 哨兵集群,保证 Redis 不宕机
服务限流降级:数据库层加限流(如 Sentinel、Hystrix),超出承受能力直接降级返回
总结对比:
| 问题 | 场景 | 解决方案 |
|---|---|---|
| 缓存穿透 | 查询缓存和数据库都不存在的数据 | 缓存空值 / 布隆过滤器 / 参数校验 |
| 缓存击穿 | 热点 key 过期瞬间的高并发 | 互斥锁 / 逻辑过期 |
| 缓存雪崩 | 大量 key 同时过期或 Redis 宕机 | 过期时间加随机值 / 多级缓存 / 高可用 |
In Spring Boot
在 Spring Boot 项目中的使用方式,请参考 Spring Data Redis
分布式锁
背景
假设两个线程同时给同一个商品下单
线程A:查询库存 → 库存还有1 → 扣库存
线程B:查询库存 → 库存还有1 → 扣库存结果可能两个都成功了,库存就出问题了
在单机Java程序里,可以用synchorized、ReentrantLock,但是服务部署了多台服务器,就锁不住了
Nginx
│
┌───────┴───────┐
↓ ↓
服务器A 服务器B
Java程序 Java程序
│ │
synchronized synchronizedA和B时两个JVM,锁只能锁住当前JVM内的线程
这时候,必须靠一个大家都能访问到的第三方来协调,比如Redis、Zookeeper、etcd或数据库
定义
多个进程、多台服务器上的代码再访问同一个共享资源时,同一时间只允许一个持有者操作 ,其他进程只能等待或者失败
单机里用synchronized、mutex、ReentrantLock就能保证互斥,因为它们靠同一台机器上的共享内存工作。但是再分布式系统里,同一套服务器通常会部署多个实例:
- 定时任务被多个实例同时触发,造成重复执行
- 多个实例同时处理同一个订单、扣同一笔库存
- 多实例同时写同一份共享数据,产生覆盖或重复
典型特征
一个好的分布式锁一般满足:
- 互斥性:任意时刻最多一个持有者
- 可容错:持有者崩溃后,锁能自动释放,不会锁死
- 可续期/防误删:业务时间超过锁超时时间时,要么自动续期,要么判断锁是不是自己的
- 高性能、高可用:获取和释放要快,协调服务本身不能成为单点
实现
假设两个服务器同时抢锁:
服务器A → SET lock:order:1001 owner-A NX
服务器B → SET lock:order:1001 owner-B NXRedis是单线程执行命令,并且SET NX是原子操作,只能有一个成功
代码大致如下:
String lockKey = "lock:order:" + orderId;
String value = UUID.randomUUID().toString();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, value, 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(success)) {
try {
// 执行业务
createOrder();
} finally {
// 释放锁
stringRedisTemplate.delete(lockKey);
}
}一定要设置过期时间,如果服务器A拿到了锁,突然宕机了,那么别的服务器拿不到这个锁,就永远处理不了这个订单了
不能直接删除锁
假设服务器A获得了锁,但是由于执行业务太久了,导致锁过期了,这时候服务器B获得了锁,这时候服务器A刚好又执行完业务,准备删除锁,但是这时候删除的是服务器B的锁,所以需要在删除锁的时候加个判断
Lua
Lua脚本可以把GET和判断和DEL放进一个Lua脚本,保证原子性
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0Java调用
redisTemplate.execute(
script,
Collections.singletonList("lock:order:1001"),
"owner-A"
);可重入锁
同一个线程已经拿到锁后,可以再次拿到这把锁,而不会死锁
场景
public void methodA() {
lock.lock();
methodB();
lock.unlock();
}
public void methodB() {
lock.lock();
// 做事情
lock.unlock();
}如果A和B用的是同一把锁,不可重入
线程 A:我想拿锁
↓
发现锁已经被线程 A 自己占着
↓
等待……
↓
但是锁必须等 methodA() 执行完才释放
↓
methodA() 又在等 methodB()
↓
💀 死锁可重入的话
线程 A
↓
第一次 lock()
↓
锁持有次数 = 1
↓
methodB()
↓
第二次 lock()
↓
发现:还是我自己
↓
锁持有次数 = 2
↓
执行
↓
unlock()
↓
锁持有次数 = 1
↓
unlock()
↓
锁真正释放分布式缓存
持久化
RDB
RDB(Redis Database)是把某一时刻的全量数据写成一个紧凑的二进制文件,默认文件名dump.rdb。它"时间点快照"的语义来自fork:子进程看到的是 fork 那一瞬间的完整内存副本,之后主进程怎么改都不影响它,所以落盘出来的是一个自洽的切片,而不是"边写边变"的拼凑数据
命令
SAVE:同步保存,在主线程里把数据写完,期间整个 Redis 阻塞,一条命令都处理不了。只用于fork失败的极端情况,生产环境不要用BGSAVE \[SCHEDULE]:异步保存,fork 一个子进程去写文件,主线程立刻返回Background saving started。加SCHEDULE表示已有子进程在跑时不报错,等它结束后再补一次LASTSAVE:返回上次成功保存的 Unix 时间戳,脚本里常用来确认备份是否真的成功
BGSAVE执行期间再发一次不带SCHEDULE的BGSAVE,会直接返回Background save already in progress错误;而save配置自动触发的保存遇到这种情况是静默跳过的,不会报错也不会排队
什么时候会触发
- 配置文件里的 save 点:
save <seconds> <changes>,意思是"在 seconds 秒内如果至少有 changes 个 key 发生变化,就触发一次 BGSAVE"。默认三组:
save 3600 1 300 100 60 10000Redis 内部用一个dirty计数器记录"上次保存以来改了多少个 key",每次事件循环都会拿它和所有 save 点比对。任意一组满足就触发,所以实际保存频率取决于最宽松的那条
BGSAVE/SAVE手动调用正常关闭:收到
SHUTDOWN命令或SIGTERM信号时,如果配置了 save 点,会先执行一次同步的SAVE再退出;SHUTDOWN NOSAVE可以跳过主从全量同步:从节点第一次连上来、或者复制积压缓冲区被覆盖需要全量同步时,主节点会
BGSAVE生成 RDB 发给从节点。这是生产环境里 RDB 最常见的触发来源,很多人"没配 save 点却看到在生成 RDB",就是复制导致的FLUSHALL/FLUSHDB:删除大量 key 会让dirty暴涨,紧接着就满足 save 点触发一次保存
kill -9(SIGKILL)无法被捕获,什么都不会保存,这是"用 RDB 会丢数据"最直接的场景。DEBUG RELOAD则是"保存后立刻重新加载",用来验证 RDB 文件能否正常恢复,也是排查数据问题的常用手段
fork 与写时复制
BGSAVE不阻塞主线程的关键在于fork出来的子进程不复制物理内存,而是和父进程共享同一批物理页,只复制一份页表。这些共享页会被标记为只读,父进程一旦要改某一页,内核先给这一页复制一份新内存再让父进程写,子进程继续读原来的旧页——这就是写时复制(Copy-On-Write)
BGSAVE
│
├─ fork() ← 这一步会阻塞主线程(页表越大越久)
│ │
│ ├─ 子进程:看到 fork 那一刻的完整数据,慢慢写 dump.rdb
│ └─ 主进程:继续处理读写命令
│
└─ 主进程修改某页 → 触发 COW → 该页复制一份 → 子进程仍读旧页由此推出三个结论:
- 快照是一致的:子进程只可能看到 fork 那一刻的视图,后续写入的修改它完全感知不到
- 额外内存取决于写入量:fork 之后主进程被改动的页越多,COW 复制的内存越多。极端情况(全量写入)会接近内存翻倍,这才是 RDB 真正的内存风险,而不是
fork本身 - fork 本身是阻塞的:内存越大、页表越长,
fork耗时越久,可能出现几十毫秒到秒级的卡顿。这个开销无法用"异步"消掉
两个和 COW 相关的系统参数:
vm.overcommit_memory建议设为1,因为fork会申请与父进程同等大小的虚拟内存,严格的内存核算会让fork直接失败并报Can't save in background: fork: Cannot allocate memory;透明大页(THP)会放大 COW 的复制粒度,建议关闭(echo never > /sys/kernel/mm/transparent_hugepage/enabled)
常用配置
| 配置项 | 默认值 | 作用 |
|---|---|---|
save <seconds> <changes> | 3600 1 300 100 60 10000 | 自动保存的触发条件,可以写多组 |
save "" | — | 关闭所有自动保存,但手动BGSAVE仍可用 |
dbfilename | dump.rdb | 文件名 |
dir | ./ | 数据目录,RDB 和 AOF 都落在这里 |
rdbcompression | yes | 用 LZF 压缩字符串对象,关掉省 CPU 但文件暴涨 |
rdbchecksum | yes | 文件尾部写入 CRC64 校验和,关掉加载快约 10% |
stop-writes-on-bgsave-error | yes | 上次 BGSAVE 失败后拒绝所有写命令 |
rdb-del-sync-files | no | 未开 AOF 时,主从同步用完的 RDB 是否删除 |
stop-writes-on-bgsave-error是把双刃剑:开着能避免"以为有持久化其实早写不进去了"(磁盘满时只读不报错的静默故障最致命),但代价是磁盘满会直接升级成"Redis 拒绝所有写入"的服务不可用。线上要监控rdb_last_bgsave_status,或者显式关掉它并接受"持久化可能已经悄悄失效"
相关 INFO 字段
rdb_changes_since_last_save 距上次保存改了多少个 key
rdb_bgsave_in_progress 是否有 BGSAVE 在跑(0/1)
rdb_last_save_time 上次成功保存的时间戳
rdb_last_bgsave_status ok / err
rdb_last_bgsave_time_sec 上次保存耗时(秒)
rdb_current_bgsave_time_sec 正在进行的保存已耗时
rdb_last_cow_size 上次 BGSAVE 期间 COW 占用的字节数
rdb_saves 本次启动以来保存了多少次加载
Redis 启动时,如果 AOF 没开启(或 AOF 文件不存在),就会去dir目录下找dbfilename指定的文件加载,加载是阻塞的,大实例的启动时间基本就等于加载 RDB 的时间。如果 RDB 文件损坏,Redis 会因为校验和不匹配而拒绝启动,这时可以用redis-check-rdb dump.rdb检查
RDB 文件开头是
REDIS魔术字符串加版本号。高版本写的 RDB 不能给低版本读,所以降级 Redis 之前要先确认。反向(低版本写、高版本读)是兼容的
优缺点
优点
- 文件紧凑:二进制 + LZF 压缩,同样数据量下比 AOF 小得多
- 恢复快:直接按结构加载,不需要逐条回放命令,重启和主从全量同步都受益
- 对主进程性能影响最小:常态下只付出一次
fork,子进程干重活 - 天然适合做备份:一个文件就是某个时间点的完整数据集,直接拷走就行
缺点
- 会丢数据:宕机时丢掉的是"上次快照之后"的全部写入,取决于 save 点,通常是几分钟
- fork 有阻塞:内存越大停顿越明显
- COW 有内存开销:BGSAVE 期间写入量大时可能接近内存翻倍
- 无法增量:每次都要全量落盘,数据量大时单次保存耗时很长
与 AOF 的关系
| 维度 | RDB | AOF |
|---|---|---|
| 记录内容 | 某个时间点的全量数据 | 每条写命令 |
| 数据安全 | 差,宕机丢几分钟 | 好,最多丢 1 秒(everysec) |
| 文件体积 | 小 | 大,需要定期重写压缩 |
| 恢复速度 | 快 | 慢,要逐条回放 |
| 主要用途 | 备份、灾难恢复、主从全量同步 | 尽量不丢数据 |
两者可以同时开启,恢复时优先用 AOF,因为它理论上更完整。Redis 4.0 起支持混合持久化,aof-use-rdb-preamble yes会让 AOF 文件的前半段是 RDB 格式、后半段才是增量命令,兼顾恢复速度和数据完整性
反过来看,即使主要用 AOF,官方也建议保留 RDB:一是做备份,二是重启更快,三是万一 AOF 引擎有 bug,RDB 还能兜底。反过来"只用 AOF"是官方明确不推荐的
官方对"该用哪个"的说法很直接:能接受灾难时丢几分钟数据,RDB 单独用就够了;想要接近 PostgreSQL 级别的数据安全,就两个都开;纯粹当缓存用,可以完全关掉持久化
AOF
AOF(Append Only File)记的不是数据本身,而是每一条改变数据集的写命令。启动时把这些命令按顺序重放一遍,就把数据集重建出来了。定位和 RDB 正好互补:RDB 存的是"结果"(快照),AOF 存的是"过程"(操作日志),所以 AOF 更新得更频繁,丢的数据更少
开启与文件结构
配置里打开appendonly yes(默认是no),之后每个写命令都会追加进 AOF。这个开关可以在运行时改:
CONFIG SET appendonly yes运行中开启会立刻在后台生成一份完整的 AOF(等于触发一次重写),否则新开的 AOF 里只有"开启之后"的命令,靠它恢复不出完整数据
AOF 文件就是 RESP 协议的原文,纯文本,可以直接看:
*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n*3\r\n$3\r\nSET\r\n$7\r\nuser:10\r\n$5\r\ntom\r\n*2\r\n$6\r\nEXPIRE\r\n$7\r\nuser:10\r\n$2\r\n60\r\n*n表示"接下来是一个 n 元素的数组"(即一条命令的 n 个参数),$n表示"接下来是一个长度为 n 的字符串"。上面这段依次是SELECT 0、SET user:10 tom、EXPIRE user:10 60。用sed 's/\r//'把\r去掉后就一行一条命令,排查问题时能直接看出最后写进来的是什么
Redis 7.0 起 AOF 变成了多部分结构,不再是单个文件:原来的一个大文件被拆成一个 base 文件(重写时刻的数据集快照,RDB 或 AOF 格式)+ 若干个 incr 文件(base 之后追加的增量命令),全部放在
appenddirname指定的目录里,由一份 manifest 清单统一管理
appendonlydir/
├── appendonly.aof.manifest 清单:当前有哪些文件正在生效
├── appendonly.aof.1.base.rdb base:重写时刻的全量数据集
└── appendonly.aof.1.incr.aof incr:base 之后的新增写命令拆分的好处是重写和追加可以彻底解耦。Redis 7 之前是单文件,重写期间产生的新命令没地方写,只能先缓存在内存里的
aof_rewrite_buf,等子进程写完新文件再补进去——写入量大的时候这块 buffer 会吃掉不少内存,切换那一刻的实现也复杂。多部分 AOF 下,父进程直接开一个新的 incr 文件继续写,子进程同时生成新 base,最后用临时 manifest 做一次原子替换,内存里不用囤任何东西
命令
BGREWRITEAOF:后台重写 AOF,fork子进程按当前数据集生成最短的命令序列。返回值只表示"已受理"或"已排期",不代表重写完成CONFIG SET appendonly yes:运行时开启 AOF(会触发一次重写生成初始文件)
AOF 没有 RDB 那样的
SAVE命令,因为它不是"某个时刻保存一次",而是每时每刻都在追加。想强制落盘应该调appendfsync或用BGREWRITEAOF
如果发起
BGREWRITEAOF时正好有BGSAVE在跑,Redis 会返回 OK 但把重写排期到快照结束后。反过来也一样——两个后台进程不会同时做重 I/O
appendfsync:决定到底能丢多少数据
AOF 只是把命令写进了用户态的缓冲区,真正落盘要靠fsync。这个配置就是控制多久fsync一次,也是 AOF 唯一需要权衡的地方:
| 取值 | 行为 | 最多丢多少 | 性能 |
|---|---|---|---|
always | 每批命令都fsync,完成之后才回复客户端 | 几乎不丢 | 极慢,必须配 SSD |
everysec | 后台线程每秒fsync一次 | 1 秒 | 默认档,兼顾 |
no | 完全不fsync,交给操作系统 | 通常 30 秒 | 最快 |
always的名义是"每条命令都刷盘",实际上 Redis 会把同一时刻到达的多个命令合并成一次write+一次fsync(组提交),并且在把回复发给客户端之前完成。所以它保证的是"客户端收到 OK 就意味着已经落盘"——代价是吞吐量断崖式下跌,而且极度依赖磁盘的fsync延迟,机械盘上基本不可用
everysec是默认档,但它有一个不明显的延迟毛刺来源:fsync是后台线程做的,如果某次fsync卡住了,而主线程又要往同一个文件写,Linux 上对正在fsync的文件调用write(2)会阻塞。Redis 的做法是用缓冲区把write最多推迟 2 秒,2 秒后即使fsync还没完成也要硬写——这时主线程就被拖住了。所以磁盘 I/O 抖动会直接变成 Redis 的延迟尖峰,这也是no-appendfsync-on-rewrite存在的理由
no最快但最不可控:Linux 默认dirty_expire_centisecs是 30 秒,正常情况最多丢 30 秒,但内核在内存压力下会乱序刷盘,实际丢多少说不准。一般只在"数据丢了也能从别处重建"时才用
重写(Rewrite)
AOF 只追加不修改,所以只会越来越大。对同一个计数器INCR一万次,AOF 里就有一万条命令,而重放它们只是为了得到最后那个数字,前 9999 条毫无意义。重写就是按当前数据集生成"最短的等价命令序列",把文件压回真实数据量
RDB 里讲的fork+写时复制在这里完全一样,开销和风险也完全一样(fork阻塞、写入量大时内存接近翻倍),所以不再重复
触发重写
│
├─ 父进程:新建 incr(2) 继续追加新命令 ──────┐
│ │
└─ 子进程:按 fork 那一刻的数据集 │ 两个进程并行,
生成新的 base ──────────────────┤ 互不干扰
│
两边都就绪 → 用临时 manifest 原子替换 ──────┘
→ 删掉旧 base 和 incr(1)自动重写需要同时满足两个条件:
auto-aof-rewrite-percentage 100:当前 AOF 大小比"上次重写之后的大小"增长了 100%(即翻倍)auto-aof-rewrite-min-size 64mb:AOF 本身至少要有这么大
第一个条件是相对增长率而不是绝对值,这解释了一个常见困惑:"AOF 明明还很小,怎么一直在重写?"——刚启动时 AOF 可能只有几 KB,随便写点数据就翻倍了。所以必须靠
auto-aof-rewrite-min-size兜底。把它设成0则关闭自动重写,只能手动BGREWRITEAOF
反复失败的重写会被限流:Redis 7 引入了退避机制,重试间隔越来越长,避免重写一直失败时疯狂生成新的 incr 文件把目录撑爆
常用配置
| 配置项 | 默认值 | 作用 |
|---|---|---|
appendonly | no | 是否开启 AOF |
appendfilename | appendonly.aof | 文件名(Redis 7 起实际会加上序号和后缀) |
appenddirname | appendonlydir | 存放多部分 AOF 的目录 |
appendfsync | everysec | 刷盘策略,见上文 |
auto-aof-rewrite-percentage | 100 | 增长多少比例触发重写,0 表示关闭 |
auto-aof-rewrite-min-size | 64mb | 触发重写的最小文件大小 |
no-appendfsync-on-rewrite | no | 重写期间暂停fsync,避免 I/O 争抢 |
aof-use-rdb-preamble | yes | base 文件用 RDB 格式,见下文 |
aof-load-truncated | yes | 文件末尾截断时仍然照常启动 |
aof-rewrite-incremental-fsync | yes | 重写时每写入 32MB 就刷一次盘 |
aof-timestamp-enabled | no | 在 AOF 里插入时间戳注释,便于按时间点恢复 |
no-appendfsync-on-rewrite是把双刃剑:开着能避免主进程的fsync和重写子进程的写盘互相抢 I/O 造成延迟尖峰,代价是这段时间内相当于appendfsync no,宕机可能丢 30 秒数据。用不用取决于你更怕延迟还是更怕丢数据
aof-rewrite-incremental-fsync同理,它把重写过程中的刷盘从"最后一次性刷一大块"改成"每 32MB 刷一次",避免重写末尾出现一次巨大的 I/O 尖峰
相关 INFO 字段
aof_enabled 是否开启了 AOF(0/1)
aof_rewrite_in_progress 是否有重写在跑(0/1)
aof_rewrite_scheduled 是否有重写被排期等待(0/1)
aof_last_rewrite_time_sec 上次重写耗时(秒)
aof_current_rewrite_time_sec 正在进行的重写已耗时
aof_last_bgrewrite_status 上次重写结果:ok / err
aof_last_write_status 上次写 AOF 的结果,err 说明磁盘可能满了
aof_last_cow_size 上次重写期间 COW 占用的字节数
aof_rewrites 本次启动以来重写了多少次
aof_rewrite_scheduled是1说明"重写被请求了但一直没真正开始"——它要等当前的 RDB 保存或其他重写结束。如果长时间都是 1,多半是有个卡住的后台任务,值得排查
aof_last_write_status变成err是最需要告警的一条:它意味着命令写不进 AOF 了(通常是磁盘满),此时 Redis 还在正常对外服务,但持久化已经悄悄失效
加载与损坏修复
只要 AOF 开着,重启时就只加载 AOF,不加载 RDB(AOF 理论上更完整)。加载过程是按 manifest 找到 base 和所有 incr,先加载 base,再依次回放 incr,全程阻塞,数据集大时比加载 RDB 慢得多
进程如果在追加过程中被kill -9,AOF 末尾可能出现只写了一半的命令(截断),这时有两个选择:
aof-load-truncated yes(默认):丢掉不完整的那一小段,加载其余部分并正常启动,日志里会打警告并给出截断的偏移量aof-load-truncated no:直接启动失败。适合"宁可服务起不来也不能静默丢数据"的场景
修复工具:
redis-check-aof --fix appendonlydir/appendonly.aof.1.incr.aof会检查文件并直接截断掉损坏的部分,所以跑之前务必备份原文件
用多部分 AOF 时,日志里通常会指出具体是哪个文件出问题,要修的是那一个 incr 文件而不是整个目录。另外
--fix是"丢掉损坏的尾部",不是"把坏数据修好"——它接受的是可控的数据损失
非确定性命令会被改写
AOF 存的是命令,但有些命令在不同时刻重放会得到不同结果。如果原样写进 AOF,重放出来的数据集就可能和原实例不一致。所以 Redis 在写入 AOF(以及发给从节点)之前,会先把这类命令改写成确定性形式:
| 你发出的命令 | 写进 AOF 的形式 | 为什么 |
|---|---|---|
EXPIRE key 60、SET key v EX 60 | PEXPIREAT key <绝对毫秒时间戳> | 相对时间会漂移,"60 秒后"在重放那一刻早就过去了 |
SPOP key | SREM key <实际弹出的成员> | 弹出哪个成员是随机的,重放时必须指定具体是哪一个 |
INCRBYFLOAT key 0.1 | SET key <计算后的结果> | 浮点实现差异可能导致末位不同 |
HINCRBYFLOAT key field 0.1 | HSET key field <结果> | 同上 |
XADD key * field value | XADD key <实际生成的 ID> ... | 自动生成的 ID 和当前时间有关,重放时必须固定下来 |
这是传播层面的改写,你发出去的命令和执行结果都不变,只是落进 AOF 的东西换了形式。理解这一点就能想通"AOF 重放为什么一定能得到和原实例完全一致的数据集"
优缺点
优点
- 丢得少:
everysec最多丢 1 秒,always几乎不丢,这是 RDB 给不了的 - 纯文本、可审计:出问题可以直接看文件内容,甚至手工裁剪命令做部分恢复;RDB 是二进制,做不到
- 追加写不需要 seek:纯顺序 I/O,在机械盘上也能跑得很好
- 损坏影响可控:末尾截断只丢最后一点,不像 RDB 需要整个文件通过校验
缺点
- 文件大:存的是操作流而不是数据集,同样数据量下比 RDB 大得多,重建同一个 list 可能要用几百条
RPUSH - 恢复慢:重启要逐条回放,大数据集下可能比 RDB 慢一个数量级
- 写入侧有持续开销:
always在高吞吐场景基本不可用,everysec也会带来延迟毛刺 - 重写代价和 RDB 一样:同样是
fork+COW,同样有阻塞和内存翻倍的风险
混合持久化
aof-use-rdb-preamble yes(Redis 4.0 起,默认开启)让重写生成的 base 文件用 RDB 格式写,而不是 AOF 格式:前半段是紧凑的 RDB 二进制快照,后半段才是 base 生成之后追加的命令
appendonly.aof.1.base.rdb ← RDB 格式,直接按结构加载,快
appendonly.aof.1.incr.aof ← AOF 格式,逐条回放,只有一小段这么做的收益很直接:恢复时不用回放全部历史命令,绝大部分数据靠加载 RDB 一次性完成,只回放 base 之后的那一小段增量——兼顾了 RDB 的恢复速度和 AOF 的低丢失率,文件也小很多。代价是 base 部分不再是人类可读的文本
关掉它(
aof-use-rdb-preamble no)后 base 文件名会变成.base.aof,整个文件都是可读的 AOF 格式。只有在真的需要"纯文本可审计"时才值得关
到这一步可以把两种持久化的关系收成一句话:RDB 负责"快点恢复、方便备份",AOF 负责"尽量不丢"。生产环境的常规做法是两个都开——用 AOF 保证数据安全,用 RDB 保证重启速度和备份能力,再配合混合持久化把两者的恢复路径合并成一条