← ReJava · 学习目录

REJAVA / LEARNING NOTES

Redis(内存数据存储)数据类型与单线程模型

按业务需求选五种 Redis 类型,讲清过期时间陷阱与单线程代价。

Java 17 / 21更新于 2026.10.12约 40 分钟阅读

根据课程与真实学习交流整理;不是逐字稿

本次更新说明
  • 整理为公开学习笔记:五种常用类型选型、过期时间丢失、KEYS 与 SCAN,含 4 张图示。
  • 附课后问答:多路复用、KEEPTTL、过期删除、SCAN 与 SSCAN 等。

热身 · 一 Redis(内存数据存储)放在哪里 · 二 字符串 · 三 过期 · 四 哈希 · 五 列表与集合 · 六 有序集合 · 七 单线程与慢命令 · 八 应用 · 检验 · 表达 · 追问 · 隔日复测

本课要解决什么问题

学完本课,给你一个业务需求,例如“验证码5分钟有效”“经办人当天三项计数”“每个人最近看过的10件办件”“找出已过期限的办件”,你能选出合适的Redis(内存数据存储)数据类型,写出关键命令和对应的Java调用,并说清要不要设过期时间、怎样设才不会失效。你还能解释Redis(内存数据存储)所说的“单线程”指什么,为什么生产环境不能随手执行 KEYS(列出所有匹配键的命令)。

关于Redis(内存数据存储),最常见的两个问题是:“Redis(内存数据存储)有哪些数据类型,各用在什么场景?”“Redis(内存数据存储)为什么快?单线程为什么还快?”本课先把数据类型和线程模型打牢;持久化、缓存穿透/击穿/雪崩、缓存与数据库一致性放在后面几课。

版本: 截至2026年10月,Redis(内存数据存储) Open Source最新版本是8.10.2(2026-09-17发布)。本课命令在6.2及以后的版本都适用;只在7.4及以后才有的功能会单独标出。Redis(内存数据存储) 8起的许可证为三选一(RSALv2、SSPLv1、AGPLv3);7.4至7.8是RSALv2/SSPLv1双许可;7.2及更早仍是BSD3。所以一些资料里“Redis(内存数据存储)是BSD许可”的说法已经过时。[13]

前置: 第3课的原子性(多线程 count++ 为什么会少加);第6课的 HashMap(普通哈希映射)。 必学: 第一至八节、六道选择题和四问表达。第四节后可暂停。内部编码、I/O线程配置放在折叠拓展里。本课的命令和Java代码都按官方文档核对,没有实际运行Redis(内存数据存储)实测。

主读: 官方 Redis(内存数据存储) data types(数据类型总览)。先读页面上Strings、Hashes、Lists、Sets、Sorted sets五段的定义,再点进各类型页面看“Basic commands”。其他类型(JSON、Streams等)本课不要求。本课引用的官方页面都列在文末“官方来源”。

热身:先把第15课的三个要点说一遍

先凭记忆回答,再展开核对。答不上来就记一句,接着往下学,这不是进入新课的门槛。

第15课:办件号是VARCHAR,Java却按Long传进去。为什么索引用不上?要说出具体原因,而不只是“比较起来更费劲”。

很多不同的字符串(’…123’、’ …123’、’…123a’)转换成数字后都等于同一个数。它们在字符串索引里位置分散,数据库找不到连续区间,所以不用这个索引,只能全表扫描、逐行转换再比较。改法是参数用String(字符串类型)。对照第15课课后问答。

第15课:EXPLAIN(查看执行计划的语句)、EXPLAIN ANALYZE(实际执行并分析)、慢查询日志,各自给你什么?

EXPLAIN(查看执行计划的语句)只给计划和估计,不执行、不给耗时;EXPLAIN ANALYZE(实际执行并分析)真的执行一次,给每一步的实际行数和耗时;slow query log(慢查询日志)记录线上真实请求的耗时和检查行数,上线后用它确认是否改好。

第15课:列表页慢,你按什么顺序排查?

慢查询日志找到语句 → EXPLAIN(查看执行计划的语句)读计划 → 说出原因 → 一次改一处 → 测试环境用实际执行结果对比,上线后看慢查询日志。

第3课:两个Java线程同时对共享变量执行 count++,起始值是10,为什么结果可能是11?

count++ 是“读出10 → 加1 → 写回11”三步。两个线程可能都先读到10,各自算出11再写回,于是少加了一次。这一步不是原子操作。第二节会看到Redis(内存数据存储)的 INCR 为什么没有这个问题。

一、Redis(内存数据存储)放在系统的哪里,存的是什么

“Redis(内存数据存储)是什么?和MySQL是什么关系?”

先看一个办件系统的典型请求:经办人打开20号办件的详情页。没有Redis(内存数据存储)时,每次打开都要查MySQL(关系型数据库)。详情页被反复打开、内容又不常变,就可以把查出来的结果放进Redis(内存数据存储),下次先查Redis(内存数据存储),查到了就不用再访问MySQL(关系型数据库)。

官方对Redis(内存数据存储)的定位是“内存中的数据结构存储”(in-memory data structure store),名字来自Remote Dictionary Server。它和普通键值存储的区别是:每个 key(键)对应的 value(值)不只是一个字符串,还可以是哈希、列表、集合等数据类型,而且在这些类型上定义了原子操作。数据放在内存里,读写非常快,代价是数据集不能大于内存。官方同时说明它也能持久化到磁盘,持久化放在下一课。[1]

官方当前列出的类型很多,包括JSON、Streams、向量集合等;日常业务最常用、也最常被问到的是下面五种。[11]图1把它们和办件系统的例子放在一起:

五种常用类型:一个键对应的值分别长成单个值、字段表、有序队列、无序不重复集合、按分数排序的集合String 字符串case:detail:20缓存办件详情、计数{"id":20,"status":"已通过",…}Hash 哈希stat:staff:7:2026-10-09一个对象的多个字段accepted12finished9returned1List 列表recent:staff:7按插入顺序排列2035189左端是最新放进去的Set 集合case:readers:20不重复的成员71231成员不重复,没有先后Sorted Set 有序集合case:deadline成员带分数,按分数排1810-08 17:002010-09 09:003510-09 15:00分数是办理期限,从早到晚排好
图1 左边是键名和用途,右边是这个键对应的值长什么样。String(字符串类型)是一个值;Hash(哈希)是一组“字段→值”;List(列表)按放入顺序排;Set(集合)不重复、无顺序;Sorted Set(有序集合)每个成员带一个分数,按分数排序。

键名约定: 本课用“业务:对象:编号”的格式命名键,例如 case:detail:20 表示“办件详情、20号”。这是常见的团队约定,便于按前缀管理,不是Redis(内存数据存储)的强制规则。

