vault backup: 2026-05-24 21:18:14
This commit is contained in:
@@ -250,8 +250,8 @@ flowchart TB
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/02-基础数据结构]] — String / Hash / List / Set / ZSet 的使用场景
|
||||
- [[hhs/Redis/02-核心数据类型]] — String / Hash / List / Set / ZSet 的使用场景
|
||||
- [[hhs/Redis/04-RDB持久化]] — RDB 快照原理与配置
|
||||
- [[hhs/Redis/05-AOF持久化]] — AOF 重写机制与 fsync 策略
|
||||
- [[hhs/Redis/07-集群方案]] — Cluster 部署与迁移策略
|
||||
- [[hhs/Redis/08-常见问题排查]] — OOM、延迟 spike、连接数爆满的诊断思路
|
||||
- [[hhs/Redis/11-运维与性能调优]] — OOM、延迟 spike、连接数爆满的诊断思路
|
||||
+5
-42
@@ -181,49 +181,12 @@ ls /var/lib/redis/appendonlydir/
|
||||
|
||||
## 混合持久化(Hybrid Persistence)
|
||||
|
||||
混合持久化是 Redis 4.0 引入、Redis 7 默认开启的特性,它解决了 AOF 重写的一个尴尬问题:**重写时到底该生成 RDB 还是 AOF?**
|
||||
|
||||
> [!question] 纯 AOF 重写的痛点
|
||||
> 纯 AOF 重写需要把每个 key 的当前状态都转成 SET/HSET 等命令写入文件。当实例有几十万个 key 时,这些命令序列化+解析的过程会显著拖慢恢复速度。而 RDB 是二进制格式,加载速度比逐条执行 AOF 命令快 10~100 倍。
|
||||
> [!tip] 混合持久化是 AOF 重写的"终极形态"
|
||||
> Redis 4.0 引入、Redis 7 默认开启。核心思路:AOF **重写**时先写 RDB 全量快照作为"底子",再追加增量 AOF 命令作为"补丁"——恢复时先加载 RDB(秒级),再回放少量增量 AOF(补齐数据)。
|
||||
>
|
||||
> 混合持久化的答案很简单:**两种都用**。重写时先写 RDB 全量快照作为"底子",再追加一小段 AOF 增量命令作为"补丁"。
|
||||
|
||||
```conf
|
||||
# redis.conf
|
||||
aof-use-rdb-preamble yes # 开启混合持久化(默认值)
|
||||
```
|
||||
|
||||
重写后生成的文件结构如下:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph NEW_AOF["重写后的新 AOF 文件"]
|
||||
direction LR
|
||||
RDB["RDB 二进制块<br/>(全量数据快照)"] --> AOF["AOF 增量块<br/>(仅重写期间的新写操作)"]
|
||||
end
|
||||
|
||||
Fork["fork 子进程"] --> RDB
|
||||
Fork --> AOF
|
||||
RDB --> Merge["合并写入临时文件"]
|
||||
AOF --> Merge
|
||||
Merge --> Rename["原子 rename 替换旧文件"]
|
||||
|
||||
style RDB fill:#bbf,stroke:#66c
|
||||
style AOF fill:#dfd,stroke:#090
|
||||
style Merge fill:#ffd,stroke:#cc0
|
||||
```
|
||||
|
||||
**好处有两个:**
|
||||
1. **恢复速度快**:启动时先加载 RDB 二进制快照(顺序读、无需解析命令语法),再回放少量 AOF 增量命令。即使 AOF 增量段有几十 MB,相比从头解析几十 GB 的纯 AOF 命令也快得多
|
||||
2. **文件体积小**:RDB 本身就是压缩的二进制格式,比等量数据的纯 AOF 文本命令小 70%~90%
|
||||
|
||||
> [!warning] Redis 7 的多文件目录结构
|
||||
> Redis 7 不再使用单个 AOF 文件,而是采用多文件目录结构:
|
||||
> - `base.rdb`:AOF 重写时生成的 RDB 全量快照
|
||||
> - `incr-*.aof`:增量 AOF 文件(记录重写之后的新写操作)
|
||||
> - `manifest.aof`:文件清单,记录哪些文件属于同一个 AOF 实例
|
||||
> **在 AOF 视角下**:重写后生成的文件不再全是文本命令,而是 `RDB 二进制基线 + AOF 增量日志` 的混合结构。好处是恢复速度快(接近纯 RDB)且数据安全性高(接近纯 AOF)。
|
||||
>
|
||||
> 恢复时 Redis 会按 manifest 加载:先加载 `base.rdb`,再按顺序回放所有 `incr-*.aof`。如果删除了 `base.rdb`,恢复退化为纯 AOF 模式,速度会大幅下降。
|
||||
> 详细的文件结构、原理图解和配置方式,参见 [[hhs/Redis/04-RDB持久化#Redis 混合持久化(推荐)|混合持久化完整章节]]。
|
||||
|
||||
## AOF vs RDB 对比
|
||||
|
||||
@@ -308,4 +271,4 @@ flowchart TD
|
||||
|
||||
- [[hhs/Redis/04-RDB持久化]] — RDB 快照机制
|
||||
- [[hhs/Redis/06-主从与哨兵]] — 哨兵监控中 AOF 状态检查
|
||||
- [[hhs/Redis/09-运维调优]] — AOF 文件膨胀治理
|
||||
- [[hhs/Redis/11-运维与性能调优]] — AOF 文件膨胀治理
|
||||
|
||||
@@ -601,6 +601,6 @@ Sentinel 方案虽然解决了"高可用"问题,但它有明确的边界。理
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/07-集群方案]] — Cluster 架构(水平扩展)
|
||||
- [[hhs/Redis/09-运维调优]] — 监控与告警配置
|
||||
- [[hhs/Redis/04-AOF持久化]] — AOF 与 RDB 结合的高可用策略
|
||||
- [[hhs/Redis/11-运维与性能调优]] — 监控与告警配置
|
||||
- [[hhs/Redis/05-AOF持久化]] — AOF 与 RDB 结合的高可用策略
|
||||
- [[hhs/EXAM/Week05]] — Docker Compose 中的部署示例
|
||||
|
||||
+24
-20
@@ -262,13 +262,16 @@ flowchart LR
|
||||
|
||||
### 故障检测三阶段
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ PFAIL 潜在失效 │ → │ FAIL 确认失效 │ → │ 选举 Leader │ → Failover
|
||||
│ (单个节点判定) │ │ (多数节点同意) │ │ (Replica 竞选) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
↓ 超时 ↓ 多数派 ↓ Raft 式投票
|
||||
cluster-node-timeout 超过半数标记 最高优先级获胜利
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["PFAIL 潜在失效<br/>单个节点判定"] -->|"超过 cluster-node-timeout"| B["FAIL 确认失效<br/>多数 Master 同意"]
|
||||
B -->|"Raft 式投票"| C["选举 Leader<br/>Replica 竞选"]
|
||||
C -->|"最高优先级获胜"| D["Failover<br/>接管 slots"]
|
||||
|
||||
style A fill:#fff3e0,stroke:#ff9800
|
||||
style B fill:#fce4ec,stroke:#e91e63
|
||||
style C fill:#e8eaf6,stroke:#3f51b5
|
||||
style D fill:#e8f5e9,stroke:#4caf50
|
||||
```
|
||||
|
||||
**详细流程:**
|
||||
@@ -376,18 +379,19 @@ let val = await cluster.get("key")
|
||||
|
||||
### 架构最佳实践
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 你的应用服务 │
|
||||
└──┬──────────┬──────────┬──────────┬──────────┘
|
||||
│ │ │ │
|
||||
┌──▼────┐ ┌──▼────┐ ┌──▼────┐ ┌──▼────┐
|
||||
│Service A│ │Service B│ │Service C│ │Service D│
|
||||
│ :7001-7 │ │ :7004-7 │ │ :7010-7 │ │ :7016-7 │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │ │
|
||||
Cluster 1 Cluster 2 Cluster 3 Cluster 4
|
||||
(独立集群) (独立集群) (独立集群) (独立集群)
|
||||
```mermaid
|
||||
flowchart TD
|
||||
App["你的应用服务"] --> SA["Service A<br/>:7001-7007"]
|
||||
App --> SB["Service B<br/>:7004-7007"]
|
||||
App --> SC["Service C<br/>:7010-7017"]
|
||||
App --> SD["Service D<br/>:7016-7023"]
|
||||
SA --> C1["Cluster 1<br/>独立集群"]
|
||||
SB --> C2["Cluster 2<br/>独立集群"]
|
||||
SC --> C3["Cluster 3<br/>独立集群"]
|
||||
SD --> C4["Cluster 4<br/>独立集群"]
|
||||
|
||||
style App fill:#e3f2fd,stroke:#1565c0
|
||||
style C1,C2,C3,C4 fill:#e8f5e9,stroke:#4caf50
|
||||
```
|
||||
|
||||
> [!IMPORTANT] 原则
|
||||
@@ -400,5 +404,5 @@ let val = await cluster.get("key")
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/06-主从与哨兵]] — Sentinel 单主高可用方案
|
||||
- [[hhs/Redis/09-运维调优]] — 监控指标与告警
|
||||
- [[hhs/Redis/11-运维与性能调优]] — 监控指标与告警
|
||||
- [[hhs/Redis/README]] — 知识索引总览
|
||||
|
||||
+18
-7
@@ -97,20 +97,28 @@ return 0 -- 被其他客户端持有
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 调用
|
||||
// Go 调用——与上方 Lua 脚本等价的分布式锁实现
|
||||
script := redis.NewScript(`
|
||||
if redis.call("get", KEYS[1]) == false then
|
||||
-- setex(key, seconds, value): ARGV[1]=过期秒数, ARGV[2]=唯一令牌
|
||||
redis.call("setex", KEYS[1], ARGV[1], ARGV[2])
|
||||
-- 尝试获取锁:SET NX EX(只有 key 不存在时才设置,原子操作)
|
||||
if redis.call("set", KEYS[1], ARGV[2], "NX", "EX", ARGV[1]) == "OK" then
|
||||
return 1 -- 成功获取
|
||||
end
|
||||
-- 已持有锁且未过期,续期(防止业务未完成锁就释放)
|
||||
if redis.call("get", KEYS[1]) == ARGV[2] then
|
||||
redis.call("expire", KEYS[1], ARGV[1])
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
return 0 -- 被其他客户端持有
|
||||
`)
|
||||
|
||||
ok, err := script.Run(ctx, c, []string{"lock:order:" + orderId},
|
||||
// KEYS[1] = lock key, ARGV[1] = TTL 秒数, ARGV[2] = 唯一令牌(如 UUID)
|
||||
ok, err := script.Run(ctx, rdb, []string{"lock:order:" + orderId},
|
||||
lockTTL, clientToken).Int()
|
||||
if ok == 1 {
|
||||
defer unlock(clientToken)
|
||||
defer func() {
|
||||
// 解锁:只有持有者才能释放(Lua 保证原子性)
|
||||
unlockScript.Run(ctx, rdb, []string{"lock:order:" + orderId}, clientToken)
|
||||
}()
|
||||
doBiz() // 临界区代码
|
||||
}
|
||||
```
|
||||
@@ -355,6 +363,9 @@ flowchart LR
|
||||
> | 任务队列、订单事件 | Stream |
|
||||
> | 海量消息、高吞吐 | Kafka/RabbitMQ |
|
||||
|
||||
> [!tip] Stream 深入阅读
|
||||
> 本节仅覆盖 Stream 基础用法。Consumer Group 内部机制、消息确认与重试(XCLAIM/XAUTOCLAIM)、Dead Letter Queue 模式、容量管理等进阶内容,详见 [[hhs/Redis/15-Stream]]。
|
||||
|
||||
## 五、Keyspace Notifications —— 键事件监听
|
||||
|
||||
### 原理与配置
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
tags: [Redis, 缓存, 数据结构, Bitmap]
|
||||
create time: 2026-05-24 10:00
|
||||
---
|
||||
|
||||
# Redis Bitmap 位图
|
||||
|
||||
## 概述
|
||||
|
||||
Bitmap 并非 Redis 的独立数据结构,而是基于 String 类型的一套位操作指令。它将一个 String 值视为一个巨大的位数组,每个 bit 只占 1 位,因此在处理大规模布尔型数据(如签到、在线状态、DAU 统计)时,内存消耗极低。一个包含 10 亿用户的 Bitmap 仅需约 125MB,这使其成为高并发场景下的利器。
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 核心原理
|
||||
|
||||
> [!question] 为什么 Bitmap 不是一种新数据结构?
|
||||
> 因为 Bitmap 本质上就是 String。Redis 的 String 底层是 SDS(Simple Dynamic String),存储的是字节数组。Bitmap 操作只是在这个字节数组上进行位级别的读写,不涉及新的底层结构。
|
||||
|
||||
Bitmap 的核心指令只有四个:
|
||||
|
||||
| 指令 | 作用 | 时间复杂度 |
|
||||
|------|------|-----------|
|
||||
| `SETBIT key offset value` | 设置指定位的值(0 或 1) | O(1) |
|
||||
| `GETBIT key offset` | 获取指定位的值 | O(1) |
|
||||
| `BITCOUNT key [start end]` | 统计值为 1 的位数 | O(N),N 为字节数 |
|
||||
| `BITOP operation destkey key [key ...]` | 对多个 Bitmap 做位运算 | O(N) |
|
||||
|
||||
其中 `BITOP` 支持 `AND`、`OR`、`NOT`、`XOR` 四种位运算,用于多个 Bitmap 之间的聚合分析。
|
||||
|
||||
> [!tip] offset 是从 0 开始的
|
||||
> `SETBIT sign:1001 5 1` 表示将 key `sign:1001` 的第 6 个 bit(offset=5)置为 1。如果 key 不存在,Redis 会自动扩展字符串长度。
|
||||
|
||||
### 2. 用户签到系统
|
||||
|
||||
用 Bitmap 实现签到非常直观:每个用户一个 key,每天对应一个 bit 位,签到则置 1。
|
||||
|
||||
```go
|
||||
// 用户签到(offset = 一年中的第几天)
|
||||
func SignIn(ctx context.Context, rdb *redis.Client, userID int64, dayOfYear int) error {
|
||||
key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year())
|
||||
return rdb.SetBit(ctx, key, int64(dayOfYear), 1).Err()
|
||||
}
|
||||
|
||||
// 查询某天是否签到
|
||||
func IsSigned(ctx context.Context, rdb *redis.Client, userID int64, dayOfYear int) (bool, error) {
|
||||
key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year())
|
||||
val, err := rdb.GetBit(ctx, key, int64(dayOfYear)).Result()
|
||||
return val == 1, err
|
||||
}
|
||||
|
||||
// 本月累计签到天数
|
||||
func MonthSignCount(ctx context.Context, rdb *redis.Client, userID int64) (int64, error) {
|
||||
key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year())
|
||||
// BITCOUNT 支持按字节范围统计,start/end 是字节索引
|
||||
// 需要根据月份计算对应的字节范围
|
||||
return rdb.BitCount(ctx, key, &redis.BitCount{
|
||||
Start: int64((time.Now().Month() - 1) * 4), // 近似值,实际需精确计算
|
||||
End: int64(time.Now().Month()*4 - 1),
|
||||
}).Result()
|
||||
}
|
||||
```
|
||||
|
||||
> [!question] 连续签到 7 天如何判断?
|
||||
> 有两种思路:(1) 用 `BITCOUNT` 统计最近 7 个 bit 是否全为 1;(2) 更高效的做法是用位移掩码——取出 7 位的值,判断是否等于 `0b1111111`(即 127)。
|
||||
|
||||
下面是连续签到检测的示例:
|
||||
|
||||
```go
|
||||
// 检测最近 N 天是否连续签到
|
||||
func IsContinuousSign(ctx context.Context, rdb *redis.Client, userID int64, days int) (bool, error) {
|
||||
key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year())
|
||||
today := time.Now().YearDay()
|
||||
|
||||
// 逐 bit 检查最近 N 天
|
||||
for i := 0; i < days; i++ {
|
||||
val, err := rdb.GetBit(ctx, key, int64(today-i)).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if val == 0 {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 3. DAU 统计
|
||||
|
||||
DAU(Daily Active Users)是 Bitmap 最经典的应用场景。思路很简单:每天一个 key,每个用户 ID 对应一个 bit 位。
|
||||
|
||||
```go
|
||||
// 记录用户今日活跃
|
||||
func MarkActive(ctx context.Context, rdb *redis.Client, userID int64) error {
|
||||
key := fmt.Sprintf("dau:%s", time.Now().Format("2006-01-02"))
|
||||
return rdb.SetBit(ctx, key, userID, 1).Err()
|
||||
}
|
||||
|
||||
// 查询今日 DAU
|
||||
func GetDAU(ctx context.Context, rdb *redis.Client, date string) (int64, error) {
|
||||
key := fmt.Sprintf("dau:%s", date)
|
||||
return rdb.BitCount(ctx, key, nil).Result()
|
||||
}
|
||||
|
||||
// 查询本周 UV(去重)
|
||||
func GetWeeklyUV(ctx context.Context, rdb *redis.Client) (int64, error) {
|
||||
destKey := "dau:weekly:tmp"
|
||||
var keys []string
|
||||
// 收集最近 7 天的 key
|
||||
for i := 0; i < 7; i++ {
|
||||
day := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
|
||||
keys = append(keys, fmt.Sprintf("dau:%s", day))
|
||||
}
|
||||
// OR 运算:任意一天活跃即算周活
|
||||
err := rdb.BitOpOr(ctx, destKey, keys...).Err()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rdb.BitCount(ctx, destKey, nil).Result()
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] BITOP OR 的去重原理
|
||||
> 假设用户 A 在周一和周三都活跃,对应的 bit 位已经是 1。OR 运算后该位仍是 1,最终 BITCOUNT 只统计一次,天然实现了去重。这比在应用层用 Set 去重高效得多。
|
||||
|
||||
### 4. 内存计算
|
||||
|
||||
> [!tip] Bitmap 到底有多省内存?我们来算一笔账。
|
||||
|
||||
假设平台有 **10 亿注册用户**,用户 ID 从 0 到 999,999,999。
|
||||
|
||||
```
|
||||
总 bit 数 = 1,000,000,000 bits
|
||||
总字节数 = 1,000,000,000 / 8 = 125,000,000 bytes ≈ 119.2 MB
|
||||
```
|
||||
|
||||
对比一下其他方案存储同样 10 亿用户的信息:
|
||||
|
||||
| 方案 | 数据结构 | 内存消耗 |
|
||||
|------|---------|---------|
|
||||
| Bitmap | 1 bit / 用户 | ~125 MB |
|
||||
| Hash(存 boolean) | ~50 bytes / 用户(含 key 开销) | ~47 GB |
|
||||
| Set(存用户 ID) | ~16 bytes / 用户 | ~15 GB |
|
||||
|
||||
Bitmap 的内存效率比 Hash 方案低约 **380 倍**,这就是为什么在大规模布尔型统计场景下,Bitmap 是首选。
|
||||
|
||||
> [!question] 为什么 Bitmap 这么省?
|
||||
> 因为它只用 1 个 bit 来表示一个布尔值,而 Hash/Set 需要存储完整的 key 和 value。Redis String 底层 SDS 本身也有元数据开销,但分摊到数十亿个 bit 上几乎可以忽略。
|
||||
|
||||
### 5. Bitmap vs Set vs HyperLogLog 对比
|
||||
|
||||
| 特性 | Bitmap | Set | HyperLogLog |
|
||||
|------|--------|-----|-------------|
|
||||
| **底层结构** | String(位数组) | Hashtable / Ziplist | 概率算法 |
|
||||
| **单条数据内存** | 1 bit | 16~50 bytes | 固定 12 KB |
|
||||
| **精确度** | 精确 | 精确 | 误差 ~0.81% |
|
||||
| **支持操作** | 位运算、计数 | 集合运算、随机取 | 仅计数 |
|
||||
| **适用场景** | 签到、DAU、布隆过滤器 | 精确集合运算 | 超大规模基数估算 |
|
||||
| **数据量上限** | 受 String 最大 512 MB 限制(约 40 亿 bit) | 受内存限制 | 固定 12 KB |
|
||||
|
||||
> [!question] 该选哪个?
|
||||
> - 需要精确统计 + 数据是布尔型(在线/签到/活跃) -> **Bitmap**
|
||||
> - 需要精确集合运算(交集、差集) -> **Set**
|
||||
> - 只需要统计基数(UV/PV),允许 0.81% 误差 -> **HyperLogLog**
|
||||
|
||||
### 6. 签到系统流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["用户发起签到请求"] --> B["计算当日 offset"]
|
||||
B --> C["SETBIT sign:uid:year offset 1"]
|
||||
C --> D["签到成功"]
|
||||
D --> E{"查询本月签到?"}
|
||||
E -->|是| F["BITCOUNT 计算本月签到天数"]
|
||||
E -->|否| G{"检查连续签到?"}
|
||||
G -->|是| H["逐 bit 检查最近 N 天"]
|
||||
G -->|否| I["返回结果"]
|
||||
F --> I
|
||||
H --> I
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/02-核心数据类型]]
|
||||
- [[hhs/Redis/13-HyperLogLog]]
|
||||
- [[hhs/Redis/README]]
|
||||
@@ -0,0 +1,212 @@
|
||||
---
|
||||
tags: [Redis, 缓存, 数据结构, HyperLogLog]
|
||||
create time: 2026-05-24 10:00
|
||||
---
|
||||
|
||||
# Redis HyperLogLog 概率计数
|
||||
|
||||
## 概述
|
||||
|
||||
HyperLogLog(HLL)是 Redis 提供的**概率型基数估算数据结构**——用固定的 **12KB 内存**,就能估算高达 2^64 个不同元素的数量,标准误差仅 **0.81%**。它不存储元素本身,只回答"有多少个不同的值",是 UV 统计、去重计数等场景的利器。
|
||||
|
||||
> [!question] 为什么不直接用 Set 做去重计数?
|
||||
> 1 亿个用户 ID 存进 Set,每个 ID 至少占几十字节,轻松吃掉几 GB 内存。HyperLogLog 只需要 12KB,代价是允许 0.81% 的误差——对于"今天有多少独立访客"这类问题,这个精度完全够用。
|
||||
|
||||
## 一、核心原理
|
||||
|
||||
### 概率思想:用"前导零"推算基数
|
||||
|
||||
HyperLogLog 的直觉来源于**伯努利试验**:抛一枚均匀硬币,连续出现 k 次正面的概率是 1/2^k。如果我们在所有试验中观察到最长的连续正面次数为 k,就可以反推大约有 2^k 个不同的试验。
|
||||
|
||||
实际做法:
|
||||
|
||||
1. **哈希映射**:对每个元素做哈希,得到一个均匀分布的比特串
|
||||
2. **分桶**:用前 14 位定位到 2^14 = 16384 个桶中的一个
|
||||
3. **记录前导零**:对剩余比特,统计第一个 1 出现前连续 0 的个数(取最大值)
|
||||
4. **调和均值**:所有桶的值通过调和均值公式合并,得到基数估算
|
||||
|
||||
> [!tip] 关键洞察
|
||||
> HLL 不存储元素,只存储每个桶的"最大前导零位数"。每个桶只需 6 个比特(能表示 0~63),所以总内存 = 16384 × 6 bit ≈ **12KB**,无论存 1 个元素还是 10 亿个元素。
|
||||
|
||||
### Redis 中只有 3 个命令
|
||||
|
||||
| 命令 | 作用 | 时间复杂度 |
|
||||
|------|------|-----------|
|
||||
| `PFADD key element [element ...]` | 向 HLL 添加元素 | O(N),N 为元素个数 |
|
||||
| `PFCOUNT key [key ...]` | 返回基数估算值 | O(N),N 为 HLL 个数 |
|
||||
| `PFMERGE dest source [source ...]` | 合并多个 HLL | O(N),N 为 HLL 个数 |
|
||||
|
||||
> [!question] 为什么命令叫 PF 开头?
|
||||
> PF 是 Philippe Flajolet 的名字缩写——他是 HyperLogLog 算法论文的主要作者。
|
||||
|
||||
## 二、UV 统计实战
|
||||
|
||||
### 用 Go 实现页面 UV 计数
|
||||
|
||||
以下是一个完整的 Web UV 统计示例,使用 go-redis v9:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var rdb *redis.Client
|
||||
var ctx = context.Background()
|
||||
|
||||
func init() {
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
DB: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordVisit 记录一次页面访问,visitorID 为用户唯一标识
|
||||
func RecordVisit(page string, visitorID string) error {
|
||||
key := fmt.Sprintf("uv:%s:%s", page, time.Now().Format("2006-01-02"))
|
||||
// PFADD:如果 visitorID 已存在,不会重复计数(概率去重)
|
||||
return rdb.PFAdd(ctx, key, visitorID).Err()
|
||||
}
|
||||
|
||||
// GetUV 获取某页面当日独立访客数
|
||||
func GetUV(page string) (int64, error) {
|
||||
key := fmt.Sprintf("uv:%s:%s", page, time.Now().Format("2006-01-02"))
|
||||
// PFCOUNT 返回估算的基数,误差约 0.81%
|
||||
return rdb.PFCount(ctx, key).Result()
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 模拟 10000 个不同用户访问首页
|
||||
for i := 0; i < 10000; i++ {
|
||||
visitorID := fmt.Sprintf("user_%d", i)
|
||||
if err := RecordVisit("home", visitorID); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
uv, _ := GetUV("home")
|
||||
fmt.Printf("首页今日 UV: %d\n", uv) // 输出约 10000,误差极小
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 对比 Set 方案
|
||||
> 如果用 `SADD` + `SCARD` 实现同样功能,10000 个用户 ID 至少消耗 **数百 KB**;而 HyperLogLog 的 `uv:home:2026-05-24` 这个 key 始终只占 **12KB**。用户量越大,HLL 的优势越明显。
|
||||
|
||||
## 三、合并多个统计源
|
||||
|
||||
实际业务中经常需要合并数据——比如从每日 UV 汇总出每周 UV。
|
||||
|
||||
```go
|
||||
// MergeWeeklyUV 将 7 天的日 UV 合并为周 UV
|
||||
func MergeWeeklyUV(page string, weekStart time.Time) error {
|
||||
destKey := fmt.Sprintf("uv:%s:week:%s", page, weekStart.Format("2006-01-02"))
|
||||
|
||||
// 构造 7 天的源 key
|
||||
var sourceKeys []string
|
||||
for i := 0; i < 7; i++ {
|
||||
day := weekStart.AddDate(0, 0, i)
|
||||
sourceKeys = append(sourceKeys, fmt.Sprintf("uv:%s:%s", page, day.Format("2006-01-02")))
|
||||
}
|
||||
|
||||
// PFMERGE:将多个 HLL 合并到 dest,结果仍是 HLL(12KB)
|
||||
// 合并后的基数 ≈ 所有源集合的并集大小
|
||||
return rdb.PFMerge(ctx, destKey, sourceKeys...).Err()
|
||||
}
|
||||
|
||||
// GetWeeklyUV 获取周 UV
|
||||
func GetWeeklyUV(page string, weekStart time.Time) (int64, error) {
|
||||
key := fmt.Sprintf("uv:%s:week:%s", page, weekStart.Format("2006-01-02"))
|
||||
return rdb.PFCount(ctx, key).Result()
|
||||
}
|
||||
```
|
||||
|
||||
> [!question] PFMERGE 是精确合并吗?
|
||||
> 是的。PFMERGE 会取每个桶在所有源 HLL 中的最大值,数学上等价于对并集重新计算 HLL。合并后再 PFCOUNT,得到的就是**并集的估算基数**,误差不会因为合并而放大。
|
||||
|
||||
## 四、误差分析
|
||||
|
||||
HyperLogLog 的标准误差为 **1.04 / sqrt(m)**,其中 m 是桶数。Redis 使用 16384 个桶:
|
||||
|
||||
```
|
||||
标准误差 = 1.04 / sqrt(16384) ≈ 0.81%
|
||||
```
|
||||
|
||||
| 实际基数 | 估算误差范围(±0.81%) |
|
||||
|---------|----------------------|
|
||||
| 1,000 | ±8 |
|
||||
| 100,000 | ±810 |
|
||||
| 10,000,000 | ±81,000 |
|
||||
|
||||
> [!question] 0.81% 的误差在实际中能接受吗?
|
||||
> 绝大多数场景完全可以。UV 统计本身就有缓存穿透、机器人流量等噪声,0.81% 的误差远小于这些业务噪声。但如果你需要精确计数(比如库存扣减),请用 Set 或数据库。
|
||||
|
||||
## 五、内存计算
|
||||
|
||||
HyperLogLog 的内存占用是**固定**的,与元素数量无关:
|
||||
|
||||
```
|
||||
桶数 = 2^14 = 16384
|
||||
每个桶 = 6 bit
|
||||
总内存 = 16384 × 6 bit = 98304 bit ≈ 12KB
|
||||
```
|
||||
|
||||
> [!tip] Redis 的内存优化
|
||||
> 当 HLL 计数的基数较小时(稀疏表示),Redis 会用更紧凑的编码存储,可能只占几百字节。只有当基数增长到一定程度,才会扩展到完整的 12KB。这个过程是自动的,对用户透明。
|
||||
|
||||
| 场景 | Set 内存 | HyperLogLog 内存 |
|
||||
|------|---------|-----------------|
|
||||
| 1 万个 ID | ~500KB | 12KB |
|
||||
| 100 万个 ID | ~50MB | 12KB |
|
||||
| 1 亿个 ID | ~5GB | 12KB |
|
||||
|
||||
## 六、适用场景 vs 不适用场景
|
||||
|
||||
### 适合使用 HyperLogLog
|
||||
|
||||
- 页面/接口 UV 统计
|
||||
- 搜索关键词去重数
|
||||
- 广告曝光独立用户数
|
||||
- 任何"只需要知道有多少个不同值"的场景
|
||||
|
||||
### 不适合使用 HyperLogLog
|
||||
|
||||
- **需要知道具体有哪些元素**:HLL 不存储元素本身,无法枚举
|
||||
- **需要精确计数**:0.81% 误差不可接受的场景(如库存、余额)
|
||||
- **数据量极小(< 1000)**:此时 Set 占用内存也很小,且精确无误
|
||||
|
||||
> [!question] 三种方案怎么选?
|
||||
|
||||
| 维度 | HyperLogLog | Set | Bitmap |
|
||||
|------|------------|-----|--------|
|
||||
| 功能 | 基数估算 | 精确去重 + 列举 | 位标记 + 计数 |
|
||||
| 内存 | 固定 12KB | 随元素线性增长 | 约 max_id / 8 字节 |
|
||||
| 精度 | ~99.19% | 100% | 100% |
|
||||
| 典型场景 | UV 统计 | 标签、好友列表 | 签到、在线状态 |
|
||||
|
||||
> [!tip] 一句话决策
|
||||
> 只需要"有多少个"→ HyperLogLog;需要"有哪些"→ Set;需要"某个 ID 在不在"→ Bitmap。
|
||||
|
||||
## 七、UV 统计流程
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["用户请求页面"] --> B["PFADD uv:page:date visitor_id"]
|
||||
B --> C["Redis HyperLogLog"]
|
||||
C --> D["PFCOUNT uv:page:date"]
|
||||
D --> E["返回估算 UV 数"]
|
||||
E --> F["展示在监控面板"]
|
||||
G["每日凌晨"] --> H["PFMERGE uv:page:week daily_keys"]
|
||||
H --> I["周报 UV 汇总"]
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/02-核心数据类型]]
|
||||
- [[hhs/Redis/12-Bitmap]]
|
||||
- [[hhs/Redis/README]]
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
tags: [Redis, 缓存, 数据结构, BloomFilter]
|
||||
create time: 2026-05-24 10:00
|
||||
---
|
||||
|
||||
# Redis Bloom Filter 布隆过滤器
|
||||
|
||||
## 概述
|
||||
|
||||
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素**是否存在于集合中**。核心特性:
|
||||
|
||||
> **"Definitely not or probably yes"** —— 说不存在则一定不存在;说存在则**大概率**存在(有极小误判概率)。
|
||||
|
||||
这个特性让它成为解决**缓存穿透**问题的利器。
|
||||
|
||||
> [!question] 为什么需要布隆过滤器?
|
||||
> 攻击者用大量不存在的 key 轰击系统,每个请求穿透缓存直打 DB,最终压垮数据库。布隆过滤器能在请求到达 DB 之前,用极低的内存成本拦截掉"肯定不存在"的请求。
|
||||
|
||||
## 一、核心原理
|
||||
|
||||
布隆过滤器的底层:**一个 bit 数组 + 多个独立的哈希函数**。
|
||||
|
||||
- **bit 数组**:长度为 `m`,初始全部为 0
|
||||
- **哈希函数**:`k` 个相互独立的哈希函数,每个函数将输入映射到 `[0, m-1]` 的某个位置
|
||||
|
||||
**添加元素**:对元素分别用 `k` 个哈希函数计算,将 bit 数组中对应位置全部置为 1。**查询元素**:用同样的 `k` 个哈希函数计算,所有对应位置都是 1 则判定"可能存在";任意一位是 0 则判定"一定不存在"。
|
||||
|
||||
> [!tip] 为什么会有误判?
|
||||
> 不同元素经过哈希计算后可能映射到相同位置(哈希冲突)。随着插入元素增多,查询一个不存在的元素时,恰好所有位置都被其他元素"碰巧"置为 1 的概率就会上升。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["element x"] --> H1["Hash1(x) = 3"]
|
||||
A --> H2["Hash2(x) = 7"]
|
||||
A --> H3["Hash3(x) = 11"]
|
||||
H1 --> B1["bit[3] = 1"]
|
||||
H2 --> B2["bit[7] = 1"]
|
||||
H3 --> B3["bit[11] = 1"]
|
||||
```
|
||||
|
||||
## 二、Redis 中的两种方案
|
||||
|
||||
### 方案 A:RedisBloom 模块(服务端)
|
||||
|
||||
RedisBloom 是 Redis 官方模块,通过 `BF.ADD` / `BF.EXISTS` 命令直接操作。
|
||||
|
||||
```go
|
||||
// go-redis 调用 RedisBloom
|
||||
func checkWithRedisBloom(ctx context.Context, rdb *redis.Client, key, value string) (bool, error) {
|
||||
result, err := rdb.Do(ctx, "BF.EXISTS", key, value).Int() // 1=可能存在, 0=一定不存在
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result == 1, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 方案 B:Go 侧布隆过滤器库(客户端)
|
||||
|
||||
使用 `github.com/bits-and-blooms/bloom/v3` 在应用内存中维护过滤器。
|
||||
|
||||
```go
|
||||
import "github.com/bits-and-blooms/bloom/v3"
|
||||
|
||||
filter := bloom.NewWithEstimates(1_000_000, 0.0001) // 100万元素, 误判率0.01%
|
||||
filter.AddString("user:10086") // 添加
|
||||
exists := filter.TestString("user:10086") // 查询: true = 可能存在
|
||||
```
|
||||
|
||||
## 三、缓存穿透防护实战
|
||||
|
||||
这是布隆过滤器最经典的应用场景。下面用一个完整的 Go 示例展示三层防护架构。
|
||||
|
||||
### 3.1 架构流程
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Req["客户端请求"] --> BF{"布隆过滤器判断"}
|
||||
BF -- "不存在" --> Ret1["直接返回空, 拒绝请求"]
|
||||
BF -- "可能存在" --> Cache{"查询 Redis 缓存"}
|
||||
Cache -- "命中" --> Ret2["返回缓存数据"]
|
||||
Cache -- "未命中" --> DB["查询 MySQL"]
|
||||
DB -- "找到数据" --> SetCache["写入 Redis 缓存"]
|
||||
SetCache --> Ret3["返回数据"]
|
||||
DB -- "未找到" --> Ret4["返回空并缓存空值"]
|
||||
```
|
||||
|
||||
### 3.2 完整代码示例
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/bits-and-blooms/bloom/v3"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type BloomCacheService struct { // 三层防护服务
|
||||
rdb *redis.Client
|
||||
filter *bloom.BloomFilter
|
||||
}
|
||||
|
||||
// NewBloomCacheService 初始化:加载全量 ID 到布隆过滤器
|
||||
func NewBloomCacheService(rdb *redis.Client) *BloomCacheService {
|
||||
filter := bloom.NewWithEstimates(1_000_000, 0.0001) // 100万用户, 误判率0.01%
|
||||
|
||||
// 从 Redis Set 批量加载已有 ID(生产环境可从 DB 加载)
|
||||
ctx := context.Background()
|
||||
ids, _ := rdb.SMembers(ctx, "user:all_ids").Result()
|
||||
for _, id := range ids {
|
||||
filter.AddString(id)
|
||||
}
|
||||
return &BloomCacheService{rdb: rdb, filter: filter}
|
||||
}
|
||||
|
||||
// GetUser 三层查询:Bloom -> Redis -> MySQL
|
||||
func (s *BloomCacheService) GetUser(ctx context.Context, userID string) (*User, error) {
|
||||
cacheKey := "user:" + userID
|
||||
|
||||
// 第一层:布隆过滤器拦截
|
||||
if !s.filter.TestString(userID) {
|
||||
return nil, nil // 一定不存在
|
||||
}
|
||||
|
||||
// 第二层:Redis 缓存
|
||||
data, err := s.rdb.Get(ctx, cacheKey).Bytes()
|
||||
if err == nil {
|
||||
if string(data) == "" {
|
||||
return nil, nil // 缓存的空值
|
||||
}
|
||||
var user User
|
||||
json.Unmarshal(data, &user)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// 第三层:查询数据库
|
||||
user, err := queryMySQL(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
s.rdb.Set(ctx, cacheKey, "", 5*time.Minute) // 缓存空值, 短 TTL
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(user)
|
||||
s.rdb.Set(ctx, cacheKey, bytes, 30*time.Minute)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// queryMySQL 模拟数据库查询
|
||||
func queryMySQL(userID string) (*User, error) {
|
||||
return nil, nil // 实际项目中查询 MySQL
|
||||
}
|
||||
```
|
||||
|
||||
> [!question] 为什么还需要缓存空值?
|
||||
> 布隆过滤器拦截了"肯定不存在"的请求,但如果某个 ID 曾经存在过(布隆过滤器会说"可能存在"),但实际已从 DB 删除,此时仍会穿透到 DB。缓存空值是第二道防线。
|
||||
|
||||
## 四、参数计算
|
||||
|
||||
选择布隆过滤器的两个关键参数:
|
||||
|
||||
| 参数 | 含义 | 影响 |
|
||||
|------|------|------|
|
||||
| `m` | bit 数组长度 | 越大误判率越低,但内存占用越多 |
|
||||
| `k` | 哈希函数个数 | 太少冲突多,太多计算慢 |
|
||||
|
||||
**公式**(基于期望元素数 `n` 和目标误判率 `p`):
|
||||
|
||||
$$m = -\frac{n \ln p}{(\ln 2)^2}, \quad k = \frac{m}{n} \ln 2$$
|
||||
|
||||
> [!tip] 快速估算
|
||||
> 对于 n = 100 万、p = 0.01% 的常见场景:
|
||||
> - m ≈ 19,170,117 bits ≈ **2.3 MB**
|
||||
> - k ≈ 13 个哈希函数
|
||||
>
|
||||
> 相比之下,用 Redis Set 存 100 万个字符串 key 至少需要 **几十 MB**。布隆过滤器的内存优势是数量级的。
|
||||
|
||||
```go
|
||||
import "math"
|
||||
|
||||
func calcBloomParams(n int, p float64) (m int, k int) {
|
||||
// m = -n * ln(p) / (ln2)^2
|
||||
ln2 := math.Ln2
|
||||
m = int(-float64(n) * math.Log(p) / (ln2 * ln2))
|
||||
// k = (m/n) * ln2
|
||||
k = int(math.Round(float64(m) / float64(n) * ln2))
|
||||
return m, k
|
||||
}
|
||||
```
|
||||
|
||||
## 五、RedisBloom vs Go-side 对比
|
||||
|
||||
| 维度 | RedisBloom 模块 | Go-side 布隆库 |
|
||||
|------|----------------|---------------|
|
||||
| **部署** | 需安装 RedisBloom 模块 | 只需引入 Go 依赖 |
|
||||
| **网络开销** | 每次查询一次网络往返 | 本地内存, 零网络开销 |
|
||||
| **分布式一致性** | 天然共享 | 需自行同步 |
|
||||
| **性能** | 受网络 RT 限制, < 10 万 QPS | 纯内存, 百万 QPS |
|
||||
| **持久化** | 随 Redis RDB/AOF 自动持久化 | 需额外持久化机制 |
|
||||
| **适用场景** | 多实例共享, 数据量大 | 单服务高频查询 |
|
||||
|
||||
> [!tip] 如何选择?
|
||||
> - **微服务多实例**:优先 RedisBloom,天然共享无需同步
|
||||
> - **单体服务、极致性能**:Go-side 布隆库,避免网络开销
|
||||
> - **折中方案**:Go-side 为主,定期从 Redis 同步 bit 数组快照
|
||||
|
||||
## 六、局限性
|
||||
|
||||
### 6.1 不支持删除
|
||||
|
||||
标准布隆过滤器不支持删除——将某位从 1 置为 0 可能影响其他元素判断。解决方案是**计数布隆过滤器(Counting Bloom Filter)**,用计数器替代 1 bit,删除时计数器减 1。RedisBloom 的 Cuckoo Filter(`CF.ADD` / `CF.EXISTS`)也原生支持删除。
|
||||
|
||||
### 6.2 需要预加载
|
||||
|
||||
布隆过滤器必须在使用前加载全部合法元素。新增数据时需同步更新过滤器,初始化时需遍历全量数据。
|
||||
|
||||
> [!question] 如果新增了数据但忘记更新布隆过滤器会怎样?
|
||||
> 新增的 key 在布隆过滤器中查询会返回"不存在",导致合法请求被误拦截。这比缓存穿透更严重——用户明明有数据却查不到。所以务必确保新增数据时同步 `BF.ADD` 或 `filter.AddString()`。
|
||||
|
||||
### 6.3 误判率随使用上升
|
||||
|
||||
实际元素数量远超预估值 `n` 时,误判率会显著上升。建议预留 20%-30% 余量,监控实际元素数量,必要时重建过滤器。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/10-缓存架构模式]]
|
||||
- [[hhs/Redis/02-核心数据类型]]
|
||||
- [[hhs/Redis/README]]
|
||||
@@ -0,0 +1,455 @@
|
||||
---
|
||||
tags: [Redis, 缓存, Stream, 消息队列]
|
||||
create time: 2026-05-24 10:00
|
||||
---
|
||||
|
||||
# Redis Stream 消息队列
|
||||
|
||||
## 概述
|
||||
|
||||
Redis Stream 是 Redis 5.0 引入的日志型数据结构,专门为**可靠消息传递**设计。它弥补了 Pub/Sub 「发完即忘、不持久化」的先天缺陷,同时又比 Kafka/RabbitMQ 这类外部中间件轻量——不需要额外部署组件,复用已有的 Redis 实例即可。
|
||||
|
||||
> [!question] Pub/Sub 够用吗?为什么要用 Stream?
|
||||
> Pub/Sub 的核心问题:消息**不持久化**,订阅者下线就丢失。Stream 将每条消息追加写入内存(可配合 AOF 持久化),并通过 Consumer Group 实现**消费确认**机制——消息只有被 ACK 后才算真正消费完成。这使得 Stream 能胜任任务队列、事件溯源等对可靠性有要求的场景。
|
||||
|
||||
## 一、核心概念
|
||||
|
||||
### 基本操作命令
|
||||
|
||||
```bash
|
||||
# === 生产者 ===
|
||||
XADD mystream * field1 value1 field2 value2
|
||||
# * 表示自动生成 ID(时间戳-序号),如 1685000000000-0
|
||||
|
||||
# === 消费者(无消费者组模式)===
|
||||
XREAD COUNT 10 BLOCK 5000 STREAMS mystream 0
|
||||
# BLOCK 5000 = 最多等 5 秒;0 = 从头读取
|
||||
# 返回一批 XEntry,每条包含 ID + fields
|
||||
|
||||
# === 消费者组模式(推荐)===
|
||||
XGROUP CREATE mystream mygroup $ # $ = 只消费创建后的新消息
|
||||
XREADGROUP GROUP mygroup consumer1 COUNT 1 BLOCK 5000 STREAMS mystream >
|
||||
# > 表示「只取未分配的新消息」
|
||||
# 返回后消息进入该消费者的 pending 列表
|
||||
|
||||
XACK mystream mygroup <entry-id> # 确认消费完成
|
||||
```
|
||||
|
||||
> [!tip] `>` vs `$` vs `0` —— 三个特殊 ID
|
||||
> | ID | 含义 | 典型场景 |
|
||||
> |---|------|---------|
|
||||
> | `0` | Stream 的第一条消息 | 数据重放、全量回溯 |
|
||||
> | `$` | 当前最新消息的 ID | `XGROUP CREATE` 时指定起始点 |
|
||||
> | `>` | 只返回**未被任何消费者分配**的新消息 | `XREADGROUP` 正常消费循环 |
|
||||
|
||||
### Stream 底层结构
|
||||
|
||||
每条消息以 Radix Tree + Listpack 存储,时间复杂度 O(1) 追加。ID 格式为 `<毫秒时间戳>-<序号>`,天然有序。
|
||||
|
||||
> [!question] Stream 会像 List 一样消费后删除吗?
|
||||
> 不会。Stream 是**只追加日志**,消息消费后仍在。需要显式 `XDEL` 或通过 `MAXLEN`/`MINID` 策略裁剪。这带来了「可回溯」的优势,但也意味着必须主动管理容量。
|
||||
|
||||
## 二、Consumer Group 机制详解
|
||||
|
||||
Consumer Group 是 Stream 的核心抽象,它让多个消费者**协作消费同一条 Stream**,每条消息只会被组内的一个消费者处理。
|
||||
|
||||
### 消息生命周期
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
P["Producer"] -->|"XADD"| S["Stream"]
|
||||
S -->|"XREADGROUP"| C1["Consumer A"]
|
||||
S -->|"XREADGROUP"| C2["Consumer B"]
|
||||
C1 -->|"XACK"| S
|
||||
C2 -->|"XACK"| S
|
||||
C1 -.->|"crashed"| PDL["Pending List"]
|
||||
PDL -->|"XCLAIM"| C2
|
||||
C2 -->|"XACK"| S
|
||||
|
||||
style S fill:#dfd,stroke:#090
|
||||
style C1 fill:#ddf,stroke:#669
|
||||
style C2 fill:#ddf,stroke:#669
|
||||
style PDL fill:#fdd,stroke:#f00
|
||||
```
|
||||
|
||||
### Pending List(待确认列表)
|
||||
|
||||
当消费者通过 `XREADGROUP GROUP ... >` 读取消息后,消息进入该消费者的 **PEL(Pending Entry List)**。只有显式调用 `XACK` 后才会移除。
|
||||
|
||||
```bash
|
||||
# 查看组内 pending 状态
|
||||
XPENDING mystream mygroup
|
||||
|
||||
# 更详细的 pending 信息(包含消费者、空闲时间)
|
||||
XPENDING mystream mygroup - + 10 # 返回最多 10 条 pending 条目
|
||||
```
|
||||
|
||||
XPENDING 返回字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `message-id` | 消息 ID |
|
||||
| `consumer` | 被分配的消费者 |
|
||||
| `idle-ms` | 自读取以来的空闲时间(毫秒) |
|
||||
| `delivery-count` | 被投递的次数(重试计数器) |
|
||||
|
||||
> [!tip] Pending 是 Stream 的「可靠性根基」
|
||||
> Pub/Sub 没有 pending 概念,消息发出去就消失了。Stream 的 PEL 记录了「谁领了哪条消息、领了多久、重试了几次」,这就是「至少一次投递」语义的实现基础。
|
||||
|
||||
### 消费者组内部机制
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as Producer
|
||||
participant S as Stream
|
||||
participant G as Consumer Group
|
||||
participant C1 as Consumer A
|
||||
participant C2 as Consumer B
|
||||
|
||||
P->>S: XADD msg-1
|
||||
P->>S: XADD msg-2
|
||||
P->>S: XADD msg-3
|
||||
C1->>G: XREADGROUP COUNT 2
|
||||
G->>S: 分配 msg-1, msg-2
|
||||
S-->>C1: msg-1, msg-2 (pending)
|
||||
C2->>G: XREADGROUP COUNT 2
|
||||
G->>S: 分配 msg-3
|
||||
S-->>C2: msg-3 (pending)
|
||||
C1->>S: XACK msg-1
|
||||
C1->>S: XACK msg-2
|
||||
C2->>S: XACK msg-3
|
||||
```
|
||||
|
||||
> [!question] 消息是如何分配给消费者的?
|
||||
> Redis 采用**轮询分配**(round-robin):新消息到达时,依次分配给组内活跃的消费者。注意不是负载均衡——如果某个消费者处理慢,它积压的 pending 消息不会自动转移给其他消费者,需要通过 XCLAIM 手动认领。
|
||||
|
||||
## 三、Go 实战
|
||||
|
||||
### 生产者
|
||||
|
||||
```go
|
||||
// StreamProducer 封装了 Stream 的写入逻辑
|
||||
type StreamProducer struct {
|
||||
rdb *redis.Client
|
||||
stream string
|
||||
maxSize int64 // MAXLEN 裁剪阈值
|
||||
}
|
||||
|
||||
func NewStreamProducer(rdb *redis.Client, stream string) *StreamProducer {
|
||||
return &StreamProducer{rdb: rdb, stream: stream, maxSize: 100000}
|
||||
}
|
||||
|
||||
func (p *StreamProducer) Publish(ctx context.Context, fields map[string]interface{}) (string, error) {
|
||||
// XADD 的 ApproximateMaxLen 约等于 MAXLEN ~,性能更好
|
||||
id, err := p.rdb.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: p.stream,
|
||||
ApproximateMaxLen: true, // ~ 模式,不精确计数但更快
|
||||
MaxLen: p.maxSize,
|
||||
Values: fields,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("XADD failed: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 消费者(含重连与重试)
|
||||
|
||||
```go
|
||||
type StreamConsumer struct {
|
||||
rdb *redis.Client
|
||||
stream string
|
||||
group string
|
||||
consumer string
|
||||
handler func(ctx context.Context, msg redis.XMessage) error
|
||||
batchSize int64
|
||||
blockTime time.Duration
|
||||
}
|
||||
|
||||
func (c *StreamConsumer) Start(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
// XREADGROUP 阻塞读取
|
||||
streams, err := c.rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
|
||||
Group: c.group,
|
||||
Consumer: c.consumer,
|
||||
Streams: []string{c.stream, ">"}, // > = 只取新消息
|
||||
Count: c.batchSize,
|
||||
Block: c.blockTime,
|
||||
}).Result()
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
continue // 超时无消息,正常
|
||||
}
|
||||
log.Printf("XReadGroup error: %v, retrying...", err)
|
||||
time.Sleep(time.Second) // 退避重试
|
||||
continue
|
||||
}
|
||||
|
||||
for _, stream := range streams {
|
||||
for _, msg := range stream.Messages {
|
||||
if err := c.handler(ctx, msg); err != nil {
|
||||
log.Printf("handler error for %s: %v", msg.ID, err)
|
||||
continue // 不 ACK,消息留在 pending 列表
|
||||
}
|
||||
// 处理成功,确认消费
|
||||
if err := c.rdb.XAck(ctx, c.stream, c.group, msg.ID).Err(); err != nil {
|
||||
log.Printf("XAck error for %s: %v", msg.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 不 ACK = 进入 pending
|
||||
> `handler` 返回 error 时跳过 `XACK`,这条消息会留在消费者的 PEL 中。后续通过 XPENDING + XCLAIM 捞回重试——这就是 Stream 实现「至少一次投递」的关键。
|
||||
|
||||
### 初始化消费者组
|
||||
|
||||
```go
|
||||
func EnsureGroup(ctx context.Context, rdb *redis.Client, stream, group string) error {
|
||||
// MKSTREAM: 如果 stream 不存在则自动创建
|
||||
err := rdb.XGroupCreateMkStream(ctx, stream, group, "$").Err()
|
||||
if err != nil && !strings.Contains(err.Error(), "BUSYGROUP") {
|
||||
return err
|
||||
}
|
||||
// BUSYGROUP = 组已存在,忽略即可
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## 四、消息确认与重试
|
||||
|
||||
### XPENDING —— 检查未确认消息
|
||||
|
||||
```bash
|
||||
# 概览:返回 [最小ID, 最大ID, 总条数, 各消费者pending数]
|
||||
XPENDING mystream mygroup
|
||||
|
||||
# 详细列表:按 ID 范围查询
|
||||
XPENDING mystream mygroup - + 10
|
||||
# - = 最小ID,+ = 最大ID,10 = 最多返回10条
|
||||
```
|
||||
|
||||
### XCLAIM —— 转移消息所有权
|
||||
|
||||
当消费者宕机后,需要把它 pending 的消息转给存活的消费者处理:
|
||||
|
||||
```bash
|
||||
# 将空闲超过 30000ms 的消息转给 consumer-b
|
||||
XCLAIM mystream mygroup consumer-b 30000 <msg-id-1> <msg-id-2>
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 实现:定期扫描超时 pending,认领到自己
|
||||
func (c *StreamConsumer) ClaimAbandoned(ctx context.Context, minIdle time.Duration) {
|
||||
pendings, err := c.rdb.XPendingExt(ctx, &redis.XPendingExtArgs{
|
||||
Stream: c.stream,
|
||||
Group: c.group,
|
||||
Idle: minIdle,
|
||||
Start: "-",
|
||||
End: "+",
|
||||
Count: 100,
|
||||
Consumer: "", // 不限定消费者,扫描所有
|
||||
}).Result()
|
||||
if err != nil || len(pendings) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]string, len(pendings))
|
||||
for i, p := range pendings {
|
||||
ids[i] = p.ID
|
||||
}
|
||||
|
||||
// XCLAIM 认领
|
||||
claimed, err := c.rdb.XClaim(ctx, &redis.XClaimArgs{
|
||||
Stream: c.stream,
|
||||
Group: c.group,
|
||||
Consumer: c.consumer,
|
||||
MinIdle: minIdle,
|
||||
Messages: ids,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 对认领到的消息重新执行 handler
|
||||
for _, msg := range claimed {
|
||||
if err := c.handler(ctx, msg); err == nil {
|
||||
c.rdb.XAck(ctx, c.stream, c.group, msg.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### XAUTOCLAIM —— Redis 6.2+ 自动认领
|
||||
|
||||
`XAUTOCLAIM` 把 XPENDING + XCLAIM 合并为一条原子命令:
|
||||
|
||||
```bash
|
||||
XAUTOCLAIM mystream mygroup consumer-b 30000 0-0 COUNT 10
|
||||
# 30000 = 最小空闲时间(ms)
|
||||
# 0-0 = 起始游标(首次从头开始)
|
||||
# COUNT = 每次最多返回 10 条
|
||||
|
||||
# 返回:next-start-id(下次传入作为游标)+ 消息列表 + 已删除的 ID
|
||||
```
|
||||
|
||||
> [!question] XCLAIM 会不会被两个消费者同时认领同一条消息?
|
||||
> 不会。XCLAIM 是单线程原子操作,Redis 保证同一消息在任意时刻只属于一个消费者。如果 consumer-A 已经 ACK 了某条消息,后续 XCLAIM 不会再返回它。
|
||||
|
||||
## 五、容量管理
|
||||
|
||||
Stream 是只追加的,消息不会自动删除。生产环境必须配置裁剪策略。
|
||||
|
||||
### MAXLEN —— 按数量裁剪
|
||||
|
||||
```bash
|
||||
# 写入时裁剪(推荐,边写边裁)
|
||||
XADD mystream MAXLEN 10000 * field value
|
||||
|
||||
# 精确裁剪 vs 近似裁剪
|
||||
XADD mystream MAXLEN 10000 * field value # 精确,O(N) 开销
|
||||
XADD mystream MAXLEN ~ 10000 * field value # 近似,~ 模式,O(1) 开销
|
||||
```
|
||||
|
||||
### MINID —— 按 ID 裁剪(Redis 6.2+)
|
||||
|
||||
```bash
|
||||
# 删除 ID 小于指定值的消息
|
||||
XADD mystream MINID 1685000000000-0 * field value
|
||||
XADD mystream MINID ~ 1685000000000-0 * field value # 近似模式
|
||||
```
|
||||
|
||||
### XTRIM —— 手动裁剪
|
||||
|
||||
```bash
|
||||
XTRIM mystream MAXLEN ~ 5000
|
||||
XTRIM mystream MINID 1685000000000-0
|
||||
```
|
||||
|
||||
> [!warning] 近似裁剪(`~`)的取舍
|
||||
> `~` 模式不会精确保留 N 条,实际保留数量可能略多(因为底层按 Radix Tree 节点边界裁剪)。对于绝大多数场景,近似裁剪足够,而且**性能显著优于精确裁剪**。建议生产环境一律使用 `~`。
|
||||
|
||||
> [!question] 裁剪会影响 pending 中的消息吗?
|
||||
> 会。`MAXLEN`/`MINID` 会删除 stream 中的原始消息,但 **pending 列表中的引用不会被清除**。这意味着 `XPENDING` 仍会显示这些 ID,但 `XCLAIM` 尝试读取时会返回 nil。设计裁剪策略时需确保消费者的处理速度跟得上写入速度。
|
||||
|
||||
## 六、Stream vs Pub/Sub vs Kafka 对比
|
||||
|
||||
| 维度 | Stream | Pub/Sub | Kafka |
|
||||
|------|--------|---------|-------|
|
||||
| **持久化** | 内存 + AOF | 不持久化 | 磁盘日志 |
|
||||
| **消费者组** | 原生支持 | 不支持 | 原生支持 |
|
||||
| **投递语义** | 至少一次(配合 ACK) | 最多一次(fire-and-forget) | 至少一次 / 精确一次 |
|
||||
| **消息回溯** | 支持(按 ID 范围) | 不支持 | 支持(按 offset) |
|
||||
| **吞吐量** | 中等(10w+ QPS) | 高(无持久化开销) | 极高(百万级 QPS) |
|
||||
| **运维复杂度** | 低(复用 Redis) | 低 | 高(独立集群) |
|
||||
| **适用规模** | 中小规模 | 实时通知 | 大规模事件流 |
|
||||
|
||||
> [!tip] 如何选型?
|
||||
> - **已有 Redis、消息量不大**(< 10w/s):Stream 是最佳选择,零额外运维成本
|
||||
> - **实时推送、允许丢消息**:Pub/Sub 更简单直接
|
||||
> - **海量事件流、需要精确一次语义**:Kafka 不可替代
|
||||
|
||||
## 七、死信队列(Dead Letter Queue)
|
||||
|
||||
当某条消息被重试 N 次仍然失败时,继续重试没有意义。此时应将其移入**死信队列**,等待人工介入或补偿处理。
|
||||
|
||||
### 实现思路
|
||||
|
||||
Stream 本身没有内置 DLQ,但可以通过 XCLAIM 的 `delivery-count` 自行实现:
|
||||
|
||||
```go
|
||||
const maxRetries = 5
|
||||
|
||||
func (c *StreamConsumer) ProcessPendingWithDLQ(ctx context.Context) {
|
||||
pendings, _ := c.rdb.XPendingExt(ctx, &redis.XPendingExtArgs{
|
||||
Stream: c.stream,
|
||||
Group: c.group,
|
||||
Start: "-",
|
||||
End: "+",
|
||||
Count: 100,
|
||||
MinIdle: 30 * time.Second,
|
||||
}).Result()
|
||||
|
||||
for _, p := range pendings {
|
||||
if p.RetryCount >= maxRetries {
|
||||
// 超过重试次数 → 写入死信 Stream
|
||||
c.moveToDLQ(ctx, p.ID)
|
||||
continue
|
||||
}
|
||||
// 认领并重试
|
||||
claimed, _ := c.rdb.XClaim(ctx, &redis.XClaimArgs{
|
||||
Stream: c.stream,
|
||||
Group: c.group,
|
||||
Consumer: c.consumer,
|
||||
MinIdle: 30 * time.Second,
|
||||
Messages: []string{p.ID},
|
||||
}).Result()
|
||||
for _, msg := range claimed {
|
||||
if err := c.handler(ctx, msg); err == nil {
|
||||
c.rdb.XAck(ctx, c.stream, c.group, msg.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StreamConsumer) moveToDLQ(ctx context.Context, msgID string) {
|
||||
// 读取原始消息内容(XCLAIM 的返回可能为空,用 XRANGE 兜底)
|
||||
msgs, _ := c.rdb.XRange(ctx, c.stream, msgID, msgID).Result()
|
||||
if len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
// 写入死信 Stream(附带原始 ID 以便溯源)
|
||||
dlqFields := msgs[0].Values
|
||||
dlqFields["original_id"] = msgID
|
||||
dlqFields["original_stream"] = c.stream
|
||||
c.rdb.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: c.stream + ":dlq",
|
||||
Values: dlqFields,
|
||||
})
|
||||
// 从原 Stream 确认移除
|
||||
c.rdb.XAck(ctx, c.stream, c.group, msgID)
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] DLQ 最佳实践
|
||||
> - 死信 Stream 命名建议 `<stream>:dlq`,保持关联性
|
||||
> - 在 DLQ 消息中记录 `original_id`、`original_stream`、`error_reason`,方便排查
|
||||
> - 监控 DLQ 长度,超过阈值立即告警——DLQ 堆积意味着业务逻辑有系统性问题
|
||||
|
||||
## 八、消息完整生命周期
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
P["Producer"] -->|"XADD"| S["Stream"]
|
||||
S -->|"XREADGROUP"| C1["Consumer A"]
|
||||
S -->|"XREADGROUP"| C2["Consumer B"]
|
||||
C1 -->|"XACK success"| ACK["Confirmed"]
|
||||
C1 -->|"handler fail"| PEL["Pending Entry List"]
|
||||
C2 -->|"XACK success"| ACK
|
||||
C2 -->|"handler fail"| PEL
|
||||
PEL -->|"retry count < max"| C1
|
||||
PEL -->|"retry count >= max"| DLQ["Dead Letter Queue"]
|
||||
ACK -->|"MAXLEN trim"| TRIM["Trimmed"]
|
||||
DLQ -->|"人工处理"| FIX["Fixed"]
|
||||
|
||||
style S fill:#dfd,stroke:#090
|
||||
style PEL fill:#fdd,stroke:#f00
|
||||
style DLQ fill:#ffd,stroke:#f90
|
||||
style ACK fill:#dff,stroke:#099
|
||||
style TRIM fill:#eee,stroke:#999
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/09-高级特性]] — Pub/Sub 基础、Lua 脚本原子操作
|
||||
- [[hhs/Redis/03-基本命令速查]] — Redis 命令速查手册
|
||||
- [[hhs/Redis/README]] — 知识索引总览
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
tags: [Redis, 缓存, 数据结构, GEO]
|
||||
create time: 2026-05-24 10:00
|
||||
---
|
||||
|
||||
# Redis GEO 地理位置
|
||||
|
||||
## 概述
|
||||
|
||||
GEO 是 Redis 3.2 引入的地理位置数据类型,底层基于 Sorted Set 实现,通过 GeoHash 编码将经纬度转换为可排序的分数值。它让我们能用简洁的命令完成"附近的人/门店"等 LBS(Location-Based Service)场景,无需依赖外部地理数据库。
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 底层原理
|
||||
|
||||
> [!question] GEO 既然是独立类型,为什么说它"没有自己的数据结构"?
|
||||
> 因为 GEO 底层就是 Sorted Set。Redis 只是在 ZSet 之上封装了一层经纬度编解码逻辑,并没有新增底层数据结构。
|
||||
|
||||
核心编码流程:
|
||||
|
||||
```
|
||||
经纬度 (latitude, longitude)
|
||||
↓ GeoHash 编码
|
||||
Base32 字符串
|
||||
↓ 转换
|
||||
52-bit 整数 → 存为 ZSet 的 score
|
||||
↓
|
||||
member 名称 → 存为 ZSet 的 value
|
||||
```
|
||||
|
||||
GeoHash 的本质是将二维坐标递归二分,交替切分经度和纬度,得到一个二进制串,再编码为 Base32 字符串。切分越细,精度越高(字符串越长)。
|
||||
|
||||
> [!tip] 为什么 GeoHash 能支持范围查询?
|
||||
> GeoHash 具有 **前缀匹配特性**:地理上越近的点,其 GeoHash 前缀越相似。将 GeoHash 转为 52-bit 整数后,相邻地理位置的分数也相近,这使得 ZSet 的 `ZRANGEBYSCORE` 天然支持范围查询。
|
||||
|
||||
**GeoHash 精度表**:
|
||||
|
||||
| 字符串长度 | 精度 (km) | 适用场景 |
|
||||
|-----------|----------|---------|
|
||||
| 4 | ~40 | 城市级 |
|
||||
| 5 | ~5 | 区县级 |
|
||||
| 6 | ~0.6 | 街道级 |
|
||||
| 7 | ~0.076 | 建筑级 |
|
||||
| 8 | ~0.019 | 高精度 |
|
||||
|
||||
Redis GEO 默认使用 **11 位精度**(约 0.019m),足以满足绝大多数 LBS 场景。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["输入经纬度"] --> B["GeoHash 编码"]
|
||||
B --> C["52-bit 整数"]
|
||||
C --> D["存入 ZSet score"]
|
||||
D --> E["范围查询时按 score 排序"]
|
||||
E --> F["解码还原经纬度并计算实际距离"]
|
||||
```
|
||||
|
||||
### 2. 核心命令
|
||||
|
||||
> [!question] GEORADIUS 命令好用,为什么 Redis 6.2 要废弃它?
|
||||
> `GEORADIUS` / `GEORADIUSBYMEMBER` 参数组合繁多,语义复杂。Redis 6.2 引入 `GEOSEARCH` 和 `GEOSEARCHSTORE`,用更清晰的接口统一了圆形和矩形搜索。
|
||||
|
||||
#### 常用命令速查
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `GEOADD key lng lat member` | 添加一个或多个地理位置 |
|
||||
| `GEOPOS key member` | 获取成员的经纬度 |
|
||||
| `GEODIST key m1 m2 [unit]` | 计算两个成员之间的距离 |
|
||||
| `GEOSEARCH key FROMMEMBER/FROMLONLAT ... BYRADIUS/BYBOX ...` | 搜索附近成员 (6.2+) |
|
||||
| `GEOSEARCHSTORE dst src ...` | 搜索结果存入目标 key (6.2+) |
|
||||
| `GEOHASH key member` | 返回成员的 GeoHash 字符串 |
|
||||
|
||||
**已废弃命令**(6.2+):
|
||||
|
||||
- `GEORADIUS` — 用 `GEOSEARCH` 替代
|
||||
- `GEORADIUSBYMEMBER` — 用 `GEOSEARCH ... FROMMEMBER` 替代
|
||||
|
||||
#### 基本操作示例
|
||||
|
||||
```bash
|
||||
# 添加门店坐标
|
||||
GEOADD stores 116.405285 39.904989 "store:1001"
|
||||
GEOADD stores 121.473701 31.230416 "store:1002"
|
||||
|
||||
# 获取坐标
|
||||
GEOPOS stores "store:1001"
|
||||
|
||||
# 计算距离(默认单位:米)
|
||||
GEODIST stores "store:1001" "store:1002" km
|
||||
|
||||
# 搜索北京某点 5km 内的门店,返回距离,最多 10 个
|
||||
GEOSEARCH stores FROMLONLAT 116.405285 39.904989 \
|
||||
BYRADIUS 5 km ASC COUNT 10 WITHDIST
|
||||
```
|
||||
|
||||
### 3. 附近门店实战
|
||||
|
||||
以下是一个完整的 Go 示例,使用 `go-redis/v9` 实现"查找附近门店"功能。
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Store 门店信息
|
||||
type Store struct {
|
||||
Name string
|
||||
Dist float64 // 距离,单位:米
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
// ---- 1. 批量添加门店坐标 ----
|
||||
locations := []*redis.GeoLocation{
|
||||
{Name: "store:1001", Longitude: 116.405285, Latitude: 39.904989},
|
||||
{Name: "store:1002", Longitude: 116.407496, Latitude: 39.908712},
|
||||
{Name: "store:1003", Longitude: 116.397827, Latitude: 39.906419},
|
||||
}
|
||||
_, err := rdb.GeoAdd(ctx, "stores", locations...).Result()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// ---- 2. 搜索附近 3km 内的门店 ----
|
||||
// 按距离升序排列,最多返回 5 个
|
||||
searchOpts := &redis.GeoSearchLocationQuery{
|
||||
GeoSearchQuery: redis.GeoSearchQuery{
|
||||
Longitude: 116.405285,
|
||||
Latitude: 39.904989,
|
||||
Radius: 3,
|
||||
RadiusUnit: "km",
|
||||
Sort: "ASC",
|
||||
Count: 5,
|
||||
},
|
||||
WithCoord: true,
|
||||
WithDist: true,
|
||||
}
|
||||
results, err := rdb.GeoSearchLocation(ctx, "stores", searchOpts).Result()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// ---- 3. 处理结果 ----
|
||||
for _, r := range results {
|
||||
fmt.Printf("门店: %s, 距离: %.2f km, 坐标: (%.6f, %.6f)\n",
|
||||
r.Name, r.Dist, r.Longitude, r.Latitude)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] `COUNT` 参数是软限制
|
||||
> `COUNT 5` 表示最多返回 5 个结果,但 Redis 内部仍会遍历所有候选点再做距离校验。如果业务数据量很大,建议结合分页或缩小搜索半径。
|
||||
|
||||
### 4. 距离计算
|
||||
|
||||
`GEODIST` 和 `GEOSEARCH` 的距离计算基于 **Haversine 公式**,该公式通过球面三角学计算地球表面两点之间的大圆距离。
|
||||
|
||||
Haversine 公式核心:
|
||||
|
||||
$$a = \sin^2\left(\frac{\Delta\phi}{2}\right) + \cos\phi_1 \cdot \cos\phi_2 \cdot \sin^2\left(\frac{\Delta\lambda}{2}\right)$$
|
||||
|
||||
$$d = 2R \cdot \arcsin(\sqrt{a})$$
|
||||
|
||||
其中 $R \approx 6371$ km(地球半径)。
|
||||
|
||||
> [!question] Redis 的距离计算精度如何?
|
||||
> Redis 使用 WGS84 参考椭球体近似为正球体,最大误差约 **0.5%**。对于 LBS 场景(几公里范围),误差通常在几十米以内,完全可接受。
|
||||
|
||||
### 5. 围栏检测
|
||||
|
||||
电子围栏(Geofencing)判断某个点是否在指定范围内。对于简单的圆形围栏,可以直接用 `GEODIST`;对于多边形围栏,需要自定义计算。
|
||||
|
||||
#### 圆形围栏
|
||||
|
||||
```bash
|
||||
# 判断 member 是否在圆心 1km 范围内
|
||||
GEOSEARCH fence FROMLONLAT 116.405285 39.904989 \
|
||||
BYRADIUS 1 km ASC COUNT 1
|
||||
```
|
||||
|
||||
#### 多边形围栏(射线法)
|
||||
|
||||
```go
|
||||
// PointInPolygon 射线法判断点是否在多边形内
|
||||
func PointInPolygon(lat, lng float64, polygon [][2]float64) bool {
|
||||
inside := false
|
||||
j := len(polygon) - 1
|
||||
for i := 0; i < len(polygon); i++ {
|
||||
xi, yi := polygon[i][1], polygon[i][0]
|
||||
xj, yj := polygon[j][1], polygon[j][0]
|
||||
if (yi > lat) != (yj > lat) &&
|
||||
lng < (xj-xi)*(lat-yi)/(yj-yi)+xi {
|
||||
inside = !inside
|
||||
}
|
||||
j = i
|
||||
}
|
||||
return inside
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 围栏方案选择
|
||||
> - **圆形围栏**:直接使用 GEO 命令,零额外开发成本。
|
||||
> - **矩形围栏**:使用 `GEOSEARCH ... BYBOX` 指定宽高。
|
||||
> - **不规则多边形**:先用 GEO 圈定一个粗略范围缩小候选集,再用射线法精确判断,兼顾性能与精度。
|
||||
|
||||
### 6. 附近门店搜索流程
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["客户端发送请求"] --> B["获取用户经纬度"]
|
||||
B --> C["GEOSEARCH 搜索半径 R"]
|
||||
C --> D{"结果数量 > 0?"}
|
||||
D -->|No| E["扩大半径 R += step"]
|
||||
E --> C
|
||||
D -->|Yes| F["返回门店列表及距离"]
|
||||
F --> G["客户端渲染附近门店"]
|
||||
```
|
||||
|
||||
### 7. 性能与限制
|
||||
|
||||
> [!question] GEO 能存多少数据?
|
||||
> GEO 底层是 ZSet,受 ZSet 最大容量限制:单个 key 最大约 **150GB**(约 $2^{32}$ 个元素),足以存储数十亿个地理位置。
|
||||
|
||||
| 限制项 | 说明 |
|
||||
|-------|------|
|
||||
| 单 key 最大元素数 | ~$2^{32}$(约 43 亿) |
|
||||
| 单 key 最大内存 | ~150GB(ZSet 限制) |
|
||||
| GeoHash 精度 | 52-bit,约 0.019m |
|
||||
| 最小搜索半径 | 0.000001(单位取决于指定) |
|
||||
| 经度范围 | -180 ~ 180 |
|
||||
| 纬度范围 | -85.05112878 ~ 85.05112878 |
|
||||
|
||||
**性能建议**:
|
||||
|
||||
- 搜索操作时间复杂度为 $O(N + \log M)$,其中 $N$ 为结果集大小,$M$ 为 key 中元素总数。大 key 下应控制搜索半径和 COUNT。
|
||||
- 如果只需坐标而不需要距离计算,可用 `GEOPOS` 代替 `GEOSEARCH` 的 `WITHDIST`,减少计算开销。
|
||||
- 对于超高并发场景,考虑将 GEO 数据加载到应用进程内存中做本地计算,减轻 Redis 压力。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/02-核心数据类型]]
|
||||
- [[hhs/Redis/08-SortedSet精解]]
|
||||
- [[hhs/Redis/README]]
|
||||
+6
-6
@@ -34,7 +34,7 @@ create time: 2026-05-15 18:10
|
||||
|---|------|------|
|
||||
| 2.1 | RDB 快照 | [[hhs/Redis/04-RDB持久化]] — fork 原理、COW、触发时机 |
|
||||
| 2.2 | AOF 追加日志 | [[hhs/Redis/05-AOF持久化]] — everysec 策略、重写流程 |
|
||||
| 2.3 | 混合持久化 | Redis 7 特性:RDB 快照 + AOF 增量 = 快 + 准 |
|
||||
| 2.3 | 混合持久化 | [[hhs/Redis/04-RDB持久化#Redis 混合持久化(推荐)]] — RDB 快照 + AOF 增量 = 快 + 准 |
|
||||
| 2.4 ~ 2.5 | 主从复制与 Sentinel | [[hhs/Redis/06-主从与哨兵]] — 全量/增量同步、故障转移流程 |
|
||||
| 2.6 | Cluster 集群 | [[hhs/Redis/07-集群方案]] — 哈希槽、Gossip、扩容迁移 |
|
||||
|
||||
@@ -57,11 +57,11 @@ flowchart LR
|
||||
| # | 主题 | 链接 |
|
||||
|---|------|------|
|
||||
| 3.1 | Sorted Set 高级玩法 | [[hhs/Redis/08-SortedSet精解]] — 排行榜、延迟队列、Geo、优先级队列 |
|
||||
| 3.2 | Bitmap | 签到、DAU 统计——每位一天,极致省内存 |
|
||||
| 3.3 | HyperLogLog | 亿级 UV 去重统计,误差 < 0.1%,仅占 12KB |
|
||||
| 3.4 | Bloom Filter | 防缓存穿透、URL 去重——false positive 可接受 |
|
||||
| 3.5 | Stream | 可靠消息队列,Consumer Group 消费组模型 |
|
||||
| 3.6 | GEO | 附近的人、距离计算、围栏检测——底层就是 ZSet |
|
||||
| 3.2 | Bitmap | [[hhs/Redis/12-Bitmap]] — 签到、DAU 统计——每位一天,极致省内存 |
|
||||
| 3.3 | HyperLogLog | [[hhs/Redis/13-HyperLogLog]] — 亿级 UV 去重统计,误差 < 0.1%,仅占 12KB |
|
||||
| 3.4 | Bloom Filter | [[hhs/Redis/14-BloomFilter]] — 防缓存穿透、URL 去重——false positive 可接受 |
|
||||
| 3.5 | Stream | [[hhs/Redis/15-Stream]] — 可靠消息队列,Consumer Group 消费组模型 |
|
||||
| 3.6 | GEO | [[hhs/Redis/16-GEO]] — 附近的人、距离计算、围栏检测——底层就是 ZSet |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
|
||||
@@ -1,601 +0,0 @@
|
||||
在分布式系统中,为了解决单点问题,通常会把redis服务部署到多个服务器上,满足故障恢复和[负载均衡](https://cloud.tencent.com/product/clb?from_column=20065&from=20065)等请求。
|
||||
|
||||
所谓的单点问题,就是某个服务器程序,只有一个节点(只有一个[物理服务器](https://cloud.tencent.com/product/cbm?from_column=20065&from=20065),来部署这个服务器程序)。
|
||||
|
||||
存在的问题:
|
||||
|
||||
- 可用性问题:如果这个机器挂了,那么部署的redis服务就挂了,服务就中断了。
|
||||
- 性能/支持的并发量有限:毕竟一台主机的硬件资源(比如CPU,硬盘,网络带宽等等)是有限的,一个请求就要消耗一定量的硬件资源。一旦并发量太高,如果请求超过主机所能提供这些资源,那么可能就会出现异常,甚至主机直接宕机了。
|
||||
|
||||
在分布式系统中,往往希望有多个服务器来部署redis服务,从而构成一个redis集群,此时就可以让这个集群给分布式系统中的其他服务,提供更稳定/更高效的数据存储功能 。
|
||||
|
||||
主要存在以下几种部署方式:
|
||||
|
||||
- 主从模式
|
||||
- 主从+哨兵模式
|
||||
- 集群模式
|
||||
|
||||
### 一,主从复制
|
||||
|
||||
##### 1,配置主从复制
|
||||
|
||||
**主从模式,即在若干个redis节点中,有的是主节点,有的是从节点 。**
|
||||
|
||||
**redis主从模式中,从节点上的数据,不允许修改,只允许读取数据。要想修改数据,只能访问主节点。**
|
||||
|
||||
假设有三个物理服务器(三个节点),都部署了redis服务,此时就可以把其中一个看作是主节点,其余两个看作是从节点。从节点上的数据要跟随主节点的数据而变化,从节点的数据要和主节点保持一致。
|
||||
|
||||
本来,在主节点上保存了一堆数据,现在引入从节点,就是要把主节点上的数据复制出来,放到从节点中。后续,主节点这里对数据有任何修改,都会把这样的修改同步给从节点。
|
||||
|
||||

|
||||
|
||||
总结:主从模式,主要是针对读操作进行可用性和并发量的提高,而对于写操作来说,无论是可用性还是并发量,都很依赖与主节点,但是主节点又不能搞多个。
|
||||
|
||||
---
|
||||
|
||||
如何在一个云服务器上部署多个redis服务,按照主从模式,实现类似于分布系统的效果?(ubutun20.04)
|
||||
|
||||
我们可以在一个云服务器主机上,运行多个redis-server进程,我们只需保证这几个服务的端口号不同即可。本来redis-server的默认端口号是6379,此时新启动的redis-server端口号就不能是6379了。
|
||||
|
||||
在这里我们创建两个从节点,端口号是6380和6381,而主节点就是原先的redis-server,端口号为默认的6379。
|
||||
|
||||
可以通过修改配置文件来改变:
|
||||
|
||||
1,首先需要将配置文件拷贝两份
|
||||
|
||||
`cp /etc/redis/redis.conf slave1.conf`
|
||||
|
||||
`cp /etc/redis/redis.conf slave2.conf`
|
||||
|
||||
2,然后修改拷贝后的两个配置文件中的选项
|
||||
|
||||
修改端口号——`port 6380 port6381`
|
||||
|
||||
将该服务设置为后台进程——`daemonize yes`
|
||||
|
||||
只需修改这两个选项即可。
|
||||
|
||||
3,启动这两个redis-server(从节点)
|
||||
|
||||
`redis-server slave1.conf`
|
||||
|
||||
`redis-server slave2.conf`
|
||||
|
||||
4,现在我们只是有了多个redis-server,还没有构成主从结构。
|
||||
|
||||
要想配置成主从结构,就需要使用slaveof。有如下三个方法:
|
||||
|
||||
- 在配置文件中加入`slaveof{masterHost} {masterPort}`,之后随redis启动生效。
|
||||
- 在redis-server启动命令中加入--slaveof{masterHost}{masterPort}生效。
|
||||
- 直接使用redis命令:slaveof{masterHost}{masterPort}生效。
|
||||
|
||||
其中{masterHost}和{masterPort}分别指主节点的ip和端口。
|
||||
|
||||
5,在这里我们选择通过修改配置文件的方式来构成主从结构
|
||||
|
||||
在slave1.conf和slave2.conf配置文件中加上slaveof 127.0.0.1 6379即可,以6379的redis-server作为主节点。配置文件修改完毕之后,需要我们重新启动redis-server,配置文件才会生效,所以此时需要重启端口号为6380和6381的redis-server。
|
||||
|
||||
由于我们启动redis-server服务时,是使用`redis-server port`这样的方式启动的,所以在重启的时候,需要使用`kill -9`来杀掉该服务进程,然后再通过redis-server port重启即可。
|
||||
|
||||
而如果我们是使用`service redis-server start`来启动服务器,就需要使用`service redis-server stop`来终止程序。此时如果使用kill -9来终止服务进程,kill掉之后,这个redis-server进程能自动启动 。相当于有一个进程来专门监控指定服务器的运行状态,如果服务器挂了,会立即重新启动。
|
||||
|
||||
因此怎么启动的redis-server,就必须搭配对应的方式进行终止。
|
||||
|
||||
6,再次启动redis-server即可实现主从模式
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
##### 2,断开主从复制
|
||||
|
||||
使用命令slaveof no one可以断开主从关系,这只是暂时的,当redis-server重启之后,主从关系就会恢复,所以要想一直断开主从关系,还是需要修改配置文件。
|
||||
|
||||
##### 3,拓扑
|
||||
|
||||
我们的redis-server服务器可能有很多个节点,如何组织这些节点?
|
||||
|
||||
###### 一主一从结构
|
||||
|
||||

|
||||
|
||||
如果其他客户端想要读取数据,从主节点A或 从节点B读取都可以,两个服务器上的数据是一致的。
|
||||
|
||||
如果是写数据请求,那么只能由主节点A来完成。
|
||||
|
||||
如果此时写请求太多,也会给主节点造成 一定的压力。我们可以通过关闭主节点的AOF,毕竟写内存比写磁盘快,只打开从节点的AOF。但是这种设定,有一个严重缺陷,主节点一旦挂了,不能立即重启,因为主节点这边没有使用AOF保存数据,如果重启了,那么数据就会丢失,进一步的主从同步,也就使从节点上数据也丢失了。改进办法是,当主节点挂了,先从从节点那里获取AOF文件,再启动,这样就可以保证数据没有问题了。
|
||||
|
||||
###### 一主多从结构
|
||||
|
||||

|
||||
|
||||
同理,如果是读请求,可以访问主节点,也可以访问从节点。
|
||||
|
||||
如果是写请求 ,是能访问主节点,主节点上的数据发生变化,就会把改变的数据同步给其他从节点。
|
||||
|
||||
这个结构存在的问题:同步操作是需要消耗网络带宽的,如果子节点太多,就会大大消耗主节点主机的硬件资源。
|
||||
|
||||
###### 树形结构
|
||||
|
||||

|
||||
|
||||
主节点将数据同步给从节点B和从节点C,再由从节点B将数据同步给从节点D和从节点E。
|
||||
|
||||
此时主节点A就不需要太高的网络带宽,但此时数据同步的延时会更长。
|
||||
|
||||
##### 4,原理
|
||||
|
||||

|
||||
|
||||
同步数据的命令:**`PSYNC replicationid offset`**,这个命令是从节点执行的。
|
||||
|
||||
其中replicationid是复制id,是主节点生成的,主节点在启动的时候就会生成。
|
||||
|
||||
当从节点和主节点建立了复制关系,就会从主节点上 获取到这个id,这个id就表明了当前从节点是从哪个主节点上获取的数据。
|
||||
|
||||
在主节点和从节点上一般都有两个replicationid:replicationid和replicationid2。其中replicationid2其备份的作用,比如下面的例子。
|
||||
|
||||
> 现在有一个主节点A和一个从节点B,A在启动的时候会生成一个replicationid,之后B获取到A的replicationid。如果A和B通信过程中出现了一些网络抖动,B可能会认为A挂了,此时从节点B就会晋升为主节点,并给自己生成一个replicationid,此时B的replicationid2就保存了之前获取到的A的replicationid。等到网络稳定,B还可以根据这个replicationid2再次与节点A建立主从关系。(但是主从复制这种方法,当主节点挂了,一般从节点是不会晋升为主节点的) 这个再次建立主从关系的过程,一般是需要手动完成的。当然,在下面的哨兵机制部分,可以自动完成这个过程。
|
||||
|
||||
offset表示偏移量
|
||||
|
||||
主节点和从节点都会维护这个偏移量(一个整数)。
|
||||
|
||||
主节点的偏移量:主节点可能会收到很多的修改操作的命令,每个命令都选哟占据几个字节。主节点会把这些命令的字节数进行累加,这个数字就是主节点的偏移量。
|
||||
|
||||
从节点的偏移量:描述了当前数据同步的进度,从节点会每秒上报自身的偏移量给主节点。
|
||||
|
||||
如果从节点和主节点的偏移量一致,就表述数据 同步完成了。
|
||||
|
||||
**综上,replicationid描述了从哪个主节点同步数据,offset表示同步数据的进度。如果两个机器的replicationid和 offset一样,就表示这两个机器上的数据一致。**
|
||||
|
||||
**replicationid和offset就共同描述了一个"数据集合"。**
|
||||
|
||||
###### PSYNC工作流程
|
||||
|
||||
PSYNC可以从主节点获取全量数据,也可以获取部分数据。
|
||||
|
||||
主要看offset的值,如果设为-1,表示全量获取,如果设置为具体的整数,则表示从当前整数位置来进行获取。
|
||||
|
||||
当然,从节点想要全量的获取数据,还是增量的获取数据,同时也取决于主节点,主节点会自行判定,看当前是否方便给部分数据,如果不方便,会直接给 全量数据。
|
||||
|
||||

|
||||
|
||||
###### 全量复制的流程
|
||||
|
||||
在一个从节点与主节点第一次建立主从关系的时候,就会涉及到数据的同步,而此时一般就是进行全量复制。
|
||||
|
||||

|
||||
|
||||
在进行全量复制的时候,主节点要进行生成rdb文件的操作,然后再把该文件通过网络传输发送给从节点,从节点也是先将该rdb文件保存,然后读取该文件来获取数据。
|
||||
|
||||
而redis也支持"无硬盘模式"(diskless):主节点生成的rdb的二进制数据,不直接保存到文件中,而是直接通过网络传输发送给从节点 ,省去了读写硬盘的操作,而从节点现在也可以省去这个操作了,直接将收到数据加载到内存中......
|
||||
|
||||
但是,即使引入了"无硬盘模式",对全量复制的效率提升不是很大,因为全量复制整个操作是比较重量的,数据规模较大。相比于网络传输,读写硬盘操作算是快的了。所以减少了对硬盘的操作,但网络传输无法省去,意味着对整个操作的效率提升不大。
|
||||
|
||||
###### 部分复制流程
|
||||
|
||||
在主节点与从节点连接的过程中,可能由于网络抖动等原因,主从节点的连接断开,此时重新建立连接并进行数据同步的时候,使用的就是部分复制。
|
||||
|
||||

|
||||
|
||||
###### 实时复制
|
||||
|
||||
全量复制:主要适用于从节点刚连上主节点进行数据初始化的工作。
|
||||
|
||||
部分复制:是全量复制的一种特殊情况,属于全量复制的一种优化。
|
||||
|
||||
实时复制:在从节点与主节点建立好连接,并且完成数据同步之后,此时主节点可能会受到源源不断的请求,其中就会包含修改数据的请求,主节点的数据就会发生变化,此时就要把数据也同步给从节点。从节点和主节点之间建立有Tcp连接,当主节点收到修改数据的请求,就会通过该Tcp连接,将这个请求发送给从节点,从节点再根据这些请求修改数据即可。
|
||||
|
||||
所以在实时复制的时候,我们需要保证主节点与从节点的Tcp连接处于可用状态。在这里,使用**心跳包机制**来实现。
|
||||
|
||||
> **心跳包机制:** 主节点:默认每隔10s,向从节点发送一个ping命令,从节点收到后会返回一个"pong"。 从节点:默认每隔1s,向主节点发送一个特定的请求,告诉主节点当前的同步进度(offset),之后主节点也会给出一个响应。 如果,达到某个阈值,还没有收到响应,那么就认为这个主节点/从节点存在问题,判断下线了。
|
||||
|
||||
##### 5,总结
|
||||
|
||||
主从复制解决的问题:单点问题
|
||||
|
||||
单点问题:单个redis节点,可用性不高,性能有限。
|
||||
|
||||
主从复制的特点:
|
||||
|
||||
1,主节点可以用来读写,从节点只能用来读,可以减少主节点的访问压力。
|
||||
|
||||
2,主从复制存在多种拓扑结构:可以在适当的场景使用适当的拓扑结构,比如一主多从的结构,同步操作快,但是消耗的网络资源多,因为主节点要通过网络和所有从节点实现同步。而树形结构,主节点消耗的网络资源减少了,但是如果树的层级太高,会造成数据同步的延迟增长加。
|
||||
|
||||
3,复制分为全量复制,部分复制和实时复制。
|
||||
|
||||
4,通过心跳机制保证主节点和从节点的正常通信和数据一致。
|
||||
|
||||
主从复制的缺点:
|
||||
|
||||
1,从节点多了,数据复制的延时就会非常明显。
|
||||
|
||||
2,如果主节点挂了,从节点不会晋升为主节点,需要通过人工干预的方式恢复。
|
||||
|
||||
### 二,哨兵(Sentinel)
|
||||
|
||||
主从复制最大的问题,还是在主机点上,如果主节点挂了,从节点不会晋升为主节点,需要通过人工干预的方式恢复。
|
||||
|
||||
因此Redis哨兵机制,就是为了解决上述问题,自动的对挂了的主节点进行替换,也就是将上述手动的过程改成自动。
|
||||
|
||||
哨兵机制是通过启动一个不同的进程来体现的,它和redis-servver服务器进程不是 同一个进程 。
|
||||
|
||||
##### 1,相关名词解释
|
||||
|
||||
|名词|逻辑结构|物理结构|
|
||||
|---|---|---|
|
||||
|主节点|Redis服务|一个独立的redis-server进程|
|
||||
|从节点|Redis服务|一个独立的redis-server进程|
|
||||
|Redis数据节点|主从节点|主节点和从节点的进程能|
|
||||
|哨兵节点|监控Redis数据节点的节点|一个独立的redis-sentinel进程|
|
||||
|哨兵节点集合|若干哨兵节点构成的整体|若干redis-sentinel进程|
|
||||
|Redis哨兵(Sentinel)|Redis提供的高可用方案|哨兵节点集合和Redis主从节点|
|
||||
|应用方|一个或多个客户端|一个或多个连接Redis的进程|
|
||||
|
||||
##### 2,哨兵自动恢复主节点故障
|
||||
|
||||

|
||||
|
||||
如果一个哨兵节点发现主节点挂了,为了防止预判,还需多个哨兵节点共同认为这件事情。
|
||||
|
||||
如果主节点确实挂了,这些哨兵节点会选出一个作为leader,由这个哨兵节点负责从剩下的从节点中选一个出来,作为主节点。选出新的主节点之后,哨兵节点就会控制该节点执行slaveof no one,并且通知其他从节点,修改slaveof到新的主节点之上。哨兵节点会自动通知客户端程序,告知新的主节点是谁,并且此后客户端再进行写操作时,就会访问新的主节点了。
|
||||
|
||||
##### 3,哨兵机制的特点
|
||||
|
||||
监控:Sentinel节点会定期检查redis数据节点,使用心跳包机制。
|
||||
|
||||
故障转移:实现从节点晋升为主节点,并维护好正确的主从关系。
|
||||
|
||||
通知:Sentinel会将故障转移的结果通知给应用方。
|
||||
|
||||
##### 4,主从切换的具体流程
|
||||
|
||||
_**重点,面试题:**_
|
||||
|
||||
1,主观下线:哨兵节点通过心跳包进制,判读redis主节点服务器是否正常工作,如果没有沙鸥到响应,该哨兵节点就会认为该主节点下线了。
|
||||
|
||||
2,客观下线:当多个哨兵节点都认为主节点挂了之后,此时这个主节点就是主观下线了。
|
||||
|
||||
3,再从多个哨兵节点中,选举出一个leader节点,由这个leader节点负责从剩下的节点中选出一个作为主节点。
|
||||
|
||||
4,leader挑选完毕后,此时需要从剩下的从节点中选一个当作新的主节点。
|
||||
|
||||
- 首先会看这些从节点的优先级,每个 redis数据节点,都会有自己的配置文件,配置文件中就有一个优先级的设置。leader会选择优先级高的作为新的主节点。
|
||||
- 如果数据节点的优先级都一样,那么就比较这些数据节点的offset,offset表示从节点与原来主节点进行数据同步的进度,offset越大,说明这个节点与原来主节点的数据相似度最高,此时就会选择这个节点作为新的主节点。
|
||||
- 如果前面两个条件都一样,此时其实意味着从这些节点中任选一个都行。那么再看这些节点的runid(一串数字),每个redis数据节点启动时都会生成一个随机的runid,此时比较runid的大小。让runid更小的作为新的主节点。
|
||||
- 指定好新的主节点之后,此时leader就会控制这个主节点执行slaveof no one,让这个节点成为主节点(master)。再控制其他从节点,执行slaveof,让这些从节点,以新的mater作为主节点。
|
||||
|
||||
##### 5,总结
|
||||
|
||||
哨兵节点不能只有一个,因为哨兵节点挂了也会影响 系统的运作。
|
||||
|
||||
哨兵节点最好是奇数个,方便选举leader,得票数更容易超过一半。
|
||||
|
||||
哨兵+主从复制解决的问题是"提高可用性",极端情况下写操作的数据丢失无法解决。
|
||||
|
||||
哨兵+主从复制不能提高数据的存储容量 ,当数据接近或者几乎超过机器的物理内存时,这样的结构就难以胜任了,**而接下来的redis集群,就是解决存储容量问题的有效方案。**
|
||||
|
||||
### 三,集群
|
||||
|
||||
**广义上的集群,是指多个机器,构成的分布式系统,就可以称为一个集群,所以前面的主从复制和哨兵模式也可以看作是一个集群。**
|
||||
|
||||
**而侠义上的集群,是redis提供的集群模式。这个集群模式之下,主要是解决存储空间不足的问题。**
|
||||
|
||||
在redis哨兵模式中,本质还是redis数据节点存储数据,其中就要求主节点/从节点来存储数据的全集。为了提高 数据存储的容量,这时就引入多台机器,每台机器只存储一部分数据。
|
||||
|
||||
> 假设有1TB的数据需要存储: 拿两台机器来存储 ,每台机器需要存储512GB。 拿三台机器来存储,每台机器需要存储300多GB。 拿四台机器来存储,每台机器需要存储 256GB......
|
||||
|
||||
但是还存在一个问题,如果使用三台机器来存储1TB的数据,如果某个机器挂了怎么办,所以我们还需要为每个机器 再分配几个从节点。
|
||||
|
||||

|
||||
|
||||
这三组机器的数据都是不同的,每个 slave都是mster的备份,当master挂了,slave就会晋升为master。
|
||||
|
||||
如上图所示,每个虚线框就可以看作是一个分片(Sharding)。
|
||||
|
||||
_**重点面试题:**_
|
||||
|
||||
三种主流的分片算法:哈希求余,一致性哈希算法,哈希槽分区算法。
|
||||
|
||||
##### 1,哈希求余
|
||||
|
||||
借助hash函数,把一个key映射成为 一个数字,在对数组长度(这里就是分片的个数)进行求余,就可以得到这个key是在哪个分片中。
|
||||
|
||||
比如现在有3个分片,编号为0,1,2
|
||||
|
||||
此时就可以针对要查询的数据(或插入的数据)计算hash值(比如可以使用md5算法),再把这个值余上分片的个数,此时就会得到一个数字,这个数字就表示这个数据在哪个分片中。
|
||||
|
||||
但是当总体的数据增长时,就需要扩容,引入更多的分片,此时分片的个数就变了。
|
||||
|
||||
如果发现某个数据在扩容之后,不该待在当前的分片中,那么就需要重新分配数据(数据搬运)。这里涉及到的数据搬运不仅仅是主节点进行,从节点也需要进行。
|
||||
|
||||
这种方式开销极大,往往不能再生产环境上操作的,搬运成本比较大。
|
||||
|
||||
**数据搬运成本大的原因:这种哈希求余的方式,导致数据是交替出现的,比如100出现在0号分片,101出现在1号分片,102出现在2号分片,103又是0号分片 。这就导致在扩容之后,分片个数增长,就会有 大量的数据需要进行搬运。**
|
||||
|
||||
##### 2,一致性哈希算法
|
||||
|
||||
这种方式可以降低上述的"搬运开销"。
|
||||
|
||||
key映射到分片序号的过程不再是简单的求余了,而是改成以下过程:
|
||||
|
||||
第一步:把0~2^32-1这个数据空间,映射到一个圆环上,按照顺时针方向增长。
|
||||
|
||||

|
||||
|
||||
第二步:假设分成3个分片,将分片放到对应的位置上
|
||||
|
||||
每个分片就会对应一个值,比如0号分片对应的值就是0
|
||||
|
||||

|
||||
|
||||
第三步:假定有一个key,计算得到的hash值为H,如何计算这个key是在哪个分区?此时H会在圆环上的某个位置,从这个位置开始,顺时针向下找,找到的第一个分片,就是这个key所属 的分片。
|
||||
|
||||

|
||||
|
||||
这就相当于,N个分片,把整个圆环分成N个区域,key的hash值落在哪个区域,它就属于哪个分片。
|
||||
|
||||

|
||||
|
||||
因此在 一致性哈希这样的设定下,把数据交替出现,改进成了连续出现。
|
||||
|
||||
在这种情况下,如何进行扩容,假设新增一个分片。
|
||||
|
||||
如下图所示,在圆环上找一个位置,设为3号分片的位置。该部分本来是0号分片上的,这样一来,只需将0号分片上的这段数据搬运到3号分片上即可,其他分片上的数据不需要搬运。
|
||||
|
||||
这种搬运的成本是有的,但是比之前哈希求余的方式低了不少。
|
||||
|
||||

|
||||
|
||||
这种方式,虽然搬运的成本降低了,但是也导致了各个分片上的数据量不均匀,称作数据倾斜。
|
||||
|
||||
##### 3,哈希槽分区算法
|
||||
|
||||
这是Redis真正采用的分片算法。
|
||||
|
||||
哈希槽计算公式:hsah_slot=hash(key)%16384,一共有16384个槽。
|
||||
|
||||
假设现在有3个分片,一种分配方式如下:
|
||||
|
||||
- 0号分片:[0,5461],共5462个槽位。
|
||||
- 1号分片:[5462,10923],共5462个槽位。
|
||||
- 2号分片:[5463,16384],共5460个槽位。
|
||||
|
||||
这里只是分片的一种,分片可以很灵活。每个分片持有的槽位号:可以是连续的,也可以是不连续的。
|
||||
|
||||
此处 ,每个分片都会使用一个位图结构,来表示该分片有多少槽位号,16384个bit位,用每一位的0/1表示是否持有这个槽位。
|
||||
|
||||
现在假设要新增一个分片,那么此时可以从0号分片,1号分片,2号分片上分别截取一部分出来,放到新的分片上,这样就可以解决数据倾斜的问题。
|
||||
|
||||
##### 4,故障处理
|
||||
|
||||
如果某个主节点挂了,此时就会把该主节点旗下的某一个从节点提拔为主节点,保证我们的redis能够正常工作。
|
||||
|
||||
###### 故障判定
|
||||
|
||||
识别某个节点是否挂了。
|
||||
|
||||
1. 节点A给节点B发送ping包,B就会给A返回一个pong包。ping和pong除了携带message type属性之外,其他部分都是一样的。还会包含集群的配置信息(该节点的id,该节点属于哪个分片,该节点是主节点还是从节点,从属于哪个主节点,持有哪些slot槽位)。
|
||||
2. 每个节点,每秒钟都会给一些 随机的节点发送ping包,而不是给所有节点都发送。这样设定设为了在节点很多的时候,心跳包也会非常多。
|
||||
3. 当节点A向节点B发送ping包后,B不能如期回应的时候,此时A就会尝试重置和B的TCP连接,看能否连接成功,如果连接失败,就会认为B节点下线了,A就会把B设为PFAIL状态(相当于主观下线)。
|
||||
4. A判定B为PFAIL后,会通过redis内置的Gossip协议,和其他节点进行沟通,向其他节点确认B的状态。(每个 节点都会维护一个自己的下线列表,由于视角不同,每个节点的下线列表也就不同)。
|
||||
5. 此时A发现很多节点也认为B为PFAIL,并且数目超过集群节点个数的一半,那么A就会把B标记为PFAIL(相当于客观下线),并且把这个消息同步给其他节点(其他节点收到后,也会把B标记为PFAI)。
|
||||
|
||||
以下三种情况会出现集群宕机:
|
||||
|
||||
- 某个分片,所有的主节点和从节点都挂了。
|
||||
- 某个分片,主节点挂了,没有从节点。
|
||||
- 整个集群超过一半的主节点挂了。
|
||||
|
||||
###### 故障迁移
|
||||
|
||||
还是上述的例子。
|
||||
|
||||
如果B是从节点挂了,那么就不需要进行故障迁移,毕竟从节点挂了,还可以通过访问同一个 分片内的主节点或者其他从节点来获取数据。
|
||||
|
||||
如果B是主节点,就会由B的从节点(比如C和D)发生故障迁移。重新挑选一个主节点,代替之前主节点的位置。
|
||||
|
||||
具体过程如下:
|
||||
|
||||
1. 从节点需要判断自己是否具有参选资格,如果主节点和从节点太久没有进行通信(此时认为主节点和从节点的数据差异太大了),就失去竞选资格。
|
||||
2. 具有资格的节点,比如C和D,就会先休眠一段时间,休眠时间=500ms基础时间+【0,500ms】随机事件+排名*1000ms。offset值越大,排名就越靠前(越小)。offset表示主节点和从节点数据同步的进度。
|
||||
3. 比如C的休眠时间到了,C就会给集群中其他所有节点,进行拉票操作。但是只有主节点才有投票资格。
|
||||
4. 主节点就会把自己的票投给C节点(每个主节点只有一票),当C收到的票数超过主节点数目的一半,C就会晋升为主节点(C会自己执行slaveof no one,并且让D执行slaveof D)。
|
||||
5. 同时,C还会把自己称为主节点的消息,同步给集群中的其他节点,大家也都会更新自己保存的集群信息结构。
|
||||
|
||||
总之,哪个节点会成为主节点,就看哪个节点先被唤醒,哪个节点的休眠时间短,大概率就是新的主节点。
|
||||
|
||||
如果两个节点被唤醒的时间是差不多的,那么此时就各凭本事了,取决于网络延迟,线程调度等等因素。
|
||||
|
||||
**上述选举的过程,称为Raft算法。**
|
||||
|
||||
### 四,Redis典型应用——缓存
|
||||
|
||||
Redis最主要的三个用途:
|
||||
|
||||
1. 存储数据(内存数据库)
|
||||
2. 缓存
|
||||
3. 消息队列
|
||||
|
||||
##### 1,使用redis作为数据库的缓存
|
||||
|
||||
在一个网站中,通常会使用[关系型数据库](https://cloud.tencent.com/product/tencentdb-catalog?from_column=20065&from=20065)(如MySQL)来存储数据,关系型数据库虽然强大,但是有一个很大的缺陷,就是性能不高。(换言之 ,进行一次 查询操作消耗的系统资源较多)。
|
||||
|
||||
> 为什么说关系型数据库的性能不高?
|
||||
|
||||
1. 数据库是把数据存储在硬盘上的,硬盘的IO速度并不快,尤其是随机访问。
|
||||
2. 如果查询不能命中索引,就需要进行表的遍历,这就会大大增加 IO的次数。
|
||||
3. 关系型数据库对SQL的操作会进行一系列的解析,校验,优化工作。
|
||||
4. 如果是一些复杂查询,比如联合查询,需要进行笛卡尔积的操作,效率更是降低很多。
|
||||
|
||||
因为MySQL等数据库 ,效率比较低,所以承担的并发量就有限了,一旦请求量多了,数据库的压力就很大,甚至很容易就宕机了。对于服务器的每一个请求,都要消耗一定的硬件资源(CPU,内存,硬盘,网络带宽等等),任意一种资源的消耗超出了机器能提供的上限,机器就很容易出故障。
|
||||
|
||||
如何提高MySQL能承担的并发量?
|
||||
|
||||
1. 开源:引入更多的机器,构成数据集群。
|
||||
2. 节流:引入缓存,将一些热点数据保存到缓存中。后续在查询数据的时候,如果数据库中已经存在了,就不再访问MySQL了。
|
||||
|
||||
##### 2,如何知道redis中应该存储哪些数据?
|
||||
|
||||
也就是怎么获得热点数据。
|
||||
|
||||
这涉及到缓存的两种更新策略:1,定期生成 2,实时生成
|
||||
|
||||
1,定期生成
|
||||
|
||||
将访问的数据 ,以日志的 形式记录下来。接下来就可以针对这些日志进行统计了,统计这一天/一周/一个月,数据出现的频率,然后再按照降序排序,取出前20%的数据数据,这些数据 就是热点数据。
|
||||
|
||||
优点:上述过程,实际上实现起来比较简单,过程更可控,缓存中的数据是比较扶额和预期的,方便排查问题。
|
||||
|
||||
缺点:实时性不够。如果出现一些突发事件,有些本来不是热词的内容成了热词,这就可能会给后面的数据库带来较大的压力。
|
||||
|
||||
2,实时生成
|
||||
|
||||
- 如果在redis中查到了数据,就直接返回。
|
||||
- 如果没有查到,就从数据库查,同时把查到的数据写入redis中。
|
||||
|
||||
这里就会有一个问题,如果不停的向redis中写入数据,就会使redis的内存占用越来越高,逐渐达到内存上限。
|
||||
|
||||
> 此时如果继续向redis中写入数据,就会出现问题,为了解决这个问题,redis就引入了"内存淘汰策略"。 _**经典面试题:**_
|
||||
|
||||
- FIFO(First In First Out)先进先出:把缓存中存在时间最久的(也就是先来的数据)淘汰掉。
|
||||
- LRU(Least Recently Used)淘汰最近未使用的:记录每个key的最近访问时间,把最近访问时间最老的key淘汰掉
|
||||
- LFU(Least Frequently Used)淘汰访问次数最少的:记录每个key最近一段时间的访问次数,把访问次数最少的淘汰掉。
|
||||
- Random 随机淘汰:从所有的key中随机抽取一个淘汰掉。
|
||||
|
||||
##### 3,缓存预热,缓存穿透,缓存雪崩和缓存击穿
|
||||
|
||||
**缓存预热(Cache preheating)**
|
||||
|
||||
缓存中的数据,有两种更新策略:**1,定期生成 2,实时生成**
|
||||
|
||||
1. 如果使定期生成,就不涉及到预热。
|
||||
2. 如果是实时生成,在redis服务首次接入之后,服务器里是没有数据的,此时客户端的所有请求就都会打给MySQL,如果请求量太多,可能就会导致MySQL服务挂了。随着时间的推移,reids中的数据越来越多,MySQl承担的压力也就越来越小了。
|
||||
|
||||
缓存预热,就是为了解决上述问题。把定期生成和实时生成相结合,先通过离线的方式,通过一些统计的途径,先找到一批热点数据,导入到redis中。此时导入的这批热点数据就能帮MySQL分担一些压力了。随着时间的推移,使用新的热点数据来淘汰旧的热点数据。
|
||||
|
||||
在刚开始架构演进的时候,没有缓存,此时要加入缓存,就要进行缓存预热。还有当服务器进行重启的时候,我们要保证重启之后缓存中是否有数据以及 这里的数据 是否是热点数据,这也涉及到缓存预热。
|
||||
|
||||
---
|
||||
|
||||
**缓存穿透(Cache penetration)**
|
||||
|
||||
在一次查询的过程中,如果要查询的某个key,在redis中没有,在MySQL中也没有。也就意味着此时这个key是不会被放到redis中,那么下次访问依然会访问数据库,这就会导致数据库承担的请求太多,压力很大。这种情况称为缓存穿透。
|
||||
|
||||
出现这种情况可能的原因:
|
||||
|
||||
- 业务设计不合理:比如缺少必要的参数检验环节,导致非法到的key也被进行查询了。
|
||||
- 开发/运维误操作:不小心把部分数据从数据库中删除。
|
||||
- 黑客恶意攻击。
|
||||
|
||||
解决方案:
|
||||
|
||||
1. 如果发现这个key,在redis和MySQL中都不存在在,仍然写入redis,将value设成一个非法值(比如"")。再应用层程序可以检查出这是一个非法的key。
|
||||
2. 还可以引入布隆过滤器,每次查询redis/MySQL之前,都先判断一下key是否在布隆过滤器上存在。布隆过滤器本质是结合了hash+bitmap,以较小的空间开销,以较快的访问速度,实现针对key是否存在的判定。
|
||||
|
||||
---
|
||||
|
||||
**缓存雪崩(Cache avalanche)**
|
||||
|
||||
由于在短时间内,redis上大规模的key失效,导致缓存命中率陡然下降,并且MySQL压力迅速上升,甚至导致MySQL直接宕机。
|
||||
|
||||
可能的原因:
|
||||
|
||||
- redis直接挂了,redis宕机/redis集群模式下很多节点宕机。(这是最主要的)
|
||||
- redis正常工作,但是可能之前短时间内设置了很多key给redis,并且设置的过期时间是相同的。在给redis里设置key作为缓存的时候,有的时候为了考虑时效性,就会设置过期时间(和redis的内存淘汰机制是配合使用的)。
|
||||
|
||||
解决方法:
|
||||
|
||||
- 加强监控报警,加强redis集群可用性的保证。
|
||||
- 不给key设置过期时间,或者在设置过期时间的时候,添加随机因子(避免同一时刻过期 )。
|
||||
|
||||
**缓存击穿(Cache breakdown)**
|
||||
|
||||
相当于缓存雪崩的特殊情况,针对**热点key**,突然过期了,导致大量的请求访问到数据库上,导致数据库宕机了。
|
||||
|
||||
解决方案:
|
||||
|
||||
1. 基于统计的方式发现热点key,并设置为永不过期。这种方案往往需要服务器做出较大的调整。比如把当前访问哪些key的日志记录下来,接到一个消息队列中,再通过一些计算,将结果再返回给我们的服务器。
|
||||
2. 进行必要的降级服务。例如访问数据库的时候,使用分布式锁,限制同时请求数据库的并发数。
|
||||
|
||||
### 五,分布式锁
|
||||
|
||||
在一个分布式系统中,会涉及到多个节点访问同一个公共资源的问题,此时就需要通过 锁 来做互斥控制,避免出现类似于 线程安全的问题。而C++中的std::mutex,这样的锁只能在当前进程中生效。
|
||||
|
||||
而在分布式系统中,是有很多进程的(每个服务器,都是独立的进程)。因此,之前的锁就难以对现在分布式系统中的多个进程之前产生制约。分布式系统中,多个进程之间的执行顺序也是不确定的。
|
||||
|
||||
此时就需要引入"分布式锁",来解决上述 问题。
|
||||
|
||||
**所谓的分布式锁,也是一个/一组单独的服务器程序,给其他的服务器提供"加锁"这样的服务。redis是一种典型的可以是实现分布式锁的方案,但不是唯一的一种。**
|
||||
|
||||

|
||||
|
||||
买票服务器在进行买票的过程中,就需要先加锁,就是往redis上尝试设置一个特殊的key-value,完成买票后,就会把这个key-value删掉。其他服务器在买票的过程中,也会去尝试设置这个key-value,如果发现key-value已经存在,就认为加锁失败(是放弃还是阻塞,就看具体的实现策略了)。
|
||||
|
||||
**这个加锁过程其实就对标redis中的一个命令setnx key val,这个命令如果key不存在才会设置,如果key存在就会执行出错,同时解锁过程也对标redis中的del key命令。**
|
||||
|
||||
##### 1,引入过期时间
|
||||
|
||||
问题1:某个服务器加锁成功了(setnx成功),如果该服务器执行后续逻辑的过程中,程序崩溃了,此时还没有执行到解锁操作。这种情况就会导致redis上的key无人删除,也就导致其他服务器无法获取到锁了。
|
||||
|
||||
解决办法:在加锁过程中,给这个key设置一个过期时间,set ex nx这样的命令来完成设置,时间到了,redis服务器会自动删除这个key,这是其他服务器就可以获取到锁了。
|
||||
|
||||
注意:在设置过期时间的时候,智能使用set nx ex这样的方式设置,不能使用set nx ,exprie这两个命令来设置。因为redis上多个命令之间,是无法保证原子性的,此时就可能出现,这两个命令,一个执行成功,一个执行失败。相比之下,使用一条命令设置,是更加稳妥的。
|
||||
|
||||
##### 2,引入校验id
|
||||
|
||||
问题2:所谓的加锁,就是给redis上设置一个key-val,所谓的解锁,就是给redis上的key-val删除掉。锁,就可以认为是redis上的一个普通键值对。可能会出现服务器1执行了加锁,而服务器2误执行了解锁。因此就可能给我们的系统带来严重的问题。(比如票数超卖)
|
||||
|
||||
为了解决这个问题,就引入了校验机制。
|
||||
|
||||
1. 给服务器编号,每个服务器都有自己的身份标识。
|
||||
2. 进行加锁的时候,设置key-val。**key对应着服务器要访问的资源,val表示服务器的编号。**
|
||||
3. 在解锁的时候,先查询这个锁对应的服务器编号,然后判定这个编号和执行解锁的服务器的编号是否一致,如果是,才能真正执行del;否则,执行失败。
|
||||
|
||||
##### 3,引入lua脚本
|
||||
|
||||
对于问题2,我们引入了校验id,但是还存在问题。就是在解锁的时候,需要两步操作,先获取到key对应的val,在执行del,此处是两步操作(不是原子的),就可能会出现问题。
|
||||
|
||||
一个服务器内部,也可能是多线程的,此时,就可能服务器A的两个线程都在执行解锁操作,首先进行id校验,都通过了,然后开始执行del命令,del就会被重复执行。
|
||||
|
||||
这看起来没有什么问题,但是如果此时一个线程执行完了del,又有一个服务器B来进行加锁(set nx ex),加锁成功,之后服务器A的另一个线程执行del,就会把服务器B的锁给解掉。
|
||||
|
||||
归根节点,是因为get 和 del这两个命令不是原子的,此时可以引入事务,将这两个操作打包成一个事务,使在执行get 和 del之间不会执行其他操作(避免插队)。
|
||||
|
||||
**使用事务,能解决上述问题,但是在实践中,往往推荐使用更好的方案——lua脚本。**
|
||||
|
||||
**redis执行lua脚本的过程 ,也是原子的,相当于执行一条命令一样。**
|
||||
|
||||
**在redis官方文档中,也明确说明了,lua就属于事务的替代方案。**
|
||||
|
||||
##### 4,引入看门狗(Watch Dog)
|
||||
|
||||
在前面提到过,服务器在进行加锁的时候,要给key设置一个过期时间。
|
||||
|
||||
- 这个过期时间,如果设置的太短,就可能在服务器的业务逻辑还未执行完,锁就释放了。
|
||||
- 如果设置的太长,也会导致"锁释放不及时"的问题。
|
||||
|
||||
**这里更好的方式是"动态续约"。**
|
||||
|
||||
**初始情况下,设置一个过期时间(比如设置1s),就提前在还剩300ms的时候(不一定是300ms,数值可以灵活调整),如果当前任务还未执行完,就把过期时间再续上1s。等到时间又快到了,任务还未执行完,就再续。**
|
||||
|
||||
这样设置也有一个好处:如果服务器中途崩溃了,也就没人续约了,此时,锁就可以再较短的时间内被释放。
|
||||
|
||||
服务器进行"动态续约"往往是需要有一个专门的线程来完成这个事情,这个线程就叫做**"看门狗"。**
|
||||
|
||||
##### 5,redlock算法
|
||||
|
||||
使用redis作为分布式锁,redis本身是有可能挂了的。
|
||||
|
||||
要想保证redis的高可用,可以使用主从复制,哨兵,集群模式等方案。这里使用哨兵机制最合适。
|
||||
|
||||
进行加锁操作,就是把key设置到设置到主节点上,如果主节点挂了,有哨兵节点会把从节点升级为主节点,进一步保证刚才的锁可用。
|
||||
|
||||
**但是主节点和从节点的数据同步是有延迟的,可能主节点收到了加锁的请求(set nx ex),还没来得及推送给从节点,主节点就挂了。即使从节点升级成为了主节点,但是刚才加锁的对应的数据是不存在的。**
|
||||
|
||||
**此时就需要使用 redlock算法。(redis作者给出的一种方案)核心思想:冗余,少数服从多数。**
|
||||
|
||||
此时加锁,就是按照一定的顺序,针对这些redis都进行加锁操作。如果某个主节点挂了(加不上锁),没关系,继续给下一个主节点加锁。如果加锁成功的主节点个数超过总结点总数的一半,就视为加锁成功。同理,进行解锁的时候,每个主节点都会进行一遍解锁。
|
||||
|
||||

|
||||
Reference in New Issue
Block a user