qinghuan
← 数据系统 / 全部笔记

单元 13 / 数据系统

P0 · 必会主线L0 · 零基础

Redis 数据结构入门:从需要的操作反推存储方式

从会话、点赞和排行榜要做的操作出发,选择 String、Hash、Set 或 Sorted Set,并限制单个键的规模。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能为会话、点赞和排行榜选择结构,并用最小数据验证命令行为。

学习理由、准备要求与知识点

为什么学它

Redis 的数据类型直接决定可执行的操作、查询成本和后续缓存与秒杀设计。

先读这些内容

知识点清单

  • String
  • Hash
  • List
  • Set
  • Sorted Set
  • Stream
  • 成员唯一性
  • 键命名与类型

运行前先确认Redis7.4、Java17;Jedis源码固定v5.2.0。示例沿用Redis环境篇的独立实例,宿主端口16379;命令进入redis-cli交互模式后执行。算法只接受ASCII成员与有限整数分数。

01先弄懂它是什么

选 Redis 类型时,先列出要做的操作。会话需要凭令牌读取少量用户信息,可以用 String 存序列化对象,也可以用 Hash 分字段存;点赞要去重并检查某人是否点过,Set 更直接;排行榜要改分数并按分数读取,Sorted Set 更合适。不要一开始就把整个 Java 对象塞进去,再想怎么查。

一个 Redis 键对应一种值类型,Hash 字段或 Set 成员并不是关系数据库里独立的一行。HGETALL 会取出整个 Hash,SMEMBERS 会取出整个 Set;内容太大时,服务端、网络和客户端内存都会增加负担。给键写清业务、对象编号和用途,也要约定最多放多少成员、字段多大,以及什么时候删除。

Redis 的 String 也能保存字节数据;使用整数命令时,存进去的内容还得符合命令的数字格式。Sorted Set 中每个成员只有一份,但不同成员可以同分,给已有成员改分数不会再新增一份。分数用双精度浮点数保存,能精确表示的整数范围有限;不能把任意长整数,或拼起来的时间和 ID,直接当分数并认定不会失真。同分成员按字节顺序排列,也不是自动按数字 ID 大小排列。

文中这几个词,先认一认

Key类型
一个键当前保存的数据结构;拿 Hash 命令操作 String 等类型不匹配时会报 WRONGTYPE。
成员唯一
Set 和 Sorted Set 按成员本身去重;Sorted Set 可以有多个成员使用相同分数。
操作驱动建模
先列查询、修改、去重、排序和数据量需求,再决定用什么结构保存。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

先完成Redis起步篇,确认独立实例PING返回PONG。若使用已命名容器,docker exec -it qh-redis-study redis-cli可进入容器内客户端;宿主redis-cli则连接127.0.0.1:16379,实际容器名与端口以环境篇为准。进入后用TYPE检查键类型,避免把旧实验键当成新的结构直接操作。

会话示例可用HSET study:session:demo id 101 name learner,再HGET读取字段;这是无真实认证能力的结构练习,正式登录令牌必须安全生成。点赞用SADD study:likes:note:1 101与SISMEMBER观察重复成员。

排行榜用ZADD study:rank 10 alice 10 bob 20 carol,再ZRANGE study:rank 0 2 REV WITHSCORES观察分数与同分次序。

结构选择还要看一致性边界。给Set加成员和给另一Key计数是两次操作,不能因为都在Redis就自动原子一致。排行榜分数更新与持久化业务积分也可能跨系统。先选权威来源,再决定派生统计如何修复;结构提供的单命令原子性并不等于整个业务用例已完整保护。

正在加载流程图…

选择结构后仍需定义容量、过期和跨键操作边界,不能只追求命令短。
查看流程图源码(Mermaid)
flowchart TD
 A["需要的操作"] --> B{"主要语义"}
 B --> C["整值读取:String"]
 B --> D["字段访问:Hash"]
 B --> E["成员去重:Set"]
 B --> F["分数与排名:Sorted Set"]
 C --> G["容量与生命周期"]
 D --> G
 E --> G
 F --> G
 G --> H["跨键及权威数据一致性"]

按顺序看,每一步做了什么

  1. 确认独立Redis环境和实际连接地址。
  2. 按用例列出读、写、去重和排序操作。
  3. 用小数据执行命令并查看TYPE及返回值。
  4. 设置键名、成员大小、容量和TTL规则。
  5. 检查跨键和跨数据库更新是否需要额外协议。