先试着自己解释,再看参考回答

Redis(内存数据存储)是放在内存里的数据结构存储,按键存取,读写很快。它常放在MySQL前面做缓存:先查Redis(内存数据存储),查不到再查数据库。和普通键值存储不同,它的值可以是字符串、哈希、列表、集合、有序集合等类型,每种类型都有自己的原子操作。代价是数据放在内存里,数据集不能大于内存。

二、String(字符串类型):缓存、验证码、计数

“String(字符串类型)一般用来做什么?”

官方说明:Redis(内存数据存储)的字符串存的是字节序列,可以是文本、序列化后的对象或二进制数据,单个值最大512 MB。它最常用来做缓存,也支持计数。[2] 办件系统里有三种典型用法:

# 1. 缓存20号办件详情(序列化成JSON),10分钟后过期
SET case:detail:20 '{"id":20,"status":"已通过"}' EX 600
GET case:detail:20

# 2. 存验证码本身,5分钟后失效
SET sms:code:13800000000 482913 EX 300
#    发送前先限制:60秒内只能发一次
SET sms:limit:13800000000 1 NX EX 60

# 3. 今天的受理量加1
INCR stat:accept:2026-10-09

验证思路:命令和选项按官方SET、INCR命令页核对,本课没有实际运行。有测试实例时,可以连续执行两次 SET sms:limit:… 这条命令:第一次返回OK,60秒内第二次不返回OK(键已存在,没有设置成功)。

SET的两个选项: EX 600 表示设置600秒后过期;NX 表示“只有这个键不存在时才设置”。验证码本身用 EX 300 存5分钟,用户提交时 GET 出来比对,过期后就读不到了。限发那条命令里,第一次执行时键不存在,设置成功,应用就去发短信;60秒内再执行,键还在,设置不成功,应用就提示“请稍后再试”。60秒后键自动删除,又可以发了。[2]

官方SET页面也提到,SET 资源名 任意值 NX EX 秒数 是实现锁的一种简单写法,但官方不推荐这种写法,建议用Redlock算法。分布式锁是另一个需要单独学习的主题,本课的NX只用来做“不重复发送”。[2]

INCR为什么不会少加

热身里说过,Java的 count++ 是“读、加、写”三步,两个线程可能都读到10,最后写回11。Redis(内存数据存储)的 INCR 不会这样。官方专门解释了“INCR是原子的”是什么意思:多个客户端同时对同一个键执行INCR,不会出现客户端1读到10、客户端2也读到10、两边都写回11的情况,最终一定是12。因为“读、加、写”这一整步执行时,其他客户端的命令都不在同时执行。键不存在时,INCR先把它当成0再加1。[2]

所以多台应用服务器同时统计受理量,用INCR不需要应用自己加锁。为什么Redis(内存数据存储)能做到“执行时其他命令都不在执行”,第七节讲单线程时会接上。

先试着自己解释,再看参考回答

String(字符串类型)存的是字节序列,最常用来缓存序列化后的对象,比如办件详情的JSON,用SET加EX设置过期时间。SET加NX表示键不存在时才设置,可以做“60秒内只能发一次验证码”。INCR做计数,它是原子的:多个客户端同时对同一个键加1,不会像Java的count++那样少加,因为一次INCR执行时其他命令不会同时执行。

三、过期时间:设了以后,哪些操作会把它弄丢

“Redis(内存数据存储)的过期时间怎么设?过期的键是到点立刻删除的吗?”

缓存和验证码都应该带过期时间。除了SET的EX选项,还可以对已有的键执行 EXPIRE 键 秒数。官方说明:过期时间到了,键会被自动删除(到期后就读不到,具体何时从内存清掉见本节末尾)。TTL 键 查看剩余秒数:返回 −2 表示键不存在,返回 −1 表示键存在但没有过期时间。[3]

最容易踩的坑:重新SET会清掉旧的过期时间。 官方说明,只有删除或覆盖键内容的命令(DEL、SET、GETSET等)才会清除过期时间;只是修改内容、不整个替换的操作会保留原来的过期时间,官方举的例子是对计数器INCR、用LPUSH往列表里追加、用HSET改哈希的某个字段。SET页面也写明:SET成功后,键原来的过期时间会被丢弃。[3] [2]

先带过期时间设置,后来不带过期时间覆盖,键就变成永不过期SET … EX 600SET …(没带EX)原定10分钟到期时TTL 600 → 倒数中TTL 变成 −1:永不过期时间键还在,没被删除刷新缓存时如果忘了带EX,这个键就再也不会过期,内存只增不减。INCR、往列表追加这类只修改内容的命令,会保留原来的过期时间。
图2 同一个键的时间线。第一次SET带了600秒的过期时间;中途另一段代码重新SET但没带EX,旧的过期时间被丢弃,TTL(剩余存活时间)变成 −1。

所以写缓存时, 每一次 SET都要带上过期时间;Java里每次调用都要传 Duration(见第五节末尾的代码)。Redis(内存数据存储) 6.0起SET还有一个 KEEPTTL 选项,覆盖值时保留原来的过期时间。[15]

计数器怎样设过期:看INCR的返回值

第二节的每日受理量 stat:accept:2026-10-09 也需要过期,否则每天留下一个永久的键。可是这个键是INCR第一次执行时自动创建的,没有机会在SET时带EX。官方INCR页面给的做法是 看INCR的返回值 :

INCR stat:accept:2026-10-09
# 如果上面返回的是1,说明这个键刚刚被创建,再给它设过期(两天后清理)
EXPIRE stat:accept:2026-10-09 172800

验证思路:做法来自官方INCR页面的限流示例(INCR返回1时再EXPIRE),本课没有实际运行。有测试实例时,INCR后执行TTL(剩余存活时间)应约为172800;再INCR一次,TTL(剩余存活时间)继续倒数,不会重新回到172800。

之后每次INCR,返回值都大于1,不再执行EXPIRE;INCR只修改内容,也不会清掉已经设好的过期时间,所以倒计时一直从第一次开始算。 注意一个边界: INCR和EXPIRE是两条命令,如果应用执行完INCR、还没来得及执行EXPIRE就崩溃了,这个键就没有过期时间,会一直留下来。官方指出了这个竞态,并给出修法:把“INCR,返回1时再EXPIRE”写成一段Lua脚本,作为一条命令发给Redis(内存数据存储)执行。Lua脚本本课不展开。[15]

过期的键是不是到点立刻删除? 不一定。官方说明过期有两种方式:被动方式是客户端访问一个已经过期的键时,Redis(内存数据存储)发现它过期了就删除;主动方式是Redis(内存数据存储)定期从设置了过期时间的键里随机抽查一些,删除其中已过期的。[3] 对应用来说,过期的键读不到;但它在被删除前可能还占着一点内存。

