mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-10-30 18:23:52 +00:00
Merge pull request #1489 from CyC2018/update-database-notes
Update MySQL、数据库系统原理、Redis 和缓存
This commit is contained in:
+37
-2
@@ -195,6 +195,8 @@ SELECT user_id, status, create_time, id FROM orders WHERE user_id = 1001;
|
||||
| `user_id = 5 OR amount = 10.99` | ALL | OR 的一侧没有索引,仍然要扫描全表 |
|
||||
| `user_id != 5` | ALL | 几乎匹配所有行 |
|
||||
|
||||
需要注意的是,表中都是 SELECT * 的结果。查询的列都在某个索引中时(覆盖索引),不需要回表,优化器会改为扫描这个二级索引:SELECT order_no 加 `LIKE '%7007'` 的 type 为 index,SELECT user_id 加 `user_id != 5` 的 type 为 range,仍然要读几乎全部的索引项,只是比扫描整张表的数据量小(见[附录 A2](#a2-索引的执行计划))。
|
||||
|
||||
对应的写法:
|
||||
|
||||
- 把函数改写为范围:`create_time >= '2025-01-12' AND create_time < '2025-01-13'`,type 变为 range;
|
||||
@@ -367,7 +369,7 @@ LIMIT 10;
|
||||
|
||||
是 MySQL 默认的事务型存储引擎,只有在需要它不支持的特性时,才考虑使用其它存储引擎。
|
||||
|
||||
实现了四个标准的隔离级别,默认级别是可重复读(REPEATABLE READ)。在可重复读隔离级别下,通过多版本并发控制(MVCC)+ Next-Key Locks 防止幻读,见 [数据库系统原理](数据库系统原理.md#六next-key-locks)。
|
||||
实现了四个标准的隔离级别,默认级别是可重复读(REPEATABLE READ)。在可重复读隔离级别下,通过多版本并发控制(MVCC)+ Next-Key Locks 在很大程度上避免了幻读,但一个事务中混用快照读和当前读时仍会出现,见 [数据库系统原理](数据库系统原理.md#六next-key-locks)。
|
||||
|
||||
主键索引是聚簇索引,数据就保存在索引的叶子节点中,按主键查询走到叶子节点就拿到了整行。
|
||||
|
||||
@@ -452,6 +454,8 @@ TIMESTAMP 比 DATETIME 少占用 1 字节,但是只能表示到 2038 年,需
|
||||
|
||||
当单表的数据量或者写入量超过一台数据库服务器的承受能力时,就需要 Sharding,把数据分布到集群的不同节点上,从而缓解单个数据库的压力。
|
||||
|
||||
单表的行数本身并不是瓶颈。附录 A1 中 200 万行的 orders 表,主键索引只有 3 层,每个中间页约有 900 个指针,每个叶子页约 80 行,按此计算 3 层可以容纳约 6000 万行。真正需要拆分的是单机承受不了的写入量和数据量,以及随之变慢的备份、加列、建索引等运维操作;如果只是读压力大,先用读写分离和缓存。阿里巴巴的 Java 开发手册给出的经验值是单表超过 500 万行或者 2 GB 再考虑分库分表,预计三年内达不到就不要提前拆分。
|
||||
|
||||
<div align="center"> <img src="pics/shard-horizontal.png" width="760px"/> </div><br>
|
||||
|
||||
### 垂直切分
|
||||
@@ -468,6 +472,10 @@ TIMESTAMP 比 DATETIME 少占用 1 字节,但是只能表示到 2038 年,需
|
||||
- 范围:可以是 ID 范围也可以是时间范围;
|
||||
- 映射表:使用单独的一个数据库来存储映射关系。
|
||||
|
||||
选择分片键时,要让最常用的查询都带上它,这样每次查询只访问一个分片。例如订单表按 user_id 分成 16 张表,放在 4 个库中:表号为 user_id % 16,库号为表号 / 4。user_id 为 10086 的用户,10086 % 16 = 6,订单都在 db_1.orders_6 中。
|
||||
|
||||
分片数量一开始就定得比库的数量多,扩容时把整张表迁移到新库,每一行所属的表不变。如果直接按 hash(key) % 库数 分配,库从 4 个增加到 5 个时约 80% 的行都要迁移(见 [一致性哈希](缓存.md#六一致性哈希))。
|
||||
|
||||
### Sharding 存在的问题
|
||||
|
||||
#### 1. 事务问题
|
||||
@@ -486,6 +494,20 @@ TIMESTAMP 比 DATETIME 少占用 1 字节,但是只能表示到 2038 年,需
|
||||
- 为每个分片指定一个 ID 范围;
|
||||
- 使用分布式 ID 生成器,例如雪花算法(Snowflake)。
|
||||
|
||||
#### 4. 不带分片键的查询
|
||||
|
||||
订单按 user_id 分片后,按订单号或者按商家查询时不知道数据在哪个分片,只能查询所有分片再合并。常用的做法:
|
||||
|
||||
- 基因法:生成订单号时把最后 4 个二进制位设为 user_id % 16,订单号 % 16 就等于 user_id % 16,按订单号也能算出所在的表;
|
||||
- 映射表:另存一张订单号到 user_id 的映射,先查映射再查订单;
|
||||
- 按另一个维度再存一份:例如按商家 ID 分片再存一份订单,或者同步到 Elasticsearch,通常订阅 binlog 异步更新,所以会有短暂的延迟。
|
||||
|
||||
#### 5. 跨分片的排序和分页
|
||||
|
||||
分片后执行 ORDER BY create_time DESC LIMIT 1000, 10,每个分片都要返回前 1010 行,由中间件归并排序后再取出第 1001~1010 行,页数越深,每个分片返回的数据越多。常用游标分页代替:记住上一页最后一条的 create_time,每个分片只取比它更早的 10 行,见上文的 [深分页](#深分页)。
|
||||
|
||||
ShardingSphere 等中间件负责按分片键路由 SQL、改写分页条件和归并结果,应用像访问一张表一样访问分片后的表。
|
||||
|
||||
## 六、复制
|
||||
|
||||
### 主从复制
|
||||
@@ -547,7 +569,7 @@ redo log 的空间是固定的,循环写入:数据页写回磁盘之后,
|
||||
|
||||
### binlog
|
||||
|
||||
binlog 由 Server 层记录,与存储引擎无关,用于 [主从复制](#主从复制) 和把数据恢复到指定的时间点。MySQL 8.0 默认使用 ROW 格式,记录每一行修改前后的值。例如执行 UPDATE orders SET status = 3 WHERE id = 1024 后,用 mysqlbinlog 解析 binlog(省略了其它列):
|
||||
binlog 由 Server 层记录,与存储引擎无关,用于 [主从复制](#主从复制) 和把数据恢复到指定的时间点。binlog 有三种格式:STATEMENT 记录 SQL 语句本身,日志小,但含有 UUID() 或者带 LIMIT 的 UPDATE 这类结果不确定的语句,在从库重放的结果可能不同;ROW 记录每一行修改前后的值;MIXED 平时记录语句,遇到这类语句时自动改用 ROW。MySQL 8.0 默认使用 ROW 格式。例如执行 UPDATE orders SET status = 3 WHERE id = 1024 后,用 mysqlbinlog 解析 binlog(省略了其它列):
|
||||
|
||||
```text
|
||||
### UPDATE `demo`.`orders`
|
||||
@@ -655,6 +677,15 @@ SET optimizer_switch = 'index_condition_pushdown=on'; Handler_read_next = 3
|
||||
SET optimizer_switch = 'index_condition_pushdown=off'; Handler_read_next = 20
|
||||
```
|
||||
|
||||
覆盖索引时的前缀通配和不等于:
|
||||
|
||||
```text
|
||||
EXPLAIN SELECT * FROM orders WHERE order_no LIKE '%7007'; type: ALL key: NULL rows: 1978736
|
||||
EXPLAIN SELECT order_no FROM orders WHERE order_no LIKE '%7007'; type: index key: idx_order_no rows: 1978736 Extra: Using where; Using index
|
||||
EXPLAIN SELECT * FROM orders WHERE user_id != 5; type: ALL key: NULL rows: 1978736
|
||||
EXPLAIN SELECT user_id FROM orders WHERE user_id != 5; type: range key: idx_user_status_time rows: 989468 Extra: Using where; Using index
|
||||
```
|
||||
|
||||
### A3 二级索引对写入的影响
|
||||
|
||||
建两张结构与 orders 相同的表:t_noidx 只有主键,t_idx 有 idx_create_time、idx_user_status_time、idx_order_no 和 idx_amount 四个二级索引。各执行两次 `INSERT INTO t SELECT * FROM orders` 插入 50 万行(每次之前 TRUNCATE),再从 mysql.innodb_index_stats 读取各索引的页数:
|
||||
@@ -718,3 +749,7 @@ COUNT(*) 130.47 ms
|
||||
- [MySQL 8.0: Column Indexes(前缀索引)](https://dev.mysql.com/doc/refman/8.0/en/column-indexes.html)
|
||||
- [MySQL 8.0: The Binary Log](https://dev.mysql.com/doc/refman/8.0/en/binary-log.html)
|
||||
- [Replication Implementation | MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/replication-implementation.html)
|
||||
- [MySQL 8.0: Mixed Binary Logging Format](https://dev.mysql.com/doc/refman/8.0/en/binary-log-mixed.html)
|
||||
- [Java 开发手册(黄山版)](https://github.com/alibaba/p3c)
|
||||
- [ShardingSphere: 改写引擎](https://shardingsphere.apache.org/document/current/cn/reference/sharding/rewrite/)
|
||||
- [Designing Data-Intensive Applications 中文版:分片](https://github.com/vonng/ddia)
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
* [A3 单线程的吞吐量和 CPU 时间](#a3-单线程的吞吐量和-cpu-时间)
|
||||
* [A4 Redis Cluster](#a4-redis-cluster)
|
||||
* [A5 Sentinel 故障转移](#a5-sentinel-故障转移)
|
||||
* [A6 SDS 的空间预分配](#a6-sds-的空间预分配)
|
||||
* [A7 热点计数](#a7-热点计数)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
@@ -221,6 +223,14 @@ OK
|
||||
|
||||
例如一个 HASH 有 512 个字段时编码为 listpack,再写入一个字段就变为 hashtable。ZSET 转换后同时使用跳跃表和字典:字典按成员查分数,跳跃表按分数排序和范围查询。
|
||||
|
||||
字符串的内容保存在简单动态字符串(Simple Dynamic String, SDS)中,它在字符数组前面记录了已用的长度 len 和分配的容量 alloc:
|
||||
|
||||
- 取长度是 O(1),不用像 C 字符串那样遍历到 \0;
|
||||
- 按 len 而不是 \0 判断结尾,可以保存任意二进制数据;
|
||||
- 追加时空间不够才重新分配,并且多分配一些:新长度小于 1 MB 时分配新长度的 2 倍,否则多分配 1 MB,连续追加时不用每次都重新分配内存。
|
||||
|
||||
例如对 60 字节、没有剩余空间的字符串再 APPEND 1 字节,SDS 按 2 × 61 = 122 字节申请(内存分配器向上取整为 128 字节),追加后剩余 63 字节;对 2040000 字节的字符串追加后剩余约 1 MB(Redis 7.4 实测,见[附录 A6](#a6-sds-的空间预分配))。embstr 编码把对象头和 SDS 放在同一块内存中,只需分配一次,44 字节的上限正好让两者一起放进 64 字节;它是只读的,APPEND 等修改会先把它转为 raw。
|
||||
|
||||
### 字典
|
||||
|
||||
键空间、HASH 和 SET 的 hashtable 编码、ZSET 中按成员查分数的部分,都是用字典(dict)实现的。字典是一个哈希表,用拉链法解决哈希冲突:桶的数量是 2 的幂,键的哈希值与桶数减 1 做位与得到桶的下标,落在同一个桶的键用链表连起来。
|
||||
@@ -264,6 +274,10 @@ Redis 的跳跃表还在每个指针上记录了跨度,即这一步跳过了
|
||||
|
||||
命令在服务器上原子执行,多个客户端同时加 1 不会丢失更新;数据在内存中,也适合这种频繁读写的场景。
|
||||
|
||||
如果直接在数据库中计数,所有请求都要更新同一行,行锁让这些更新只能一个接一个地执行。对同一行执行 UPDATE counter SET cnt = cnt + 1,MySQL 用 1、8、50 个连接时每秒都只有 5000~7000 次;Redis 的 INCR 用 50 个连接每秒约 10 万次(MySQL 8.0、Redis 7.4 实测,见[附录 A7](#a7-热点计数))。
|
||||
|
||||
常用的做法是在 Redis 中计数,再定期把计数写回数据库。单个计数仍然太热时,可以拆成多个键,例如 views:1024:0~views:1024:9,每次随机给其中一个加 1,读取时求和。点赞这类每个用户只能计一次的计数,还要记录点过赞的用户,见下文的 [点赞功能](#点赞功能)。
|
||||
|
||||
### 缓存
|
||||
|
||||
将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率,见 [缓存](缓存.md)。
|
||||
@@ -406,6 +420,12 @@ Redis 是内存型数据库,为了保证数据在断电后不会丢失,需
|
||||
|
||||
配置项 aof-use-rdb-preamble 默认开启,重写生成的基础文件使用 RDB 格式,称为混合持久化:重启时先载入 RDB 部分,再重放增量文件中的命令,兼顾了 RDB 恢复快和 AOF 丢失数据少的优点。两种持久化都开启时,重启后使用 AOF 恢复,因为它的数据通常更完整。
|
||||
|
||||
两种方式的对比:
|
||||
|
||||
- 丢失的数据:RDB 丢失最后一次快照之后的写入,按 save 配置可能是几分钟;AOF 使用 everysec 时约丢失 1 秒;
|
||||
- 文件和恢复:RDB 是紧凑的二进制快照,适合备份,数据量大时恢复也快;AOF 记录的是命令,文件通常更大;
|
||||
- 开销:RDB 只在生成快照时 fork 子进程,数据量大时 fork 本身可能让主进程停顿;AOF 每次写入都要追加,并且需要定期重写。
|
||||
|
||||
## 九、事务
|
||||
|
||||
一个事务包含了多个命令,服务器在执行事务期间,不会改去执行其它客户端的命令请求。
|
||||
@@ -809,6 +829,36 @@ master_port:6401
|
||||
NOREPLICAS Not enough good replicas to write.
|
||||
```
|
||||
|
||||
### A6 SDS 的空间预分配
|
||||
|
||||
Redis 7.4.1(内存分配器为 libc),启动时加 --enable-debug-command yes,用 DEBUG SDSLEN 查看值的 len、avail(剩余空间)和实际分配的内存:
|
||||
|
||||
```text
|
||||
SET s(50 字节) val_sds_len:50, val_sds_avail:10, val_zmalloc: 64 encoding raw
|
||||
APPEND s 10 字节 val_sds_len:60, val_sds_avail:0, val_zmalloc: 64
|
||||
APPEND s 1 字节 val_sds_len:61, val_sds_avail:63, val_zmalloc: 128
|
||||
SET big(2000000 字节) val_sds_len:2000000, val_sds_avail:31606, val_zmalloc: 2031616
|
||||
APPEND big 40000 字节 val_sds_len:2040000, val_sds_avail:1072950, val_zmalloc: 3112960
|
||||
SET 44 字节 encoding embstr,SET 45 字节 encoding raw
|
||||
```
|
||||
|
||||
对应的源码在 sds.c 的 _sdsMakeRoomFor():剩余空间足够时直接返回;否则新长度小于 SDS_MAX_PREALLOC(1 MB)时乘以 2,不小于时加上 1 MB。SET 50 字节时的 10 字节、SET 大字符串时的 31606 字节剩余空间来自内存分配器的取整和协议缓冲区,不是预分配。
|
||||
|
||||
### A7 热点计数
|
||||
|
||||
MySQL 8.0.44(innodb_flush_log_at_trx_commit 和 sync_binlog 都为 1),Redis 7.4.1,Apple M1 Pro。MySQL 用多个进程同时执行自动提交的 UPDATE counter SET cnt = cnt + 1 WHERE id = 1,最后核对 cnt;Redis 用 redis-benchmark 对同一个键执行 INCR:
|
||||
|
||||
```text
|
||||
MySQL 1 个连接共更新 5000 次:0.92 s,5437 次/秒,cnt = 5000
|
||||
MySQL 8 个连接共更新 16000 次:2.21 s,7251 次/秒,cnt = 16000
|
||||
MySQL 50 个连接共更新 20000 次:3.72 s,5383 次/秒,cnt = 20000
|
||||
|
||||
$ redis-benchmark -p 6395 -c <连接数> -n 200000 --csv incr counter:1
|
||||
1 个连接 "incr counter:1","11020.50",...
|
||||
8 个连接 "incr counter:1","50213.41",...
|
||||
50 个连接 "incr counter:1","101368.47",...
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- Carlson J L. Redis in Action [M]. Manning, 2013.
|
||||
@@ -829,3 +879,4 @@ NOREPLICAS Not enough good replicas to write.
|
||||
- [Scripting with Lua | Redis](https://redis.io/docs/latest/develop/interact/programmability/eval-intro/)
|
||||
- [Redis 7.4.1 源码 dict.c](https://github.com/redis/redis/blob/7.4.1/src/dict.c)
|
||||
- [Redis 7.4.1 源码 t_zset.c](https://github.com/redis/redis/blob/7.4.1/src/t_zset.c)
|
||||
- [Redis 7.4.1 源码 sds.c](https://github.com/redis/redis/blob/7.4.1/src/sds.c)
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
* [A3 事务 ID 的分配](#a3-事务-id-的分配)
|
||||
* [A4 快照读与当前读混用](#a4-快照读与当前读混用)
|
||||
* [A5 Next-Key Locks 的加锁范围](#a5-next-key-locks-的加锁范围)
|
||||
* [A6 没有索引时的加锁](#a6-没有索引时的加锁)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
@@ -259,6 +260,8 @@ SELECT ... FOR UPDATE;
|
||||
|
||||
一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。
|
||||
|
||||
InnoDB 在提交读下不加间隙锁(只在检查外键和唯一键时使用),UPDATE、DELETE 扫描过但不满足条件的行,判断完条件就释放锁。插入不再被间隙锁阻塞,死锁也少得多,所以不少公司把隔离级别改为提交读,代价是会出现不可重复读和幻读,并且 binlog 必须使用 ROW 格式(见 [binlog](MySQL.md#binlog))。
|
||||
|
||||
### 可重复读(REPEATABLE READ)
|
||||
|
||||
保证在同一个事务中多次读取同一数据的结果是一样的。
|
||||
@@ -415,6 +418,8 @@ SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;
|
||||
|
||||
按 c = 11 查询时,c 不是唯一索引,后面还可能有 c = 11 的记录,所以除了 (10, 11] 的 Next-Key Lock,还要锁住到下一条记录 13 之间的间隙;如果 c 是唯一索引,就只锁 c = 11 这一条记录。
|
||||
|
||||
当前读的条件列没有索引时,InnoDB 只能扫描整个聚簇索引,扫描过的每一行都要加锁。例如表 t_nl 有 5 行,d 列没有索引,执行 UPDATE t_nl SET d = d + 1 WHERE d = 30 只修改 1 行:在可重复读下锁住了全部 5 行和最后的间隙,其它事务插入新行、修改 id 为 5 的行都要等待,效果相当于锁住了整张表;在提交读下只锁住 1 行,插入和修改都不受影响(MySQL 8.0 实测,见[附录 A6](#a6-没有索引时的加锁))。所以 UPDATE、DELETE 的条件列要建索引。
|
||||
|
||||
## 七、关系数据库设计理论
|
||||
|
||||
### 函数依赖
|
||||
@@ -670,6 +675,29 @@ SELECT * FROM t WHERE c BETWEEN 10 AND 20 FOR UPDATE
|
||||
把 idx_c 改为唯一索引后,c = 11 FOR UPDATE 只有 uk_c X,REC_NOT_GAP 11, 2 和主键上的记录锁,插入 c = 12 成功。
|
||||
```
|
||||
|
||||
### A6 没有索引时的加锁
|
||||
|
||||
```sql
|
||||
CREATE TABLE t_nl (id INT PRIMARY KEY, d INT);
|
||||
INSERT INTO t_nl VALUES (1, 10), (2, 20), (3, 30), (4, 40), (5, 50);
|
||||
```
|
||||
|
||||
A、B 两个会话的锁等待超时设为 1 秒。A 执行 UPDATE t_nl SET d = d + 1 WHERE d = 30 后不提交,查看 data_locks,B 再插入一行、修改另一行:
|
||||
|
||||
```text
|
||||
可重复读:A 的锁 7 个
|
||||
TABLE IX
|
||||
RECORD X 1 / 2 / 3 / 4 / 5 / supremum pseudo-record
|
||||
B: INSERT INTO t_nl VALUES (100, 1000) -> 1205 Lock wait timeout exceeded
|
||||
B: UPDATE t_nl SET d = 51 WHERE id = 5 -> 1205 Lock wait timeout exceeded
|
||||
|
||||
提交读:A 的锁 2 个
|
||||
TABLE IX
|
||||
RECORD X,REC_NOT_GAP 3
|
||||
B: INSERT INTO t_nl VALUES (100, 1000) -> OK
|
||||
B: UPDATE t_nl SET d = 51 WHERE id = 5 -> OK
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- AbrahamSilberschatz, HenryF.Korth, S.Sudarshan, 等. 数据库系统概念 [M]. 机械工业出版社, 2006.
|
||||
@@ -678,6 +706,7 @@ SELECT * FROM t WHERE c BETWEEN 10 AND 20 FOR UPDATE
|
||||
- [The InnoDB Storage Engine](https://dev.mysql.com/doc/refman/5.7/en/innodb-storage-engine.html)
|
||||
- [MySQL 8.0: Transaction Isolation Levels](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html)
|
||||
- [MySQL 8.0: InnoDB Locking](https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html)
|
||||
- [MySQL 8.0: Locks Set by Different SQL Statements in InnoDB](https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html)
|
||||
- [MySQL 8.0: The data_locks Table](https://dev.mysql.com/doc/refman/8.0/en/performance-schema-data-locks-table.html)
|
||||
- [Transaction isolation levels](https://www.slideshare.net/ErnestoHernandezRodriguez/transaction-isolation-levels)
|
||||
- [Concurrency Control](https://web.archive.org/web/20260614192237/https://scanftree.com/dbms/2-phase-locking-protocol)
|
||||
|
||||
+74
-2
@@ -129,14 +129,26 @@ CDN 主要有以下优点:
|
||||
|
||||
<div align="center"> <img src="pics/bloom-filter.png" width="620px"/> </div><br>
|
||||
|
||||
误判是因为这些位置可能都被其它 Key 置为 1。插入 n 个 Key、位数组为 m 位、使用 k 个哈希函数时,误判率约为 (1 - e<sup>-kn/m</sup>)<sup>k</sup>,k 取 (m/n) ln 2 时最小。每个 Key 占 10 位、k = 7 时,100 万个 Key 只需要 1.2 MB,按公式误判率为 0.82%;实际插入 100 万个 Key,再查询 100 万个不存在的 Key,误判率也是 0.82%(见[附录 A6](#a6-布隆过滤器的误判率))。目标误判率为 1% 时,每个 Key 约需要 9.6 位,1 亿个 Key 约 114 MB。
|
||||
|
||||
使用时需要注意:
|
||||
|
||||
- 容量要预估准确:插入的 Key 超过预期后误判率迅速上升,例如按 100 万个 Key 配置的过滤器插入了 200 万个 Key,按公式误判率会升到约 14%;
|
||||
- 不能删除:一个位可能同时属于多个 Key,清零会让其它 Key 被误判为不存在,需要删除时可以定期重建,或者使用计数布隆过滤器、布谷鸟过滤器;
|
||||
- 新增的数据要同时写入布隆过滤器,否则请求会被误判为不存在而拒绝。
|
||||
|
||||
Redis 8 起内置了布隆过滤器(BF.RESERVE、BF.ADD、BF.EXISTS),Redis 7 需要加载 RedisBloom 模块。
|
||||
|
||||
### 缓存击穿
|
||||
|
||||
指的是某个热点数据的缓存过期时,大量对它的请求同时到达数据库。
|
||||
|
||||
解决方案:
|
||||
|
||||
- 使用互斥锁,只允许一个请求去数据库加载数据并写回缓存,其它请求等待后直接读缓存;
|
||||
- 热点数据不设置过期时间,或者由后台线程异步更新。
|
||||
- 互斥锁:缓存未命中时用 SET lock NX EX 3 抢锁,抢到的请求先再查一次缓存(可能别人已经写回),仍然没有才查询数据库并写回缓存,最后释放锁(释放时要确认锁仍是自己的,见 [分布式锁](分布式.md#redis-的-set-nx-指令));没抢到的请求等待几十毫秒后重新读缓存;
|
||||
- 逻辑过期:键不设置过期时间,而是在值中保存一个过期时间。读到已经过期的值时,抢到锁的请求启动后台线程重建缓存,所有请求都直接返回旧值,不等待。
|
||||
|
||||
两者都只让一个请求查询数据库,区别在于等待重建还是返回旧值。热点键刚过期时 100 个请求同时到达,数据库查询耗时 50 毫秒:不加保护时查询了数据库 68~100 次;互斥锁只查询 1 次,请求耗时的中位数约 90~100 毫秒;逻辑过期也只查询 1 次,耗时中位数约 12 毫秒,但 100 个请求拿到的都是旧值(Redis 7.4、MySQL 8.0 实测,见[附录 A5](#a5-缓存击穿的三种做法))。要求数据准确时用互斥锁,能接受短暂的旧值、更看重响应时间时用逻辑过期。
|
||||
|
||||
### 缓存雪崩
|
||||
|
||||
@@ -397,6 +409,8 @@ public class LRU<K, V> implements Iterable<K> {
|
||||
}
|
||||
```
|
||||
|
||||
上面的实现不是线程安全的:get() 也会移动链表中的节点,多个线程同时访问会破坏链表,所以读操作也要加锁。简单的做法是 get() 和 put() 使用同一把锁,或者用 Collections.synchronizedMap() 包装上面的 LinkedHashMap。并发高时用 Caffeine,它不在每次读取时调整顺序,而是把访问记录写入缓冲区,再批量调整,读操作不需要获取全局锁。
|
||||
|
||||
## 附录:实测
|
||||
|
||||
以下实验的环境均为 Apple M1 Pro(10 核)、macOS,Redis 7.4.1 使用默认配置,客户端和服务器在同一台机器上。
|
||||
@@ -522,6 +536,62 @@ update-then-delete stale 542/1000 (54.2%)
|
||||
update-then-delete + double delete stale 0/1000 (0.0%)
|
||||
```
|
||||
|
||||
### A5 缓存击穿的三种做法
|
||||
|
||||
MySQL 8.0.44,Redis 7.4.1,Python 3.13(pymysql、redis-py)。数据库查询为 SELECT price, SLEEP(0.05) FROM cache_demo WHERE id = 1,热点键刚过期时 100 个线程同时发起请求,统计查询数据库的次数和每个请求的耗时,逻辑过期的旧价格为 99。每种做法运行两轮:
|
||||
|
||||
```python
|
||||
def mutex():
|
||||
while True:
|
||||
v = r.get(KEY)
|
||||
if v is not None:
|
||||
return int(v)
|
||||
if r.set(LOCK, 1, nx=True, ex=3): # 只有一个请求拿到锁
|
||||
try:
|
||||
v = r.get(KEY) # 再查一次,可能别人已经写回
|
||||
if v is None:
|
||||
v = query_db(); r.set(KEY, v, ex=60)
|
||||
return int(v)
|
||||
finally:
|
||||
r.delete(LOCK)
|
||||
time.sleep(0.01) # 没拿到锁,稍后重新读缓存
|
||||
|
||||
def logical():
|
||||
d = json.loads(r.get(KEY)) # 值中带逻辑过期时间
|
||||
if d['expire_at'] < time.time() and r.set(LOCK, 1, nx=True, ex=3):
|
||||
threading.Thread(target=rebuild).start() # 后台重建,完成后删除锁
|
||||
return d['price'] # 不等待,直接返回
|
||||
```
|
||||
|
||||
```text
|
||||
不加保护 查询数据库 68 次,耗时中位数 157.8 ms,最大 181.4 ms,返回旧值 0 次
|
||||
互斥锁 查询数据库 1 次,耗时中位数 88.2 ms,最大 95.8 ms,返回旧值 0 次
|
||||
逻辑过期 查询数据库 1 次,耗时中位数 13.8 ms,最大 19.8 ms,返回旧值 100 次
|
||||
不加保护 查询数据库 100 次,耗时中位数 408.4 ms,最大 444.2 ms,返回旧值 0 次
|
||||
互斥锁 查询数据库 1 次,耗时中位数 102.0 ms,最大 118.5 ms,返回旧值 0 次
|
||||
逻辑过期 查询数据库 1 次,耗时中位数 11.5 ms,最大 18.6 ms,返回旧值 100 次
|
||||
```
|
||||
|
||||
### A6 布隆过滤器的误判率
|
||||
|
||||
Python 3.13 模拟:插入 user:0~user:999999,再查询 user:1000000~user:1999999 这 100 万个不存在的 Key。哈希值取 MD5 的两个 64 位整数 h1、h2,第 i 个位置为 (h1 + i × h2) mod m:
|
||||
|
||||
```python
|
||||
def pos(key):
|
||||
d = hashlib.md5(key.encode()).digest()
|
||||
h1, h2 = int.from_bytes(d[:8], 'little'), int.from_bytes(d[8:], 'little')
|
||||
return [(h1 + i * h2) % m for i in range(k)]
|
||||
```
|
||||
|
||||
```text
|
||||
n=1000000 m/n=10 k=7 位数组 1.19 MiB:实测误判率 0.8199%,公式 0.8194%
|
||||
n=1000000 m/n=10 k=3 位数组 1.19 MiB:实测误判率 1.7588%,公式 1.7411%
|
||||
n=1000000 m/n=5 k=3 位数组 0.60 MiB:实测误判率 9.2104%,公式 9.1849%
|
||||
n=1000000 m/n=20 k=14 位数组 2.38 MiB:实测误判率 0.0065%,公式 0.0067%
|
||||
目标误判率 1.0%:每个 Key 9.59 位,k = 6.6,1 亿个 Key 约 114 MiB
|
||||
目标误判率 0.1%:每个 Key 14.38 位,k = 10.0,1 亿个 Key 约 171 MiB
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 杨传辉. 大规模分布式存储系统:原理解析与架构实战 [M]. 机械工业出版社, 2013.
|
||||
@@ -533,5 +603,7 @@ update-then-delete + double delete stale 0/1000 (0.0%)
|
||||
- [Redis: UNLINK](https://redis.io/docs/latest/commands/unlink/)
|
||||
- [Redis CLI](https://redis.io/docs/latest/develop/tools/cli/)
|
||||
- [Scaling Memcache at Facebook](https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf)
|
||||
- [Redis: Bloom filter](https://redis.io/docs/latest/develop/data-types/probabilistic/bloom-filter/)
|
||||
- [Caffeine: Design](https://github.com/ben-manes/caffeine/wiki/Design)
|
||||
- [Caffeine Wiki](https://github.com/ben-manes/caffeine/wiki)
|
||||
- [Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web](https://dl.acm.org/doi/10.1145/258533.258660)
|
||||
|
||||
Reference in New Issue
Block a user