03对照实现代码

UnifiedJedis命令委派与返回值边界 (新标签页)↗

Jedis v5.2.0 · UnifiedJedis#hset、#zadd、#zrank

原创Java命令模型,非源码节选。真实类构建命令对象并交给执行器;本例只表现成员和分数是独立参数,不实现Redis服务端结构。

核心机制代码 · java
import java.util.*;
class SourceModel {
    record Command(String name,List<String> args) {}
    static Command zadd(String key,double score,String member) {
        if (!Double.isFinite(score)) throw new IllegalArgumentException("finite score");
        // 教学应用主动限制有限分数,真实Redis选项与范围需看命令规范。
        List<String> args=List.of(key,Double.toString(score),member);
        // 分数与成员分开编码,成员身份不能由分数代替。
        return new Command("ZADD",args);
    }
    // 构建成功只形成请求,不表示服务端已经接收和执行。
}

UnifiedJedis的hset、zadd等入口委派给commandObjects和executeCommand,数据结构实际由Redis服务端处理。

Java方法的返回类型反映命令结果,例如ZADD常返回新增成员计数;更新已有成员与新建成员不能混为一谈,选项会影响返回语义。

String与byte[]重载体现编码边界。Redis同分成员排序依据字节,不保证等于任意Java字符串比较器的字符顺序。

客户端调用方便不代表可以无界读取。HGETALL、成员全量列表或排名范围仍需应用控制结果规模与超时。

代码先调用谁,再调用谁

  1. 应用调用hset或zadd
  2. commandObjects构建命令
  3. executeCommand选择执行器
  4. 连接发送协议
  5. 读取Redis返回值

04动手写一个例子

双索引维护唯一成员排行榜

实现容量有限的单线程排行榜:成员名仅ASCII字母数字、下划线或横线,分数为±十亿内整数。更新分数不增加成员;按分数降序,同分按成员名降序,读取前K名。此限制让成员顺序与Redis的字节序一致。

输入 1
alice=10、bob=10、carol=20
输出 1
carol、bob、alice
输入 2
alice更新为30
输出 2
alice、carol、bob,成员仍3个

用Map按成员定位旧分数,用有序集合按分数和成员排序。两种访问需求对应两个索引,不能只用分数作集合比较,否则同分成员会被误判成同一项。

更新先移除旧的有序条目,再写新分数并加入新条目,保持两份索引一致。只有新成员占用新增容量,满容量时更新现有成员仍允许。

读取从降序比较器定义的集合头部迭代,最多取K项。成员仅ASCII使Java比较与Redis字节比较一致;一般Unicode成员需要按实际编码字节比较,不能无说明复用本题比较器。

为什么这样写是对的Map和有序集合成员完全一致,每个成员恰好对应一个当前分数;集合按分数和同分成员的双重顺序排列。

时间 更新O(log N),读取O(K)额外空间 O(N)

Map定位均摊常数,树集合删除与插入为对数;读取最多K项,保存两份线性索引。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 58 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    record Entry(String member,long score) {}
    static class Board {
        final int capacity;
        final Map<String,Long> scores=new HashMap<>();
        final TreeSet<Entry> order=new TreeSet<>(
            Comparator.comparingLong(Entry::score).reversed()
                .thenComparing(Entry::member,Comparator.reverseOrder()));
        Board(int capacity) {
            if (capacity<1) throw new IllegalArgumentException("capacity");
            this.capacity=capacity;
        }
        void put(String member,long score) {
            if (member==null||!member.matches("[A-Za-z0-9_-]+")||
                    score < -1_000_000_000L||score > 1_000_000_000L)
                throw new IllegalArgumentException("value");
            Long old=scores.get(member);
            if (old==null&&scores.size()==capacity) throw new IllegalStateException("full");
            // 更新前移除旧排序项,防止同一成员出现两个分数。
            if (old!=null) order.remove(new Entry(member,old));
            scores.put(member,score);
            order.add(new Entry(member,score));
        }
        void remove(String member) {
            Long old=scores.remove(member);
            // 删除同时维护两个索引;不存在成员不产生新条目。
            if (old!=null) order.remove(new Entry(member,old));
        }
        List<String> top(int k) {
            if (k<0) throw new IllegalArgumentException("k");
            List<String> result=new ArrayList<>();
            for (Entry e:order) {
                if (result.size()==k) break;
                result.add(e.member()); // 迭代顺序已经包含同分规则。
            }
            return List.copyOf(result);
        }
    }
    static void eq(Object expected,Object actual) {
        if (!Objects.equals(expected,actual)) throw new AssertionError(actual);
    }
    public static void main(String[] args) {
        Board b=new Board(3);
        b.put("alice",10); b.put("bob",10); b.put("carol",20);
        eq(List.of("carol","bob","alice"),b.top(5));
        b.put("alice",30);
        eq(List.of("alice","carol","bob"),b.top(3)); eq(3,b.scores.size());
        b.put("bob",10); eq(3,b.order.size());
        try { b.put("dave",40); throw new AssertionError("capacity"); }
        catch (IllegalStateException expected) {}
        b.remove("carol"); eq(List.of("alice","bob"),b.top(3));
        eq(List.of(),b.top(0));
        try { b.put("中文",1); throw new AssertionError("encoding boundary"); }
        catch (IllegalArgumentException expected) {}
        System.out.println("PASS 8 leaderboard tests");
    }
}