先试着自己解释,再看参考回答

可以用SET的EX选项,或者对已有的键执行EXPIRE。TTL(剩余存活时间)返回−2表示键不存在,−1表示存在但没有过期时间。要注意,SET会覆盖整个值并丢弃旧的过期时间,所以每次刷新缓存都要重新带上过期时间;INCR这类只改内容的命令会保留过期时间。过期删除有被动和主动两种:访问时发现过期就删,另外Redis(内存数据存储)定期抽查一部分带过期时间的键。

四、Hash(哈希):一个对象的多个字段

“存一个对象,用String(字符串类型)存JSON还是用Hash(哈希)?”

官方说明:Redis(内存数据存储)的哈希是“字段—值”对的集合,类似Java的HashMap(普通哈希映射),可以用来表示对象,或者把一组计数器放在一起。[4] 例如每个经办人当天要统计受理、办结、退回三项数字:

# 7号经办人受理了一件:accepted 字段加1
HINCRBY stat:staff:7:2026-10-09 accepted 1
# 只读一个字段
HGET stat:staff:7:2026-10-09 accepted
# 一次读三个字段
HMGET stat:staff:7:2026-10-09 accepted finished returned

验证思路:命令按官方Hash(哈希)页面核对,本课没有实际运行。有测试实例时,执行两次第一条命令后,HGET应返回2。

用Hash(哈希)的好处是能 只改一个字段 :受理一件就只给accepted加1,不用把整个对象读出来、改完再写回去。如果换成String(字符串类型)存一段JSON,每次加1都要“读出整段 → 应用里改 → 整段写回”,和热身里的count++一样有并发覆盖的风险。

对象的用法更合适的类型原因
整个对象一次读、一次写,比如办件详情缓存String(字符串类型)存JSON读写都是整体,最简单
经常单独修改某几个字段,比如三项计数Hash(哈希)HINCRBY、HSET只改一个字段,不用整体读写

这张取舍表参考了JavaGuide的说法:多数情况下String(字符串类型)更合适,经常修改部分字段时选Hash(哈希)。[14]

注意 HGETALL: 官方说明大多数哈希命令是O(1),但HGETALL、HKEYS、HVALS是O(n),n是字段数。字段很多时一次取全部会很慢,第七节会讲为什么“慢一条”会拖住所有请求。[4]

拓展:能不能只让哈希里的某一个字段过期?

Redis(内存数据存储) 7.4起可以。官方说明7.4引入了给单个哈希字段设置过期时间的能力,例如HEXPIRE命令,字段到期后会从哈希里自动删除。更早的版本只能给整个键设过期。用之前先确认线上Redis(内存数据存储)版本。[4]

先试着自己解释,再看参考回答

Hash(哈希)是字段到值的集合,类似Java的HashMap(普通哈希映射),适合表示一个对象或一组计数器。整个对象一次读写,用String(字符串类型)存JSON最简单;经常单独改某个字段,比如经办人的受理数、办结数,就用Hash(哈希),HINCRBY只改一个字段,不用把整个对象读出来再写回,也避免并发覆盖。字段很多时要小心HGETALL,它是O(n)。

这里可以暂停。先能说清String(字符串类型)和Hash(哈希)各适合什么、过期时间怎样才不会丢,再继续看后三种类型。

五、List(列表)与 Set(集合):要顺序,还是要不重复

“List(列表)和Set(集合)有什么区别?各举一个场景。”

List(列表):按放入顺序排,适合“最近N条”

官方说明:Redis(内存数据存储)的列表是按插入顺序排列的字符串链表。因为是链表,即使有几百万个元素,往头部或尾部加一个元素也是常数时间;代价是按下标访问中间的元素会慢,要走的步数和下标成正比。[5]

例如“7号经办人最近看过的10件办件”。每打开一件,就把编号从左边放进去,然后只保留前10个:

LPUSH recent:staff:7 20      # 从左端(头部)放入20号
LTRIM recent:staff:7 0 9     # 只保留下标0到9,也就是最新的10个
LRANGE recent:staff:7 0 9    # 读出这10个,最新的在前

验证思路:命令按官方List(列表)页面核对,本课没有实际运行。有测试实例时,连续LPUSH 12个不同编号再执行LTRIM,LRANGE应只返回最后放入的10个。

官方把 LPUSH+LTRIM 称为“只记住最新N条、丢掉旧的”的用法。[5] 同一个人重复打开20号会放进两次,如果要去重,需要先从列表里删掉旧的再放入,这里不展开。

官方也介绍了用列表做简单队列:生产者LPUSH,消费者用BRPOP阻塞等待新元素,不必轮询。[5] 业务上需要“消息不丢、能重试”的可靠队列时,应考虑专门的消息队列,本课不把List(列表)当成可靠消息队列。

Set(集合):成员不重复,适合去重和关系

官方说明:集合是不重复字符串的无序集合,可以用来记录唯一项(例如访问过某篇文章的不重复IP)、表示关系(例如拥有某个角色的所有用户),以及求交集、并集、差集。添加、删除、判断成员都是O(1)。[6]

SADD case:readers:20 7        # 7号经办人看过20号办件(重复执行不会重复记录)
SISMEMBER case:readers:20 7   # 7号看过吗?返回1表示是
SCARD case:readers:20         # 有多少人看过
SINTER role:approver role:legal   # 同时在两个集合里的成员(交集)

验证思路:命令按官方Set(集合)页面核对,本课没有实际运行。有测试实例时,对同一成员执行两次SADD,SCARD仍应返回1;两个集合各放几个成员后,SINTER只返回两边都有的。Java对应 opsForSet().intersect(键1, 键2)。

注意 SMEMBERS: 它一次返回整个集合,是O(n)。官方提醒,几十万成员以上的大集合要小心使用,可以改用SSCAN分批取。[6]

在Java里怎么写

Spring Data Redis(Spring 的内存数据存储访问模块)的 RedisTemplate 按Redis(内存数据存储)命令的分组提供操作入口:opsForValue() 对应字符串,opsForHash()、opsForList()、opsForSet()、opsForZSet() 分别对应哈希、列表、集合、有序集合。StringRedisTemplate 把键和值都按字符串保存,在命令行里能直接看懂。[12]

// stringRedisTemplate 由 Spring Boot 注入
ValueOperations<String, String> value = stringRedisTemplate.opsForValue();
value.set("case:detail:20", json, Duration.ofMinutes(10));          // SET … EX 600
Boolean canSend = value.setIfAbsent("sms:limit:13800000000", "1",
        Duration.ofSeconds(60));                                     // SET … NX EX 60
Long accepted = value.increment("stat:accept:2026-10-09");          // INCR

stringRedisTemplate.opsForHash()
        .increment("stat:staff:7:2026-10-09", "accepted", 1);       // HINCRBY

