Redis 学习笔记(1)
一、Redis 数据类型
1.1 GEO(地理空间)
GEO 是 Redis 中专门处理地理空间位置信息的数据类型,可理解为“带了地理坐标的 Sorted Set”。
底层原理:复用 Sorted Set
- 每个地理位置作为一个 member
- 它的 score 是一个 52 位的 Geohash 整数
- Geohash 把二维经纬度编码成一维整数,从而利用 Sorted Set 按 score 排序和范围查询的特性,快速找到附近位置
核心命令:
| 命令 | 作用 |
|---|---|
GEOADD key 经度 纬度 成员名 | 添加地理位置 |
GEOPOS key 成员名 | 查询成员经纬度 |
GEODIST key 成员1 成员2 [单位] | 计算两位置距离(m/km/mi/ft) |
GEOSEARCH key FROMMEMBER 成员名 BYRADIUS 半径 单位 | 查找某位置周围指定半径内的其他位置 |
GEOSEARCH ... BYBOX | 按矩形区域查找 |
典型场景: 打车找附近车辆、外卖找附近餐厅、社交“附近的人”、共享单车找附近单车。
注意点:
- 精度约厘米级,多数 LBS 应用够用
- 删除用
ZREM - 数据量极大时(如全国车辆)应按城市/区域分 key,避免单 key 过大
GEODIST默认单位米,算的是地球球面距离
与其他类型区别: Bitmap、HyperLogLog 是为了省内存做统计,GEO 是为了做空间索引和查询,解决“谁在谁附近”。
二、Redis 6.0 多线程模型
2.1 为什么长期单线程
Redis 核心操作是内存读写,速度极快,瓶颈通常在网络 I/O 和内存,而非 CPU。单线程好处:
- 无锁竞争,无死锁、竞态条件
- 实现简单,维护成本低
- 命令串行执行,天然保证原子性
2.2 6.0 的多线程“多”在哪
一次请求完整流程:
- 接收连接、读取 socket 数据(网络 I/O)
- 解析客户端命令(协议解析)
- 执行命令(操作内存数据)
- 把结果写回 socket(网络 I/O)
Redis 6.0 多线程用在第 1、2、4 步,第 3 步仍是单线程串行。
准确描述:I/O 多线程 + 命令执行单线程。
2.3 为什么不用担心线程安全
| 阶段 | 是否多线程 | 说明 |
|---|---|---|
| 网络读取、协议解析 | ✅ 多线程 | 多个 I/O 线程并行处理不同客户端 |
| 命令执行、内存读写 | ❌ 单线程 | 主线程串行执行,保证原子性和一致性 |
| 结果写回 socket | ✅ 多线程 | 多个 I/O 线程并行写回不同客户端 |
- 命令执行阶段单线程,不存在两线程同时改同一 key
- I/O 线程只处理各自独立的 socket,不共享内存数据结构
- 多个客户端同时发命令不会乱序:I/O 线程解析后放入队列,主线程按顺序取执行,结果再交 I/O 线程写回
- 默认
io-threads为 1,即默认仍是单线程;官方建议非网络 I/O 瓶颈不必开
一句话: 多线程只是把“网络搬运”并行化,真正碰数据的核心环节还是单线程,所以不用操心线程安全问题。
三、Redis 事务
3.1 与传统事务的区别
我们通常理解的原子性来自关系型数据库 ACID:事务中所有操作要么全部成功,要么全部失败。
Redis 官方称事务是“原子操作”,但含义是:事务中的命令要么全部被执行,要么全部不执行(针对命令是否被提交执行,而非执行后是否全部成功)。一旦 EXEC 被调用,队列中所有命令依次执行,中间出错也不停止。从 ACID 严格标准看,Redis 事务不满足原子性。
3.2 两种错误,两种结果
(1)入队错误(EXEC 之前)
命令放入队列时就能发现语法错误(拼写错误、参数数量不对)或内存不足。此时 Redis 拒绝执行整个事务,清空队列,所有命令都不执行。符合“要么全部不执行”。
(2)运行时错误(EXEC 之后)
命令已成功入队,EXEC 执行时才发现错误。典型例子:对字符串类型 key 执行 LPOP。此时出错命令失败,但事务中其他命令依然正常执行,不会停止,更不会回滚。这正是“不满足原子性”的关键。
3.3 为何刻意不支持回滚
官方解释:支持回滚会极大增加复杂度并严重影响性能,与 Redis 简单高效的设计初衷相违背。
更深层原因:Redis 认为运行时错误本质是编程错误,应在开发测试阶段发现修复,不应出现在生产环境。回滚只能撤销操作,不能修正错误意图。因此 Redis 选择更简单快速的方式,把正确性责任交还开发者。
3.4 对比总结
| 特性 | Redis 事务 | 传统关系型数据库事务 |
|---|---|---|
| 原子性定义 | 命令要么全部被执行,要么全部不执行 | 操作要么全部成功,要么全部失败 |
| 运行时错误处理 | 出错命令失败,其他命令继续执行 | 整个事务回滚,所有操作撤销 |
| 回滚支持 | 不支持 | 支持 |
| 设计哲学 | 简单、快速,将错误暴露在开发阶段 | 强一致性,保证数据绝对可靠 |
四、原子性问题的解决方案
4.1 方案对比
| 方案 | 核心原理 | 适用场景 | 能否解决“部分执行”问题 |
|---|---|---|---|
| Lua 脚本 | 服务端原子执行,无其他命令插入 | 读+写逻辑,依赖前序结果 | ✅ 能,命令逻辑在脚本内闭环 |
| WATCH 乐观锁 | 监视键,若被修改则事务失败 | 简单 check-and-set 场景 | ⚠️ 部分,仅防止外部并发修改 |
4.2 Lua 脚本(首选)
Lua 脚本在 Redis 中原子性执行,运行期间不会有其他命令插入。更关键的是,它解决了 MULTI/EXEC 的短板:读操作结果无法用于后续写操作。脚本里可自由把读取值存入临时变量,判断后再写入,整个“读-判断-写”流程密不可分。
转账示例:
-- KEYS[1]=账户A, KEYS[2]=账户B, ARGV[1]=转账金额
local balance_a = tonumber(redis.call('GET', KEYS[1]))
local amount = tonumber(ARGV[1])
if balance_a >= amount then
redis.call('DECRBY', KEYS[1], amount)
redis.call('INCRBY', KEYS[2], amount)
return 1 -- 成功
else
return 0 -- 余额不足
end
注意: Lua 脚本依然不支持回滚。若脚本前几条成功、后几条因逻辑错误失败,已执行命令不会撤销,逻辑正确性需开发阶段保证。
4.3 WATCH 乐观锁(补充)
WATCH 在事务开始前监视某些键,若这些键在 EXEC 前被其他客户端修改,整个事务中止,一条命令都不执行。
局限: 只能保证“键被改了,就整个不执行”,无法解决“事务内部命令因逻辑错误部分执行”的问题。适合简单 check-and-set 场景。
4.4 关键区别:Lua vs MULTI/EXEC
| 对比项 | MULTI/EXEC | Lua 脚本 |
|---|---|---|
| 执行过程不被其他客户端打断 | ✅ | ✅ |
| 出错后回滚已执行命令 | ❌ | ❌ |
| 命令之间可引用前序结果 | ❌ | ✅ |
| 能实现“读-判断-写”逻辑闭环 | ❌ | ✅ |
一句话总结: 两者都保证“执行过程不被打断”,但 Lua 脚本额外保证“命令之间的逻辑连贯性”。MULTI/EXEC 只能做到“批量命令不被插队”,Lua 脚本才能把“检查余额”和“扣款”写成一个不可分割的逻辑单元。
五、Redis Cluster
5.1 槽位(Slot)基础
5.1.1 槽位数量是确定的
Redis Cluster 整个集群共有 16384 个槽位,硬编码在源码中的常量。作者 Salvatore Sanfilippo 权衡多方面因素后确定:
(1)心跳包大小的权衡
每个节点通过心跳包(PING/PONG)交换集群状态,包含表示“当前节点持有哪些槽位”的位图:
- 16384 个槽位 → 位图仅 2KB
- 65536 个槽位 → 位图膨胀到 8KB,显著增加心跳包大小,浪费带宽
(2)集群规模的限制
官方建议主节点数量最好不要超过 1000 个。此规模内,16384 个槽位足以保证每个主节点分配到足够槽位,实现较好负载均衡。
(3)位图压缩的效率
槽位信息以位图存储和传输,可被压缩。槽位过多(如 65536)而节点较少时,位图填充率低,压缩效率不高。16384 在集群规模不大时压缩效果更好。
总结:16384 是在网络开销、集群规模和数据分布粒度之间取得的精妙平衡点。
5.1.2 分片越多,每片槽位越少
总槽位固定 16384 的前提下:
平均每个分片负责的槽位数 = 16384 / 主节点数量
| 主节点数量 | 平均每个节点负责的槽位数 |
|---|---|
| 3 | 约 5461 |
| 10 | 约 1638 |
| 100 | 约 164 |
| 1000 | 约 16 |
重要前提与注意事项:
- 这是“平均”值,实际分配可不均匀:Redis 允许槽位分配不均匀,如 3 个主节点可以是
8000 / 8000 / 384 - 分片数不能超过 16384:每个主节点至少负责 1 个槽位,理论上限 16384 个主节点,官方建议不超过 1000
- 槽位太少会带来问题:
- 数据分布粒度变粗:槽位是数据分配的最小单位,槽位少则单槽数据量大,迁移时单次移动数据变多
- 管理开销上升:节点越多,心跳通信、Gossip 协议开销越大
- 集群复杂度与运维成本显著上升
- 分片多 ≠ 性能一定好:分片增加可提升并发能力和内存容量上限,但过多会带来:
- 客户端连接数增多
- 跨节点请求(多 key 操作、事务)更易失败
- 集群元数据同步开销增大
结论:分片越多,每个分片负责的槽位越少,数学上成立,但实际部署需综合权衡,不能盲目追求更多分片。
5.2 槽位的作用与本质
5.2.1 槽位的本质
槽位是一个哈希空间的分区编号,范围固定 0 ~ 16383。
对每个 key 计算:
slot = CRC16(key) mod 16384
注意:用的是 CRC16,不是一致性哈希。结果一定落在 0~16383 之间。
5.2.2 槽位解决的核心问题
当集群有多个节点时,客户端怎么知道某个 key 该去哪个节点存取?
槽位是 key 和节点之间的“中间层”,把映射变成两级:
key → slot(固定算法,永不改变)
slot → node(由集群动态分配,可调整)
- 第一级:key 到 slot 的映射是纯数学计算,与节点数量无关,永远稳定
- 第二级:slot 到 node 的分配由集群管理,扩容缩容时只需移动槽位,而非重新计算所有 key
核心价值:把“数据分布”和“节点数量”解耦。
5.2.3 直观比喻
把 16384 个槽位想象成 16384 个编号固定的信箱,节点就是邮递员:
- 每个 key 通过 CRC16 算出该投哪个编号的信箱,规则永远不变
- 哪个邮递员负责哪些信箱,可以随时调整
- 扩容时,只需把一部分信箱连信带箱交给新邮递员,其他信箱不动
5.3 集群元数据:slot → node 映射
每个节点都保存一份集群状态视图,核心是一张槽位分配表:
slot 0 → node A
slot 1 → node A
...
slot 5460 → node A
slot 5461 → node B
...
slot 10922 → node B
slot 10923 → node C
...
slot 16383 → node C
这份信息通过 Gossip 协议在节点间不断传播、收敛,保证每个节点最终都知道“哪个槽在哪个节点上”。
5.4 一次数据存取的完整链路
以 SET user:1001 "tom" 为例:
阶段 1:客户端计算槽位
客户端(Jedis、Lettuce、redis-cli)本地计算:
CRC16("user:1001") mod 16384 = 假设得到 8000
阶段 2:客户端查路由表
客户端本地缓存 slot → node 映射表(首次连接时从集群拉取),查到:
slot 8000 → node B (假设 192.168.1.3:6379)
阶段 3:客户端直接发请求给目标节点
客户端直接连接 node B 发送 SET user:1001 "tom"。
关键点:Redis Cluster 是去中心化的,没有代理层。客户端自己算路由,直接连目标节点,不像 Codis/Twemproxy 那样经过代理转发。
阶段 4:节点校验槽位归属
node B 收到请求后自己也算一遍,查自己的槽位表:slot 8000 是否归我管?
- 是 → 正常执行,写入数据,返回
OK - 不是 → 返回 MOVED 重定向错误:
-MOVED 8000 192.168.1.4:6379
意思是:“这个槽现在归 192.168.1.4 管,你去找它。”
阶段 5:客户端处理重定向
收到 MOVED 后:
- 客户端更新本地路由表:slot 8000 → 192.168.1.4
- 重新向新节点发送请求
- 后续访问 slot 8000 的 key,直接去新节点
阶段 6:数据真正落盘
node B 确认自己负责 slot 8000 后,把 user:1001 写入内存(以及持久化机制 RDB/AOF)。
5.5 特殊重定向情况
| 情况 | 含义 | 客户端行为 |
|---|---|---|
| MOVED | 槽位永久迁移到了别的节点 | 更新路由表,以后都去新节点 |
| ASK | 槽位正在迁移中,该 key 恰好已迁到目标节点 | 本次临时去目标节点,不更新路由表 |
| CLUSTERDOWN | 集群不可用(如槽位未全覆盖) | 报错,等待集群恢复 |
MOVED 与 ASK 的区别(高频考点):
- MOVED 是“永久搬家”,路由表要改
- ASK 是“临时借住”,只是迁移过程中的过渡状态,路由表不改
5.6 多 key 操作的坑
槽位机制带来限制:只有落在同一个槽位的 key,才能做多 key 操作(如 MGET、事务、Lua 脚本)。
例如:
MGET user:1001 user:1002
若两个 key 槽位不同,报错:
-CROSSSLOT Keys in request don't hash to the same slot
解决办法:hash tag
用 {} 包裹的部分才参与 CRC16 计算:
MGET user:{1001}:name user:{1001}:age
只有 1001 参与计算,两个 key 一定在同一槽位。
5.7 Redis Cluster 下 Lua 脚本的原子性
在集群模式下,Lua 脚本的原子性只对同一个槽内的 key 有效。一旦涉及多个槽,脚本根本无法执行,自然谈不上原子性。
原因: 数据被分散到不同节点,跨节点的原子操作无法通过单个 Lua 脚本实现。若脚本操作的 key 分属不同槽,Redis Cluster 直接拒绝执行并返回:
ERR eval/evalsha command keys must be in same slot
或
CROSSSLOT Keys in request don't hash to the same slot
解决方案:Hash Tag
用 {} 包裹 key 的一部分,只有 {} 内的内容参与哈希槽计算:
user:{1001}:profile
user:{1001}:settings
order:{1001}:items
这三个 key 的哈希计算都只基于 1001,会被分配到同一个槽,落在同一节点上,Lua 脚本即可正常操作并保持原子性。
总结:
| 场景 | Lua 脚本原子性 | 原因 |
|---|---|---|
| 单机 Redis | ✅ 完整保证 | 所有 key 都在同一节点 |
| 集群 + 同槽 key | ✅ 保证 | 所有 key 落在同一槽,由同一节点处理 |
| 集群 + 跨槽 key | ❌ 无法执行 | 数据分散在不同节点,无法跨节点原子操作 |
5.8 整体链路总结图
客户端
│
│ 1. slot = CRC16(key) mod 16384
▼
本地路由表 (slot → node)
│
│ 2. 查到目标节点,直连发送
▼
目标节点
│
│ 3. 自己再算一遍 slot,校验归属
├── 归属自己 ──→ 执行读写,返回结果
│
└── 不归自己 ──→ 返回 MOVED / ASK
│
│ 4. 客户端更新路由表(MOVED)
│ 或临时改道(ASK)
▼
重新请求正确节点
1851



被折叠的 条评论
为什么被折叠?