这些特殊情况也要试一试

  • 相同分数允许多个成员,比较器必须包含成员身份。
  • 容量满时可更新旧成员,新增成员拒绝。
  • K为零返回空;大于成员数返回全部,Unicode输入按边界拒绝。
进阶选读 · 跨分片全局排行榜与分数精度

场景多个分片各有排名,需要查询全局前K并处理高精度业务分数。

做法从每个分片读取局部前K,再按一致比较规则合并;高精度分数选择明确缩放、范围检查或独立精确存储,不能随意压入double。

复杂度P个分片候选最多PK项,堆合并约O(K log P),网络与快照成本另计。

好处与代价各分片读取时点不同可能导致非同一快照的全局排名,需声明一致性与更新可见性。

05项目里怎么用

键名也属于数据设计,宜包含业务前缀、对象编号和用途,别让同一键先后混用不同类型。为 Hash、Set 和排行限定数量,过大时采用分页或扫描。SCAN 一类扫描不是冻结快照,扫描期间数据变化可能影响结果,也要处理重复返回等规则。

会话Hash不要存完整用户实体和敏感字段,保留业务需要的最小DTO并设置生命周期。普通HSET修改字段通常保留Key现有TTL,但不会自动为新键提供过期;是否使用字段过期等新能力要与明确Redis版本和命令支持对应。

点赞数与成员列表要说明以哪份数据为准。若计数由列表推导,就应能按列表或数据库重建;若积分由数据库决定,Redis 排行只是方便查询的副本,更新失败后要能重放或重建。某条命令执行很快,不等于多份数据已经保持一致。

容易踩的坑

  • 分数作唯一身份导致同分成员丢失。
  • 把长整数直接转double并假定任意值精确。
  • 无界使用HGETALL或SMEMBERS读取巨大结构。
  • 两个Key分别更新却宣称整组操作原子一致。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
String序列化对象整值读写直观。局部更新需重写且可能冲突。整体读取的小快照。
Hash字段结构按字段读取与修改。要维护字段类型和生命周期。小型对象属性。
Sorted Set成员唯一并支持分数排序。需要分数精度与容量约束。榜单和有序成员查询。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01Sorted Set成员能同分吗?

展开参考答案与推导

能,成员唯一而分数可以相同,同分再按成员字节顺序排列。

02HSET会自动创建登录TTL吗?

展开参考答案与推导

不会自动给新Key设置预期有效期,应明确过期操作。

03Key类型选定后就解决跨库一致性了吗?

展开参考答案与推导

没有,数据结构只提供相应单命令能力,跨系统仍需协议。

换个情况,还会处理吗

01同分排行只剩一个人怎么办?

展开参考答案与推导

检查自写比较器是否只比较分数,需要加入唯一成员次序。

02大Hash查询引起延迟怎么办?

展开参考答案与推导

检查完整读取规模,按字段读取或分批处理,并限制结构容量。

03积分落库成功而排行更新失败怎么办?

展开参考答案与推导

以权威积分为准,可靠重放更新或重建排行,不直接改写已确认业务结果。

写代码试一试

01怎样验证改分不增加成员?

输入 / 条件
alice10改为30
输出 / 预期
成员数不变,顺序改变

提示 删除旧有序条目。

展开参考答案与推导

同时断言Map与TreeSet大小以及最终排名。

02满容量时旧成员更新能通过吗?

输入 / 条件
容量3已满,更新alice
输出 / 预期
允许