ListOperations<String, String> recent = stringRedisTemplate.opsForList();
recent.leftPush("recent:staff:7", "20");                            // LPUSH
recent.trim("recent:staff:7", 0, 9);                                // LTRIM
List<String> latest = recent.range("recent:staff:7", 0, 9);         // LRANGE

stringRedisTemplate.opsForSet().add("case:readers:20", "7");        // SADD
Boolean read = stringRedisTemplate.opsForSet().isMember("case:readers:20", "7"); // SISMEMBER

验证思路:方法名和参数按Spring Data Redis(Spring 的内存数据存储访问模块)当前Javadoc核对(见来源[12]),本课没有编译运行。setIfAbsent 返回true表示设置成功、可以发短信;注意 value.set(...) 这一行每次都传了 Duration,避免第三节的过期时间丢失问题。

先试着自己解释,再看参考回答

List(列表)是按插入顺序排列的链表,头尾增删是常数时间,按下标访问中间元素慢,适合“最近看过的10件”这类场景:LPUSH放入,LTRIM只保留前10个。Set(集合)是不重复的无序集合,判断成员是O(1),适合去重和关系,比如“谁看过20号办件”,还能求交集并集。需要顺序用List(列表),需要不重复用Set(集合)。大集合不要直接SMEMBERS,用SSCAN分批取。

六、Sorted Set(有序集合):按分数排好,适合排行和到期

“排行榜用什么类型?有序集合底层是什么结构?”

官方说明:有序集合是不重复成员的集合,每个成员带一个分数(score),按分数排序;分数相同时按成员的字典序排。典型用途有排行榜和限流。[7]

办件系统里有一个很合适的场景: 找出已经超过办理期限的办件。 把办件编号作为成员、办理期限的时间戳作为分数放进去,定时任务每分钟只需对Redis(内存数据存储)按分数取一次“期限 ≤ 现在”的那一段,不用去扫办件表:

# 20号办件的期限是北京时间 2026-10-09 09:00,秒级时间戳 1791507600 作为分数
ZADD case:deadline 1791507600 20
# 假设现在是北京时间 10-09 16:00(1791532800);0 表示从最小分数开始
ZRANGE case:deadline 0 1791532800 BYSCORE
# 办理完成后从集合里移除
ZREM case:deadline 20

验证思路:ZADD、ZRANGE的BYSCORE参数按官方页面核对;时间戳是示意数值,实际用 Instant.now().getEpochSecond() 之类的方法计算。本课没有实际运行。

有序集合按办理期限排好,按分数范围取出现在之前的那一段,就是已超期的办件分数 ≤ 现在:已超期,按分数范围一次取出1810-08 17:002010-09 09:003510-09 15:00现在 10-09 16:004110-10 10:005210-12 09:00成员是办件编号,分数是办理期限;集合内部始终按分数从小到大排好。
图3 case:deadline 这个有序集合。“现在”左边的18、20、35号已超期,用 ZRANGE … BYSCORE 按分数范围一次取出;右边的还没到期,不会被读到。

排行榜 同理:成员是经办人编号,分数是本月办结数。ZRANGE rank:finish:2026-10 0 9 REV 按分数从高到低取前10名。官方说明,从Redis(内存数据存储) 6.2起,ZRANGE加上BYSCORE、REV等参数可以替代ZRANGEBYSCORE、ZREVRANGE等旧命令,ZRANGEBYSCORE已被标为弃用;旧资料里还常见旧命令,认识即可。[7]

底层结构: 这是常见追问。官方的实现说明是:有序集合由 一个skip list(跳表)和一个哈希表 两部分组成,所以每次添加元素是O(log N)的操作。[7] 可以这样理解两部分的分工:哈希表让“按成员找分数”很快,跳表让成员始终按分数排好、能快速取一段范围。这一句分工是对官方说明的理解,官方原文只说由这两部分组成。跳表的具体结构本课不展开。

分数会变怎么办: 对已经存在的成员再执行一次ZADD,会更新它的分数;只想在原分数上加一点(例如办结数加1),用 ZINCRBY 键 增量 成员。[12] [15]

Java里对应 opsForZSet().add(键, 成员, 分数)、rangeByScore(键, 最小分, 最大分)、reverseRange(键, 0, 9)(按分数从高到低取前10)。[12]

先试着自己解释,再看参考回答

有序集合的每个成员带一个分数,按分数排序,适合排行榜和按时间处理的任务。比如把办件编号作为成员、办理期限作为分数,用ZRANGE加BYSCORE取“期限在现在之前”的一段,就是已超期的办件;排行榜用ZRANGE加REV取分数最高的前10名。官方说明它由跳表和哈希表组成,添加是O(log N):哈希表负责按成员快速找分数,跳表负责保持按分数的顺序、支持范围查询。

七、“单线程”到底指什么,为什么一条慢命令会拖住所有人

“Redis(内存数据存储)是单线程的吗?单线程为什么还这么快?”

官方的原话是“mostly single threaded”(基本是单线程的):一个进程服务所有客户端的请求, 同一时刻只处理一个请求,所有请求按顺序执行 。一个线程怎么照看成百上千个连接?它同时盯着所有连接,哪个连接的数据到了就处理哪个,这种做法叫I/O多路复用。说“基本”,是因为Redis(内存数据存储)也会用后台线程做一些慢的磁盘I/O等工作,但这不改变“所有请求由一个线程处理”这件事。[8]

为什么还快? 官方给的理由有两条:一是单个请求本身完成得很快;二是更主要的原因,某个连接的数据还没到时,Redis(内存数据存储)不会停下来等它,而是先去处理别的连接,不会卡在读写网络这类系统调用上。官方FAQ还说,Redis(内存数据存储)的瓶颈通常是内存或网络,很少是CPU(处理器)。[8] 再加上数据都在内存里,一次GET、SET通常只花很短的时间。

一些学习资料(例如JavaGuide)常补一句“单线程执行命令,避免了多线程的锁竞争和上下文切换”。这是常见的解释,但本课在官方页面里没有找到这句原话,回答时可以作为自己的理解来说。[14] 第二节INCR“执行时其他命令不同时执行”,正是“请求按顺序一个个执行”带来的效果。

代价:一条慢命令,所有人一起等

官方把后果说得很直接:因为是单线程,一个请求处理得慢,其他所有客户端都要等它处理完。GET、SET、LPUSH这类常数时间的命令没问题;但有些命令要处理很多元素,比如SORT、SUNION等。[8] 最典型的是 KEYS:官方明确要求在生产环境极其小心,它在大数据库上执行可能拖垮性能,只用于调试;需要按前缀找键时,用SCAN,或者写入键时把键名SADD到一个集合里,需要时读这个集合。[9] 注意:KEYS的复杂度是O(N),N是 整个库 的键数;KEYS case:detail:* 虽然带了前缀,仍要把几百万个键逐个拿来比对,匹配到的键少,耗时也不会少。[15]

一个线程按顺序执行命令:KEYS执行期间其他请求全部排队;SCAN分批执行,中间可以插入其他请求执行 KEYS case:*(一次遍历所有键)GETSETKEYS case:* 遍历全部键GETSETGET这些请求早就到了,只能排在KEYS后面改用 SCAN(每次只取一小批)GETSCANSETGETSCANGETSCANSETSCANGET每批很快结束,其他请求可以插在中间执行;游标回到0时遍历结束,同一个键可能返回不止一次。
图4 横轴是时间,每个方块是一条命令,一个线程从左到右依次执行(示意,方块长短代表耗时)。KEYS一次遍历所有键,执行期间后面的GET、SET只能等;SCAN把遍历拆成很多小批,每批之间别的请求能插进来。

SCAN是基于游标的迭代器:从游标0开始,每次调用返回一小批元素和下一个游标,服务器返回的游标为0时遍历结束。官方说明它每次只返回少量元素,所以可以在生产环境使用,不会像KEYS、SMEMBERS那样在大集合上阻塞服务器很久(甚至几秒)。代价是同一个元素可能返回多次,应用要能处理重复。还有两点容易用错:某一批返回空列表不代表结束,要一直调用到返回的游标为0;SCAN的总工作量并没有变少,只是拆成很多小批执行,不会长时间挡住别人。[9] [15] 集合、哈希、有序集合也有同一系列的SSCAN、HSCAN、ZSCAN。

由此得到两条习惯: 生产代码里不用KEYS;对成员很多的集合或哈希,不一次取全部(SMEMBERS、HGETALL),改用对应的SCAN系列分批取。

拓展:Redis(内存数据存储) 6.0以后不是有多线程了吗?

有,但多线程做的是网络读写,不是执行命令。Redis(内存数据存储) 6.0的发布说明写到:Redis(内存数据存储)现在可以选择用线程处理I/O。8.10.2版本的配置文件 redis.conf 说明:默认不开启;开启后,读写客户端连接、解析协议这些工作可以交给I/O线程,建议至少4核的机器才开,并留出一个核,例如4核试3个线程。配置文件开头仍然写着“Redis(内存数据存储)基本是单线程的”。所以准确的概括是“6.0引入了多线程I/O,命令仍由一个线程顺序执行”。[8]

拓展:同样是Hash(哈希),为什么小的和大的占用内存不一样?

对外的数据类型和内部的存储方式(编码)不是一回事。OBJECT ENCODING 键 可以看内部编码:元素少、值短的哈希、有序集合、集合会用一种紧凑的 listpack(紧凑列表编码),只含整数的小集合用intset,小集合在7.2起也可以用listpack(紧凑列表编码);超过阈值后自动转成普通编码(哈希转hashtable,有序集合转skiplist)。官方说紧凑编码最多可省10倍内存,对使用者完全透明。Redis(内存数据存储) 7.0起的默认阈值例如 hash-max-listpack-entries 512、zset-max-listpack-entries 128。列表在官方文档里讲成链表;内部编码上,小列表在7.0起用listpack(紧凑列表编码),大列表用quicklist(由多个紧凑块串起来)。被问到“底层结构”时,可以先说“对外是有序集合,对内元素少时用listpack(紧凑列表编码)、多了用跳表加哈希表”。[10]

先试着自己解释,再看参考回答

Redis(内存数据存储)的“单线程”是指一个线程按顺序处理所有客户端的命令,同一时刻只执行一个请求;它用多路复用照看很多连接,后台也有一些线程做慢I/O,6.0以后还可以开I/O线程处理网络读写,但命令执行仍是一个线程。它快是因为数据在内存里、单个请求很短、设计上不阻塞在系统调用上,瓶颈通常是内存或网络。代价是一条慢命令会让所有客户端排队,所以生产不用KEYS,大集合不用SMEMBERS、HGETALL一次取全部,改用SCAN系列分批取,并处理可能的重复。

八、应用:为新需求选结构,排查一次线上问题

“给你几个业务需求,你会怎么用Redis(内存数据存储)实现?”

A.为三个新需求选结构

背景: 办件系统新增三个需求。请为每个需求选择Redis(内存数据存储)数据类型,写出关键命令,并说明过期时间怎么处理。正文演示过的是验证码、每日受理量、最近浏览、已阅名单,这三个需求要你迁移。

  1. 登录失败锁定: 同一个账号从第一次输错密码开始计时,5分钟内累计输错5次就锁定。锁定一直持续到这个5分钟窗口结束、计数自动清零为止。每次登录前,系统先读计数,达到5就直接拒绝,不再校验密码。要求:后面的错误 不能 把5分钟窗口往后延长。
  2. 部门首页滚动展示: 每个部门首页滚动显示本部门 最近提交 的50件办件编号,新的在前。
  3. 会签人员: 某类办件需要“既有审批角色、又有法制审核角色”的人员会签。系统里已经分别维护了“审批角色成员”和“法制审核角色成员”两份名单,要算出同时在两份名单里的人。
完成后核对过程

1. 用String(字符串类型)计数,做法和第三节的每日受理量一样。每次输错执行 INCR login:fail:账号;返回1时说明窗口刚开始,再执行 EXPIRE login:fail:账号 300。后面的错误只执行INCR、 不再执行EXPIRE ,所以5分钟窗口不会被重新延长;INCR只改内容,也不会清掉已设置的过期时间。登录前 GET 计数,达到5就拒绝。两点要注意:不能每次都用SET写回新数字,那样会丢掉过期时间;INCR和EXPIRE之间如果应用崩溃,键会没有过期时间、账号被一直锁住,正式实现应按官方建议把两步放进一段Lua脚本。

2. 用List(列表)。每提交一件,LPUSH dept:recent:10 编号,再 LTRIM dept:recent:10 0 49 只保留最新50个;展示时 LRANGE dept:recent:10 0 49。这个列表一直有用,可以不设过期时间。

3. 用两个Set(集合)分别存两份名单,求交集:SINTER role:approver role:legal。名单要和数据库保持一致,更新角色时同步修改集合。

B.一次线上问题:接口都变慢,内存还在涨

背景: 线上Redis(内存数据存储)里有几百万个键。运维同事写了一个定时任务,每分钟执行一次 KEYS case:detail:*,统计当前缓存了多少件办件详情。另外,办件详情缓存最初都用 set(键, json, Duration.ofMinutes(10)) 写入,带10分钟过期。后来有两段刷新代码会改这些缓存:

// 刷新代码A:办件被修改后,重写整段详情
stringRedisTemplate.opsForValue().set("case:detail:" + id, json);