提示 区分新增和更新。

展开参考答案与推导

只有old为空的新成员才检查额外容量。

03支持任意Unicode成员需改哪里?

输入 / 条件
含补充字符与普通汉字
输出 / 预期
按Redis实际字节顺序

提示 UTF-16比较不一定等于UTF-8字节序。

展开参考答案与推导

按明确编码生成字节并做无符号字节字典序比较,增加边界用例。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能为会话、点赞和排行榜选择结构,并用最小数据验证命令行为。

本篇重点加练

事件流与成员唯一性分别表达变化和当前身份,需要防止把结构特性误当作业务去重。

  • Stream
  • 成员唯一性

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    用户资料包含昵称、版本号与头像,另有一次性下载凭证。为两类数据比较 String 与 Hash 表示的读写原子粒度和更新代价。

    提交内容
    模型对照表
    怎样算完成
    • 列出实际读取和更新操作
    • 说明缺字段与空值口径

    练到的知识点String · Hash

  2. 作业 02代码推演

    任务候选人需要保持加入顺序,而活动报名需要判断某人是否已参加。构造重复加入、退出和再次加入的序列,比较 List 与 Set 的状态。

    提交内容
    操作序列和状态表
    怎样算完成
    • 区分顺序与成员唯一性
    • 包含空结构行为

    练到的知识点List · Set

  3. 作业 03面试场景

    两个模块都使用 user:42 键,一个写 Hash,另一个写 String。设计键命名登记表和类型检查,说明版本升级时如何识别旧格式。

    提交内容
    命名规范与冲突用例
    怎样算完成
    • 包含环境及用途边界
    • 成员身份规则明确

    练到的知识点成员唯一性 · 键命名与类型

  4. 作业 04原理问答

    同一商品以两个别名进入集合,字符串不同但业务上是同一商品。设计成员规范化契约,说明结构去重与实体去重的不同证据。

    提交内容
    成员身份及规范化规则
    怎样算完成
    • 覆盖别名迁移和大小写
    • 规范化冲突有处理口径

    练到的知识点成员唯一性

综合应用

  1. 作业 05设计评审

    排行榜要求按分数查询前十,审计日志要求保存每次积分变动供不同处理者读取。评审 Sorted Set 和 Stream 分别适合保存什么事实。

    提交内容
    数据流与字段契约
    怎样算完成
    • 区分当前分数和变动事件
    • 覆盖重复事件身份

    练到的知识点Sorted Set · Stream

  2. 作业 06编程实践

    在本地 Redis 建立小型读书活动数据:资料、待处理任务、报名、积分榜和审计流;为每类操作准备重复及非法输入。

    提交内容
    命令脚本与结构选择说明
    怎样算完成
    • 实测各键类型
    • 注明跨结构操作是否有共同原子边界

    练到的知识点String · Hash · List · Set · Sorted Set · Stream · 成员唯一性 · 键命名与类型

  3. 作业 07设计评审

    积分事件进入 Stream 后需要支持审计与投影重建,但运营只想保留最新分数。评审裁剪事件与保存状态各能支持的后续查询。

    提交内容
    查询能力与保留矩阵
    怎样算完成
    • 包含删除及更正事件
    • 区分当前状态和完整变化过程

    练到的知识点Stream

  4. 作业 08编程实践

    用本地 Redis 或明确标注的 Java 模型实现别名商品的事件导入,分别记录事件身份和实体身份,测试别名变更后重放的效果。

    提交内容
    代码和身份对账报告
    怎样算完成
    • 输入规模受限
    • 模拟时说明不覆盖真实 Stream 投递协议

    练到的知识点Stream · 成员唯一性

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    一次业务升级将积分规则从整数改为含小数,历史审计必须保留。制定数据格式版本与迁移校验方案。

    提交内容
    迁移计划及对账口径
    怎样算完成
    • 覆盖旧新客户端并存
    • 不丢失成员身份和历史变动来源

    练到的知识点Hash · Sorted Set · Stream · 成员唯一性 · 键命名与类型

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 跨域迁移

    把 Redis 成员建模迁移到多来源文档库,相同内容可能有不同 URL,URL 内容也会变化。如何重新定义事件、文档和版本的唯一性?

    提交内容
    跨域身份模型
    怎样算完成
    • 包含内容变化与别名
    • 避免用单一字符串承担全部身份

    练到的知识点Stream · 成员唯一性

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