// 刷新代码B:办件被浏览后,给统计哈希里的一个字段加1(统计哈希也带了过期时间)
stringRedisTemplate.opsForHash().increment("case:stat:" + id, "views", 1);

验证思路:这是练习题给定的代码,请先自己判断,再展开核对。

观察到的现象: 每分钟都有一小段时间,所有接口访问Redis(内存数据存储)都变慢;同时Redis(内存数据存储)内存持续增长,几天也降不下来,很多键的TTL(剩余存活时间)是 −1。

  1. 为什么一个统计任务会让 所有 接口都变慢?“只匹配 case:detail: 开头的键”能不能让它变快?应该怎样改这个统计?
  2. 刷新代码A和B,哪一段会让键的TTL(剩余存活时间)变成 −1?为什么另一段不会?应该怎样改?
完成后核对过程

1. Redis(内存数据存储)按顺序一个个执行命令。KEYS的复杂度按整个库的键数算,带了前缀也要把几百万个键逐个比对;执行期间其他客户端的GET、SET只能排队,所以每分钟都有一段时间所有接口变慢。改法:需要精确数量,就用SCAN按游标分批数,一直调用到游标为0,并处理重复的键;只要大致规模,可以抽样估算。

2. 代码A会。它调用不带过期时间的 set(key, json),相当于不带EX的SET:SET覆盖整个值,并丢弃原来的过期时间,键变成永不过期。代码B是HINCRBY,只修改哈希里一个字段,属于“只改内容、不整个替换”,会保留原来的过期时间。改法:代码A每次写入都带 Duration.ofMinutes(10);已经变成永久的键,需要另外清理或重新设置过期时间。

九、即时检验:先判断,再读解释

每题只问一个主要判断。先作答,再展开核对答案与解释。

  1. 选结构

每个经办人当天有受理、办结、退回三个数字。每发生一笔业务,只有其中一个数字加1;页面偶尔要同时显示三个数字。

问题: 选哪种存法最合适?

  • A. 用String存JSON
  • B. 用Hash存三个计数字段
  • C. 用List按顺序存三个数
查看答案与解释

答案:B

经常单独修改某个字段,用Hash(哈希),HINCRBY只给一个字段加1,不用整段读出再写回。String(字符串类型)存JSON每次加1都要整体读写,并发时可能互相覆盖;List(列表)按顺序存,没有字段名,不适合按名字改某一项。

  1. 重新SET以后

一个键先用 SET 键 值 EX 600 写入。2分钟后,另一段代码执行 SET 键 新值,没有带EX。紧接着执行 TTL 键。

问题: TTL(剩余存活时间)返回什么?

  • A. 负一,键不会再过期
  • B. 负二,键已经不存在
  • C. 大约还剩四百八十秒
查看答案与解释

答案:A

SET会覆盖整个值,并丢弃原来的过期时间,所以键还在,但没有过期时间了,TTL(剩余存活时间)返回−1。−2表示键不存在。

  1. 同时计数

键 stat:accept 当前值是10。两台应用服务器几乎同时各执行一次 INCR stat:accept,都执行成功。

问题: 最终的值是多少?

  • A. 一定是十二
  • B. 可能是十一
  • C. 一台会报错
查看答案与解释

答案:A

INCR是原子的:一次INCR的“读、加、写”执行时,其他客户端的命令不会同时执行,不会出现两边都读到10的情况,最终一定是12。

  1. 生产上执行KEYS

线上Redis(内存数据存储)有几百万个键。有人在业务高峰执行 KEYS case:*,这条命令要执行一段时间。

问题: 在它执行期间,其他客户端的GET会怎样?

  • A. 由其他线程并行执行
  • B. 直接返回空值不等待
  • C. 排队等这条命令结束
查看答案与解释

答案:C

Redis(内存数据存储)由一个线程按顺序执行命令,一个请求慢,其他客户端都要等。生产上应改用SCAN分批遍历。

  1. 找出已超期的办件

有序集合 case:deadline 的成员是办件编号,分数是办理期限的时间戳。

问题: 要找出期限不晚于当前时间的办件,应该怎样查?

  • A. 按成员名字的顺序取
  • B. 按分数范围取出一段
  • C. 按插入先后的顺序取
查看答案与解释

答案:B

有序集合按分数排好,用 ZRANGE 键 0 当前时间戳 BYSCORE 就能取出分数在这个范围内的成员,也就是期限已过的办件。

  1. “单线程”指什么

常见问题:“都说Redis(内存数据存储)是单线程的,那它进程里是不是只有一个线程?”

问题: 哪种说法最准确?

  • A. 命令由一个线程按顺序执行
  • B. 整个进程始终只有一个线程
  • C. 每个客户端分配单独的线程
查看答案与解释

答案:A

官方说的是“基本是单线程”:所有请求由一个线程按顺序处理;后台还有线程做慢I/O等工作,6.0起还可以开I/O线程处理网络读写。

十、把过程讲出来:用完整回答检验理解

连续回答下面四问,可以写下来,也可以先口述再整理成文字。每问先说是什么,再说为什么,最好带一个办件系统的例子。写完再对照各节的参考回答。

第一问:五种常用数据类型

请介绍Redis(内存数据存储)常用的数据类型。

请回答: String(字符串类型)、Hash(哈希)、List(列表)、Set(集合)、Sorted Set(有序集合)各是什么样的结构?各举一个办件系统里的场景和一条关键命令。存一个对象时,String(字符串类型)和Hash(哈希)怎么选?

第二问:单线程为什么还快

常见问题:“Redis(内存数据存储)是单线程的吗?为什么还这么快?有什么代价?”

请回答: “单线程”具体指什么?快的原因有哪些?为什么一条慢命令会影响所有客户端?6.0以后的多线程做的是什么?

第三问:过期时间

你负责办件详情缓存,要求10分钟后过期。

请回答: 怎样设置过期时间?TTL(剩余存活时间)返回−1和−2各表示什么?哪些操作会让过期时间丢失,举一个会因此出问题的代码写法。过期的键是到点立刻删除的吗?

第四问:生产上遍历键

运维需要统计所有以 case:detail: 开头的键有多少个,线上有几百万个键。

请回答: 为什么不能用KEYS?应该用什么,它是怎么工作的,有什么要注意的?对一个有几十万成员的集合,又该怎样取全部成员?

练习:请在自己的笔记中写出答案(可附第八节的判断过程),再对照各节参考回答核对。

十一、集中追问:把容易混的地方分开

先自己解释,再展开核对

能不能用 SET 键 值 NX EX 秒数 直接做分布式锁?

官方SET页面提到这是实现锁的一种简单写法,但明确表示不推荐,建议用Redlock算法,因为后者更有保障、能容错。锁的释放、超时续期等问题属于分布式锁的专门内容,本课不要把NX当成完整的锁方案。

List(列表)能当消息队列用吗?

官方介绍过用LPUSH加BRPOP做简单的生产者—消费者队列,消费者阻塞等待、不用轮询。但“消费者取走后崩溃,消息还在不在”“失败怎么重试”这些可靠性问题本课没讲,需要和专门的消息队列放在一起比较。

过期时间到了,键是不是立刻从内存里删除?

不一定。被动方式:访问时发现过期就删除;主动方式:定期随机抽查一部分带过期时间的键,删除已过期的。应用读不到过期的键,但它在被删除前可能还占着内存。

INCR是原子的,那“先GET判断、再SET写入”两条命令合起来也是原子的吗?

不是。每条命令执行时不会被其他命令打断,但两条命令之间,别的客户端的命令可以插进来,这和第3课“检查再执行”的问题一样。所以需要“判断再写入”时,优先用一条命令完成,比如 SET 的 NX 选项、INCR。

Redis(内存数据存储)数据都在内存里,重启后会全部丢失吗?

官方说Redis(内存数据存储)是“在内存中、但持久化到磁盘”的数据库。具体怎样持久化(RDB(快照文件)、AOF(追加写日志))、会丢多少,见第18课 Redis(内存数据存储)持久化。

十二、隔日复测:换一个业务对象

隔一天再用五分钟回答;约一周后再换场景试一次。刚学完马上复述,只能说明短时记忆。

1. 商城要统计每件商品今天被浏览的次数,多台服务器同时上报,并且第二天自动清零。用什么类型和命令?过期时间怎么设才不会被后面的操作清掉?

用String(字符串类型),每次浏览 INCR 商品浏览键(键名里带日期);键名里带日期,第二天自然换用新键,“清零”靠的是换键;过期时间只是为了清理旧键:第一次INCR返回1时,再EXPIRE设一个超过一天的时长(例如两天)。INCR是原子的,多台服务器同时加不会少;INCR不会清掉过期时间。不要用SET写回新数字。

2. 游戏要显示积分最高的前20名玩家,积分会不断变化。用什么类型?怎么取前20?

用Sorted Set(有序集合),成员是玩家编号,分数是积分;积分变化时更新分数;用 ZRANGE 键 0 19 REV 按分数从高到低取前20。

3. 线上有人用 HGETALL 读一个有50万个字段的哈希,期间其他请求都变慢。为什么?怎么改?

HGETALL是O(n),50万个字段要处理很久;Redis(内存数据存储)按顺序执行命令,这期间其他请求都排队。改用HSCAN分批读,或者从设计上避免把这么多字段放进一个键。

第15课复习:EXPLAIN(查看执行计划的语句) 和 EXPLAIN ANALYZE(实际执行并分析) 的区别是什么?验证优化时要控制哪些条件?

EXPLAIN(查看执行计划的语句)只给计划和估计,不执行;EXPLAIN ANALYZE(实际执行并分析)真的执行,给实际行数和耗时。对比时用同一份数据、同一组参数,一次只改一处,看实际行数而不只看耗时;上线后看慢查询日志。

官方来源与配套材料

本课速查 · 完整术语表。命令和代码按官方文档核对,没有实际运行Redis(内存数据存储);时间戳、计数等数字为示意。

  1. Redis(内存数据存储) FAQ与数据结构存储入门:内存数据结构存储、与普通键值存储的区别。

  2. Strings、SET、INCR:用途、512 MB、NX/EX、覆盖丢弃过期时间、INCR原子性。

  3. EXPIRE、TTL(剩余存活时间):自动删除、清除过期时间的命令、被动与主动过期、−1/−2。

  4. Hashes:字段—值对、HINCRBY、HGETALL为O(n)、7.4起字段过期。

  5. Lists:链表、LPUSH/LTRIM保留最新N条、BRPOP。

  6. Sets:唯一成员、关系、交并差、SMEMBERS与SSCAN。

  7. Sorted sets、ZRANGE:按分数排序、跳表加哈希表、BYSCORE、ZRANGEBYSCORE弃用。

  8. 延迟诊断、FAQ、redis.conf 8.10.2:基本单线程、慢命令阻塞、瓶颈在内存或网络、I/O线程。

  9. KEYS、SCAN:生产警告、游标迭代、可能重复。

  10. OBJECT ENCODING、内存优化:内部编码与阈值。

  11. 数据类型总览:官方类型列表与五种基础类型定义。

  12. Spring Data Redis(Spring 的内存数据存储访问模块):RedisTemplate及各操作接口Javadoc:opsForValue等操作视图、StringRedisTemplate。

  13. Redis(内存数据存储)许可证、8.10.2发布:Redis(内存数据存储) 8起三选一许可,7.2及更早为BSD3。

  14. JavaGuide:Redis(内存数据存储)常见问题总结(上)(第三方):类型场景、String(字符串类型)与Hash(哈希)存对象的取舍、单线程的常见解释。

  15. INCR、EXPIRE、KEYS、SCAN、ZINCRBY、SET 命令页:INCR返回1时EXPIRE及其竞态与Lua修法、保留过期时间的例子、KEYS为O(N)、SCAN零元素不代表结束、ZINCRBY、KEEPTTL。

第15课 · 第18课

学完后,用办件系统的具体场景检查自己能否讲清“为什么重新SET会让缓存永不过期”“有序集合为什么要同时用跳表和哈希表”,而不只是背出“五种类型”。记下仍不确定的地方,隔日回看时先尝试回答,再核对课程里的解释。本课末尾的课后问答记录了几处容易说错的地方。

课后问答:理解是怎样变化的

问题与结论默认可见。展开后可看理解过程;以下根据真实交流整理,并非逐字稿。

存一个办件对象时,用String(字符串类型)存JSON还是用Hash(哈希)?

最终结论: 对象大多整体读、很少改,用String(字符串类型)存JSON;经常单独修改某几个字段(例如经办人的受理数、办结数),用Hash(哈希),例如 HINCRBY 键 字段 1。

展开理解过程与易混点

纠正与解释: 关键看读写方式。整体读写时,String(字符串类型)最简单;如果每次只改一个计数也要“读出整段 → 应用里改 → 整段写回”,并发时就可能互相覆盖。Hash(哈希)可以用 HINCRBY、HSET 只改一个字段。

最终结论: 对象大多整体读、很少改,用String(字符串类型)存JSON;经常单独修改某几个字段(例如经办人的受理数、办结数),用Hash(哈希),例如 HINCRBY 键 字段 1。

现有复盘未逐条保留中间追问,本页不补写对话。

Redis(内存数据存储)一个线程照看很多连接,某个连接的数据还没到时,它是排队等这个连接,还是先做别的?

最终结论: 不等,先处理别的连接。Redis(内存数据存储)快的原因可以这样串起来:①数据在内存里,单个GET、SET很短;②更主要的是用I/O多路复用照看连接,不会卡在网络读写上;③瓶颈通常是内存或网络,很少是CPU(处理器)。

展开理解过程与易混点

最初理解(归纳): 曾认为“先来数据的任务先处理,数据没来则排队等候”:前半句接近,后半句说反了。

纠正与解释: 一个线程要照看成百上千个连接,如果某个连接的数据没到就停下来等,其他连接都会被耽误,就快不起来。I/O多路复用是同时盯着所有连接,只处理数据已经到达的那些,没到的先不管。

最终结论: 不等,先处理别的连接。Redis(内存数据存储)快的原因可以这样串起来:①数据在内存里,单个GET、SET很短;②更主要的是用I/O多路复用照看连接,不会卡在网络读写上;③瓶颈通常是内存或网络,很少是CPU(处理器)。

现有复盘未逐条保留中间追问,本页不补写对话。

为什么一条慢命令(例如KEYS)会让所有客户端都变慢?

最终结论: KEYS执行的这段时间里,其他客户端的GET、SET只能排在它后面等,这正是单线程的代价。6.0以后的I/O线程只处理网络读写和协议解析,命令仍由一个线程执行。

展开理解过程与易混点

最初理解(归纳): 曾只说到KEYS要逐个比对几百万个键、浪费时间,没有说出它对其他客户端的影响。

纠正与解释: “KEYS本身慢”只是前一半。后果来自执行模型:命令由一个线程按顺序执行,同一时刻只执行一条。

最终结论: KEYS执行的这段时间里,其他客户端的GET、SET只能排在它后面等,这正是单线程的代价。6.0以后的I/O线程只处理网络读写和协议解析,命令仍由一个线程执行。

现有复盘未逐条保留中间追问,本页不补写对话。

覆盖一个键的值、又想保留原来的过期时间,SET要加什么选项?

最终结论: 用 KEEPTTL(Redis(内存数据存储) 6.0起):SET 键 新值 KEEPTTL,覆盖值的同时保留原来的过期时间。

展开理解过程与易混点

最初理解(归纳): 曾把这个选项记成“keepalive”。

纠正与解释: keepalive是连接保活的概念,和过期时间无关。TTL(剩余存活时间)就是“剩余存活时间”,可以借此记住选项名。

最终结论: 用 KEEPTTL(Redis(内存数据存储) 6.0起):SET 键 新值 KEEPTTL,覆盖值的同时保留原来的过期时间。

现有复盘未逐条保留中间追问,本页不补写对话。

举一个会让缓存过期时间丢失的代码写法,应该怎样改?

最终结论: 刷新缓存时调用 stringRedisTemplate.opsForValue().set(key, json),没传 Duration,等于不带EX的SET:原来的10分钟过期被丢弃,TTL(剩余存活时间)变成 −1,缓存永不过期,内存只增不减。改法是每次写入都传 Duration.ofMinutes(10),或用KEEPTTL保留原过期时间。

展开理解过程与易混点

纠正与解释: SET会覆盖整个值并丢弃原来的过期时间;DEL、SET这类删除或覆盖整个值的命令会清掉过期时间,INCR、LPUSH、HSET这类只改内容的命令会保留。

最终结论: 刷新缓存时调用 stringRedisTemplate.opsForValue().set(key, json),没传 Duration,等于不带EX的SET:原来的10分钟过期被丢弃,TTL(剩余存活时间)变成 −1,缓存永不过期,内存只增不减。改法是每次写入都传 Duration.ofMinutes(10),或用KEEPTTL保留原过期时间。

现有复盘未逐条保留中间追问,本页不补写对话。

过期的键是怎么被删除的?是靠定期把所有键扫一遍吗?

最终结论: 两种方式:被动,客户端访问一个已过期的键时,Redis(内存数据存储)发现过期就删除;主动,定期从设置了过期时间的键里随机抽查一部分,删除其中已过期的。过期键可能在内存里多待一会儿,但对应用来说过期后就读不到了。

展开理解过程与易混点

最初理解(归纳): 曾认为过期删除只靠“定期扫描”。

纠正与解释: 如果定期把所有键扫一遍,就和KEYS一样会阻塞。官方说明的主动方式是随机抽查一部分带过期时间的键,另外还有访问时的被动删除。

最终结论: 两种方式:被动,客户端访问一个已过期的键时,Redis(内存数据存储)发现过期就删除;主动,定期从设置了过期时间的键里随机抽查一部分,删除其中已过期的。过期键可能在内存里多待一会儿,但对应用来说过期后就读不到了。

现有复盘未逐条保留中间追问,本页不补写对话。

统计整个库里以 case:detail: 开头的键,和取一个几十万成员的集合,分别该用什么?

最终结论: 前缀统计用 SCAN 游标 MATCH case:detail:* COUNT 数量,不用KEYS;大集合用 SSCAN 集合键 游标,不用SMEMBERS。用法相同:从游标0开始,返回的游标为0才算结束,某一批返回空不代表结束;同一个元素可能返回多次,应用要能处理重复。

展开理解过程与易混点

最初理解(归纳): 曾把这两个问题合在一起,都答成“用SSCAN”。

纠正与解释: SCAN遍历的是整个库的键;SSCAN遍历的是某一个Set(集合)内部的成员;同一系列还有HSCAN(哈希的字段)、ZSCAN(有序集合的成员)。

最终结论: 前缀统计用 SCAN 游标 MATCH case:detail:* COUNT 数量,不用KEYS;大集合用 SSCAN 集合键 游标,不用SMEMBERS。用法相同:从游标0开始,返回的游标为0才算结束,某一批返回空不代表结束;同一个元素可能返回多次,应用要能处理重复。

现有复盘未逐条保留中间追问,本页不补写对话。

介绍Sorted Set(有序集合)时,应该先说什么?

最终结论: 先说它对外是什么:每个成员带一个分数,按分数排序,适合排行榜、按期限取任务(ZRANGE 键 0 现在时间戳 BYSCORE);再说底层:官方说明由跳表和哈希表组成,添加是O(log N)。

展开理解过程与易混点

最初理解(归纳): 曾直接用“跳表加哈希表”介绍有序集合,只说了底层结构。

纠正与解释: 底层结构是追问时才需要展开的内容;先说清对外是什么、能做什么,再说底层。

最终结论: 先说它对外是什么:每个成员带一个分数,按分数排序,适合排行榜、按期限取任务(ZRANGE 键 0 现在时间戳 BYSCORE);再说底层:官方说明由跳表和哈希表组成,添加是O(log N)。

现有复盘未逐条保留中间追问,本页不补写对话。