Redis Kafka Mysql三件套
前言
不知道我是被pua了,还是真的不了解底层。但是我还是决定沉淀一下。
Redis
redis 作为常见考点,也是很常见的缓存方案,不过有观点认为考虑到网络延迟(TCP 往返 + 序列化 + 网络调度)有的时候还不如直接postgreSQL?
数据结构
-
string:典中典,不过也不会有人问的
其数据结构是SDS(Simple Dynamic String),说白了就是比C语言字符串多了个长度,放到结构体的头部(弱弱地插一句才知道redis是用c写的)
-
hash:我说我没用过有没有懂得。但是为了用它,我没事找事,创造了需求,在秒杀环节,还是特别傻逼的能秒杀多个地业务场景(秒杀正常人类指的是买一个),可以通过用商品id作为key,用户的id作为field,value存储用户购买量。这时候你就会发现,哎呀,一个hash存了一坨,这不就是大key问题吗。所以我们用userid % 32,来进行分桶设计key。
seckill:goodId:userId%32 userId。然后我们进行底层学习:
当它很小的时候是listpack或者ziplist。这个以后再说。
hash-max-listpack-entries默认是512,hash-max-listpack-value是64,当元素不大于512,且value不大于64时,不会用hashtable。(当然此时不是O(1)的操作,不过CPU命中缓存了,是很快的线性便利)下面为hashtable:redis里面是有h[0] 和 h[1] 两个哈希库的,哈希冲突采用拉链法,h[0] 为旧库,h[1]为新库,初始的时候hash大小为4,当新key过多,超过负载因子的时候,我们就会渐进式扩容,每次扩展两倍。通过rehash的方式,将 h[0] 中的键值对逐步迁移到 h[1]中,修改的是索引,哈希函数不会改变,因为hash(key) & (size - 1)的size变了,所以需要调整。(&((size-1)就是取模)。当
h[0]的数据全部搬空后,释放h[0]占用的内存,将h[1]的指针赋值给h[0],重新初始化h[1]为空表。 -
set:集合。其实秒杀对应的就是集合。同上,只要你不存在逆天想要秒杀多个,人类都会用这个。
数据结构是
intset和hashtable。set-max-intset-entries默认是512,数量小于等于512且在无字符串的时候会用inset。 -
zset(sorted set):有序集合。用的是skiplist/listpack,用来做排行榜,不过目前没遇到这个业务需求。
我们先了解传统的跳表skiplist。每一个元素都有随机(通常50%,标准理论跳表用 p=1/2,但 Redis 源码固定 p=1/4(ZSKIPLIST_P=0.25))的概率来添加一个升到上一层的复制品,所以跳表的层数是随机的。
然后是redis的定制版本。
肉眼可见,它多了一个span所以可以
- O(log N) 获取排名ZRANK/ZREVRANK):在查找节点的过程中,累加经过的
span值,即可直接得出该节点的排名,无需遍历。 - O(log N) 根据排名获取元素 (ZRANGE):可以通过二分查找式的跳跃,快速定位到指定排名的节点。
还可以看到多了一个backward,这允许它反向遍历。
但其实它还允许score是重复的(即key),如果相同则字典序比较ele。
还限定的最高高度为32,因为2^32次方够你排序了,再多有点逆天了。
p=1/2:足够支持约
2^32元素p=1/4:足够支持约
4^32 = 2^64元素(远超需求)最后要指出头指针指向的是最高层的节点,然后推荐自己脑袋模拟一下。
- O(log N) 获取排名ZRANK/ZREVRANK):在查找节点的过程中,累加经过的
-
bitmap 位图,如果你做过二进制枚举或者二进制dp压缩的问题,这个很好理解,就是一样的道理,其底层是sds。其场景如:用户签到,是否在线等二元情景。
常见问题如用户采用uuid,跨度过大,浪费内存,我们就使用hash key
id:mappingfielduuidvalue 用lua脚本写一个自增来进行映射。 -
HyperLogLog 基于 伯努利试验 和 哈希函数。通过哈希函数将输入数据映射为比特串,统计比特串中第一个出现 1 的位置(称为 k 值),并利用数学公式估算集合的基数。为了降低误差,HyperLogLog 将数据分为多个桶(默认 16384 个),每个桶独立计算 k 值,最终通过 调和平均数 合并结果。(不会)
最后备注一下listpack和ziplist的区别。(这两个东西没有struct的定义,直接连续分布在内存,十分的黑魔法)至于为什么不用struct,是因为c语言会进行内存对齐,一般是8Byte的倍数,而且ziplist的prevlen和listpack的encoding是变长的,struct也无法支持。
ziplist分布
|
|
zlbytes:表示整个ziplist占用的字节数 ztail:表示最后一个entry的偏移量 zllen:表示entry的数量
entry分布
prevlen: 如果前一个节点长度1B能存下就用1B,否则高位固定为0xFE,剩下的4B存实际长度。encoding:最高两个bit存储类型,剩下的表示长度,如果是整数剩下的表示整数类型
listpack分布
|
|
total_bytes:表示总字节数 num_elements:表示数量 可见少了一个ztail,所以listpack访问尾部的方式变了,如果想要知道最后一个元素的位置只能O(n)便利。
entry分布
|
|
backlen:存储entry自身除了backlen的长度
持久化
这个就是Redis Database(RDB)和Append Only File(AOF)
RDB简单粗暴,直接把内存存到文件里面,并且默认是启用的。唯一需要记住的就是有同步save和异步bgsave两种,父进程fork后交给子进程。
关键参数 save <seconds> <changes>,多少时间内多少次改变触发保存。
AOF,顺序读写,顺序读写最快了,有三个等级
随着时间推移,AOF 文件会变得越来越大。Redis 会在后台自动触发 AOF 重写:创建一个当前数据集的最小体积 AOF 文件(例如,如果一个 key 被设置了多次,最终只保留最后一次的值)。
目前生产常用混合持久化?
当开启混合持久化后,在进行 AOF 重写时,新产生的 AOF 文件不再全是纯命令日志,而是:
- 前半部分:以 RDB 二进制格式存储当前内存的全量快照。
- 后半部分:以 AOF 文本格式存储增量命令。
为什么快
内存快。
单线程执行命令,避免上下文切换和锁竞争及一致性问题。
IO多路复用,使用epoll机制。这里扩展一下IO模型。
阻塞IO,非阻塞IO(轮询),IO多路复用。
总所周知,Linux一切皆文件,一个网络连接Socket被视为一个文件标识符FD
IO多路复用分为:
- select:将所有的FD拷贝到内核,内核遍历检查。检查遍历慢,FD数量上限为1024,每次用户态和内核态切换调用全拷贝FD集
- poll:用链表代替数组,仅突破数量限制
- epoll:采用红黑树logN增删改所有FD,维护一个就绪链表,返回就绪的FD。
分布式容灾
redis挂了怎么办,虽然你妈挂了mysql都不会挂,但是redis可能挂,因为可能会oom,算了oom也不太可能,你就当被人拔电源了。
-
主从复制(Master-Slave Replication)
Master 负责写,Slave 负责读和备份。Master 将写命令异步发送给 Slave。一个 Master 可以有多个 Slave。数据只能从 Master 流向 Slave。
同步分为全量同步和增量同步。全量的时候发送的是rdb,或者 Master 不生成 RDB 文件到磁盘,而是直接通过 Socket 发送给 Slave。适合磁盘慢但网络快的场景。
增量同步时,有一个全局的offset,每次master写入的时候,把操作指令发送给slave,slave会ack自己目前的offset。
然后就是master挂了怎么办,master挂了就炸了,所以生产没有用主从结构的。
-
哨兵模式(Sentinel)
一组 Sentinel 节点监控 Master 和 Slave 的状态。当 Master 不可达时,Sentinel 选举新的 Master,并通知客户端更新连接地址。
Sentinel既可以和redis在一个服务器上跑,也可以放到独立的服务器来保证独立性(有点土豪的),一般建议至少三台。
哨兵系统主要完成以下三个任务:
- 监控(Monitoring): 哨兵会不断检查主节点和从节点是否运作正常。
- 通知(Notification): 当被监控的某个 Redis 节点出现问题时,哨兵可以通过 API 向管理员或其他应用程序发送通知。
- 自动故障转移(Automatic Failover): 如果主节点无法正常工作,哨兵会启动故障转移流程, 将一个从节点升级为主节点。 让其他的从节点改为复制新的主节点。 当客户端尝试连接失效的主节点时,集群也会向客户端返回新主节点的地址,使得集群可以使用新的主节点代替失效的主节点。
然后哨兵也需要有一个主节点,当多个 Sentinel 认为 Master 客观下线(ODOWN)后,需要有一个 Sentinel 来主导故障转移流程(比如选择新的 Master、通知其他 Sentinel 和客户端)。这个主导者就是 Leader Sentinel。其过程类似Raft。
-
Redis Cluster(分片集群)
数据分片存储在多个 Master 节点上,每个 Master 有对应的 Slave。使用 Gossip 协议通信,客户端可直接连接任意节点。此时一共有16384个slots(编号 0~16383),均匀的分布到集群的所有节点里面,当写入一个key的时候,使用
CRC(KEY) % 16384确定在哪一个slot,进一步知道去哪个节点。Gossip:和Sentinel模式不一样,分片集群没有Leader的逻辑,所有节点的地位平等。他们定时默认1秒,向几个其他节点发送PING,收到的节点回复PONG(事实上不只是心跳消息,还包括发送者知道的集群状态信息),如果X知道Y下线了,X会告诉Z,Z进而和别人继续gossip,最终所有人都知道了。
分片集群在通信层面使用gossip是平等的,但是在存储数据这一块和哨兵的逻辑是一样的,也是需要主从的。
问题:注意在集群模式下lua脚本,所有设计的key必须属于同一个slot,否则会拒绝执行。
Raft算法
感觉问的很少,面试官可能不太懂。
分布式常用算法。解决三大问题:领导者选举,日志复制,安全性保障。
首先我们定义大多数这个概念,初始的时候节点为N,那么大多数在以后一直定义为大于N / 2(初始节点是提前在配置文件写好ip的,也支持动态变更,但是一般只允许一个一个加减,动态变更暂不了解)
领导者选举:
每个节点有三种状态Follower->Candidate->Leader 初始全为Follower,当随机时间还没有收到心跳则转为Candidate,Candidate会向其他所有节点要投票,如果获得多数票,那么成为Leader,如果两个人票数相同,那么则重新进行一轮选举。
日志复制:
当客户端请求后,Leader先处理,然后发送给Follower,Follower落库后发送commited确定,当大多数确定后,这个消息就被认为是成功并最新的。
安全性保障:
日志最新的才允许作为Leader,首先比较term,然后比较日志长度
Leader只能追加日志不能覆盖活着删除已经提交的条目。
| 机制 | 作用 | 实现要点 |
|---|---|---|
| 任期(Term) | 逻辑时钟,检测过时领导者 | 单调递增整数,每次选举+1 |
| 随机超时 | 避免选票分裂 | 150-300ms随机选举超时 |
| 多数派原则 | 保证容错与进展 | 5节点集群容忍2节点故障 |
| 日志匹配性质 | 确保复制一致性 | 若两日志在某index处term相同,则此前所有条目一致 |
最后以一个例子进行进一步理解,如果有5个节点,突然网络炸了,变成3+2的形式,重新选举,当用户分别set a到3和set b到2时,由于3个满足大多数,所以最终恢复的时候,set b不会起作用,而2中由于就算所有票都选一个节点也不满足大多数,所以无法选举Leader,进而2个节点的分区无法使用。
那么我们当然会想到如果是六个呢,3+3,那么谁也满足不了大多数,没有领导,所以直接就炸了,不再接受新数据,这就体现了CAP中系统的CP性,所以节点也不要配制成偶数个。
最后再总结一下term的变化情景
| 场景 | 变化规则 | 触发条件 | 节点行为 |
|---|---|---|---|
| 主动递增(发起选举) | term = local_term + 1 |
Follower 选举定时器超时 | 转为 Candidate,先自投一票,再发 RequestVote(term) |
| 被动更新(发现更高世代 | term = max(local_term, rpc_term) |
收到任意 RPC(心跳/投票/日志)且 rpc_term > local_term |
立即更新 term → 退位为 Follower → 重置选举定时器 |
| 重启恢复 | term = 磁盘持久化值 |
节点崩溃重启 | 不变。从 HardState 加载,不会自动+1 |
Kafka
架构
kafka的consumer和producer是解耦合的。总集群称之为Cluster,每个节点称之为broker。业务消息的分割单位是Topic,Topic之间存在partition。consumer之间可以组成consumer group,每个consumer group中的consumer只能消费一个topic中的partition一次。
核心概念
Topic:
- 消息的逻辑分类,类似数据库的"表"
- Producer 向 Topic 发送消息,Consumer 从 Topic 订阅消息
- 一个 Topic 可被多个 Consumer Group 订阅
Partition:
- 水平扩展:分区是 Kafka 实现高吞吐和并行处理的关键
- 有序性:单个 Partition 内消息严格有序(FIFO)
- 副本机制:每个分区可有多个副本(Leader + Followers)
- 一个Partition其实就是一个文件夹,文件夹的前缀是Topic,跟着的是Partition的idx
- Partition里面存储的一份数据对应着一组log index timeindx,他们的文件名就是offset稀疏索引
Broker:节点
Producer:
- 发送消息到 Kafka 的客户端
- 发送策略:
key指定:相同 key 的消息路由到同一分区(保证局部有序)- 轮询:均匀分布到各分区(负载均衡)
- 自定义分区器:业务定制路由逻辑
Consumer:
-
组内负载均衡:一个分区只能被组内一个消费者消费
-
组间独立消费:不同 Group 可独立消费同一 Topic
-
消费策略:
- 偏移提交策略:
- 自动提交:每5秒自动提交
- 手动提交
- 手动异步提交
- 事务+手动提交
- 负载均衡策略:
- 范围分配:按照Partition序号范围分配给消费者
- 轮询
- 粘性分配:优先上次分配结果
- 增量粘性分配
- 偏移提交策略:
拉模式:Kafka 的 Broker 原生不支持 Push 模式,消费者与 Broker 之间的数据传递是纯粹的 Pull(拉取)模式
Recoder:我们称一条消息为Record,里面有包含headers,key,value,timestamp,其中headers和key是可选的。
- Key 消息键,字节数组,可为空。用于分区路由(哈希取模)和日志压缩去重,决定消息发往哪个分区。
- Value 消息体,字节数组,不能为空。存储实际业务数据,是消息的核心内容。
- Timestamp 时间戳,long 类型。有两种:生产者发送时间(CreateTime)、Broker 写入时间(LogAppendTime),用于消息过期、时序计算、按时间回溯消费。
- Headers 消息头,Kafka 0.11+ 支持,键值对列表。用于传递轻量级元数据,如链路追踪ID、版本、租户信息,不影响分区和业务主体。
补充
补充一个特别傻逼的问题:如果保证单个partition的有序?这一听我就懵了,这不废话吗,队列先进先出。结果还真是这样,然后你也就多答一个顺序写入这一块。
当然还有一个好问题,Kafka怎么保证的生产者幂等,也就是避免重复发同一条消息。这个还真是没了解过,是Kafka 0.11+本身就支持的,不再需要自己的业务代码进行实现。每个生产者实例会分配一个唯一 ID:PID (Producer ID)(有没有一蹭process id热度的感觉),每条消息带:PID + 序列号(sequence number),Broker 端会缓存:PID + 分区 → 最大序列号,重复消息:相同 PID + 相同分区 + 相同或更小序列号 → 直接拒绝,不写入。
分布式容灾
Kafka的分布式类似于redis的master-slave模式,但是Kafka是leader负责所有的读写,replica只负责备份,由此我们引出kafka 发消息 ack的三种模式。
- ack = 0 生产者只管发消息
- ack = 1 leader节点确认落库后认为发送成功
- ack = -1 所有的replica都落库后才认为成功
我有一个问题,为什么kafka不像redis那样读写分离呢,这是因为redis读写分离是为了减少读的压力,而kafka本身就可以水平扩展有多个partition,读的问题已经解决了。而且kafka对于一致性的要求更高且是磁盘IO主从数据差距可能较大,所以我们没必要也不应该读写分离。
新版本采用的是Raft算法选取领导,老版本的是zookeeper再说吧。
消费者 Rebalance
这个东西属于是只有专门研究过Kafka的才会问你,因为分布式系统动态更改本身就是一个比较复杂的东西。
首先我们要了解一下心跳线程。 消费者需要定期地发送心跳请求(Heartbeat Request)到 Broker 端的协调者,以表明消费者它还存活着。
在0.10.1.0 版本之前,发送心跳请求是在消费者主线程完成的,如果消费时间过长,可能被误认为死亡,但是没死。
自 0.10.1.0 版本开始,社区引入了一个单独的心跳线程来专门执行心跳请求发送,避免了这个问题。社区肯定不傻,为啥最开始就不用这个方案呢,这个方案也不是完美的,**主线程死了,心跳线程依然在跳!**为了解决“拆分线程后产生的僵尸问题”,Kafka 社区又打了个补丁,引入了 max.poll.interval.ms,如果主线程因为处理太慢,超过了这个时间还没进行下一次 poll()(poll 里面包含了向 Coordinator 汇报进度的逻辑),就会被踢出。
然后我们了解一下Rebalance触发场景:
- 组成员数量发生变化。
- 订阅主题数量发生变化。
- 订阅主题的分区数发生变化。
对于2.3版本之前,基于eager 协议,非常的简单粗暴。Coordinator 只知道“现在的状态”和“下一个状态”,转换状态的方式就是全部清空再重填。
过程如下:
- 触发与心跳发现:当组内成员发生变动(新加入、离开或被认为死亡)或订阅的Topic分区数变化时,再平衡被触发。消费者通过心跳(Heartbeat)通信感知到再平衡开始。
- 停止消费与提交位移 (Revoke):收到通知后,消费者需在当前
poll()循环结束前完成消息处理,并触发onPartitionsRevoked()回调。在此回调中,必须同步提交Offset以避免消息丢失或重复。随后,消费者将释放所有分区并停止拉取新消息。 - 加入组 (JoinGroup):所有消费者重新向协调者发送
JoinGroup请求,上报自身订阅信息。协调者会将第一个加入的成员指定为"组领导者",并将所有成员信息发送给它。 - 领导者分配 (Leader Assignment):组领导者根据
partition.assignment.strategy(分区分配策略,如Range、RoundRobin、Sticky)计算分区分配方案,并通过SyncGroup请求将结果发送给协调者。 - 同步分配 (SyncGroup) 并恢复消费:协调者将分配方案下发给所有消费者。消费者收到分区后触发
onPartitionsAssigned()回调,开始从各自的分区拉取消息,再平衡过程结束。
其中的重点是停止消费与提交位移这一步,我们要考虑消费者在poll这一轮必须把获取的所有消息在不超过max.poll.interval.ms的时间消费完(默认是5分钟其实绰绰有余),如果真做不到可以考虑调整max.poll.records和max.poll.interval.ms。然后业务这里也要进行幂等操作。
当然,我们很容易就会发现第二个触发场景有问题,明明只需要对新增的topic进行处理,为什么还有STW来重分配呢,所以Kafka2.4+引入了协作式再平衡(Cooperative Rebalance),也称为增量式再均衡(Incremental Rebalance)。 其它的场景也有优化,这一块的问题迄今为止应该还在不断优化。
| 触发场景 | 是否需要 Revoke | Revoke 范围 | 核心特点 |
|---|---|---|---|
| 组成员变化(新增) | ✅ 需要 | 仅部分分区迁移 | 为了负载均衡,老成员让出一部分分区给新成员(增量迁移,无全停顿) |
| 组成员变化(离开) | ❌ 基本不需要 | 无(只新增分配) | 剩余成员直接接管“无人持有”的分区,几乎无迁移成本 |
| 订阅 Topic 增加 | ❌ 不需要 | 无 | 旧分区完全不动,只分配新 Topic 的分区 |
| 订阅 Topic 删除 | ✅ 需要 | 仅被删除 Topic | 只释放不再订阅的分区,不影响其他 Topic |
| Topic 分区数增加 | ❌ 不需要 | 无 | 旧分区不动,仅对新增分区做分配 |
| Topic 分区数减少 | ✅(理论) | 被移除分区 | 实际很少发生(Kafka 不支持直接缩分区) |
注:Revoke 指的是:Coordinator 要求某个 Consumer 放弃它当前持有的分区
MySQL
通识
MySQL为CS架构,有连接层、服务层和存储引擎层。连接层负责身份验证,连接管理。服务层负责解析SQL语句、优化查询SQL和各种运维功能。
存储引擎层负责数据的存储和检索,目前采用InnoDB(支持事务和行级锁),5.5之前是MyISAM目前没啥人用,但是了解一下区别也不错,毕竟查询性能还是高一点,发现MyISAM仅仅只缓存索引(Index),不缓存数据(Data),所以查询也是一坨。
| 特性 | InnoDB | MyISAM | Memory |
|---|---|---|---|
| 事务支持 | 支持 | 不支持 | 不支持 |
| 锁粒度 | 行级锁 | 表级锁 | 表级锁 |
| 外键约束 | 支持 | 不支持 | 不支持 |
| 崩溃恢复 | 支持 | 不支持 | 不支持 |
| 全文索引 | MySQL 5.6+ 支持 | 支持 | 不支持 |
| 存储限制 | 64TB | 256TB | 受内存限制 |
| 适用场景 | OLTP 高并发 | 读多写少 | 临时缓存 |
名词解释:
外键约束:通过限制一个表中的字段值必须匹配另一张表的主键值,保证数据的参照完整性,防止出现孤立、无效或不一致的数据关系。
崩溃恢复:能够通过自身的日志机制(如 InnoDB 的 redo/undo log)自动检测并恢复数据到崩溃前的一致状态,避免数据丢失、损坏或不一致,保障数据的持久性和可靠性。
全文索引:针对文本内容的特殊索引类型,用于快速检索字符串类型字段中的关键词,相比传统的LIKE模糊查询,它支持自然语言的分词检索,大幅提升文本搜索的效率和准确性,适用于内容检索类场景。(没啥人用,Elasticsearch爆杀它,除非很轻量化)
OLTP(Online Transaction Processing):大量短小、高频、实时性的事务
SQL vs No SQL
ACID:关系型数据库的事务原则,确保操作要么全做要么全不做,数据时刻保持正确。 e.g. 银行转账:A 向 B 转 100 元。步骤包含“A 扣款”和“B 收款”,这两步必须同时成功或同时失败回滚,绝不能出现 A 钱少了 B 却没收到的情况。 BASE:NoSQL 常用的设计理念,牺牲强一致性换取高可用,允许数据存在中间状态。 e.g. 朋友圈点赞:你点赞后,其他好友可能过几秒才看到红点亮起。系统不保证实时一致,但保证经过一段时间后数据最终同步。适合海量数据、高并发场景。
ACID(关系型数据库事务原则):
-
A (Atomicity) 原子性:事务不可分割,操作要么全成功,要么全失败回滚。
-
C (Consistency) 一致性:事务前后,数据必须符合规则(如余额不能为负),保持合法状态。
-
I (Isolation) 隔离性:并发事务互不干扰,各干各的,互相看不到中间过程。
-
D (Durability) 持久性:事务一旦提交,修改永久生效,宕机重启数据也不丢。
BASE(NoSQL / 分布式设计理念):
-
BA (Basically Available) 基本可用:系统出故障时,允许损失部分功能(如响应变慢、返回旧数据),但保证核心服务不挂。
-
S (Soft State) 软状态:允许数据存在中间状态(如副本同步中),不要求实时强一致。
-
E (Eventually Consistent) 最终一致性:不保证任意时刻数据一致,但承诺经过一段时间后,数据最终会同步到一致状态。
数据库三大范式
NF = Normal Form
数据库三大范式(1NF / 2NF / 3NF)是设计关系表的一套递进规范:
- 1NF:字段不可再分,每列都是原子值,没有“小表格”、“数组”字段。
- 2NF:在 1NF 基础上,消除“非主属性对主键的部分依赖”,只与主键整体相关。(针对于联合主键而言)
- 3NF:在 2NF 基础上,消除“非主属性对非主属性的传递依赖”,避免冗余和更新异常。
e.g.
1NF : 一个列为联系方式是不好的。分为邮件和电话两列更好。 2NF:若主键为(学号,课程号),存在一个列为姓名是不好的,姓名只依赖学号而不依赖课程号;存在一个列存成绩是正常的。所以就把学生表独立出来,原来的表只留成绩。 3NF:若学生表中有“学号、姓名、系名、系主任”,主键是学号。这里“系主任”依赖“系名”,而“系名”又依赖“学号”,构成了传递依赖(学号 -> 系名 -> 系主任)。这会导致数据冗余(同系的学生重复存储系主任)和更新异常(换系主任要改全表)。应将表拆分为:学生表(学号、姓名、系名)和系表(系名、系主任)。
外键
不过其实应用层来控制删除日志记录和控制性更好,只要不疏忽
索引
|
|
最左匹配:
如果创建了一个 (a, b, c) 联合索引。 可用
失效
前缀索引是一种特殊索引类型,它仅对文本字段的前N个字符建立索引,而不是对整个字段进行索引。这种方式特别适用于那些字段值很长,但查询时通常只基于字段值前几个字符进行的情况。
CREATE INDEX idx_name ON table_name (column_name(length));
要注意覆盖率和长度的平衡。
|
|
找到前缀选择性最接近且不低于全列选择性的最小长度。
MySQL事务的ACID特性是其核心,而InnoDB存储引擎通过Redo Log、Undo Log、Binlog以及MVCC和锁机制共同协作来实现这些特性。下面我将为您详细解析其原理与实现。
事务
ACID是数据库事务正确执行的四个关键特性,它们共同保证数据库操作的可靠性。
| 特性 | 含义 | 核心实现机制 |
|---|---|---|
| 原子性 (Atomicity) | 事务是不可分割的工作单位,事务中的操作要么全部成功,要么全部失败回滚。 | Undo Log (回滚日志) |
| 一致性 (Consistency) | 事务必须使数据库从一个一致性状态变换到另一个一致性状态。 | 由原子性、隔离性、持久性共同保障 |
| 隔离性 (Isolation) | 多个事务并发执行时,一个事务的执行不应影响其他事务的执行。 | 锁机制 + MVCC (多版本并发控制) |
| 持久性 (Durability) | 一个事务一旦提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和故障不应该对其有任何影响。 | Redo Log (重做日志) |
核心日志系统
MySQL(InnoDB)通过三类关键日志来实现ACID,它们协同工作,构成了事务可靠性的基石。
|
|
- Undo Log(回滚日志)—— 原子性
- 核心作用:记录数据修改前的旧值,用于事务回滚和实现MVCC的多版本读取。
- 工作原理:
- 当执行
INSERT、UPDATE、DELETE等操作时,InnoDB会先将修改前的数据写入Undo Log。 - 例如,对于一条
UPDATE语句,Undo Log会记录反向的UPDATE操作,将数据改回旧值。 - 如果事务需要回滚,InnoDB就会根据Undo Log中的记录执行逆向操作,将数据恢复到事务开始前的状态。
- 当执行
- 额外作用:Undo Log是InnoDB实现MVCC(多版本并发控制)的关键。它通过版本链将一行数据的历史版本串联起来,使得读操作可以无锁地访问数据的一致性快照。
- Redo Log(重做日志)—— 持久性的保障
- 核心作用:记录事务对数据页的物理修改,确保事务的持久性,实现崩溃恢复。
- 工作原理:
- 采用WAL (Write-Ahead Logging) 机制。事务提交时,InnoDB会先将修改操作记录到Redo Log并持久化到磁盘,然后再慢慢将Buffer Pool中的脏页刷入数据文件。
- 这将原本的随机写(更新数据文件的不同位置)转化为顺序写(追加日志文件),极大地提升了性能。
- 在系统崩溃重启后,InnoDB会自动检查Redo Log,将已提交但未刷盘的修改重新应用,从而恢复数据。
- 关键配置:
innodb_flush_log_at_trx_commit参数控制Redo Log的刷盘策略。1:最安全,每次事务提交都刷盘,保证不丢数据(这是默认值)。0:每秒刷盘一次,可能丢失最后一秒的事务。2:提交时写入文件系统缓存,由系统决定何时刷盘,性能好但存在风险。
- 补充:
- Buffer Pool (缓冲池):InnoDB 引擎为了弥补内存和磁盘速度的巨大鸿沟,在内存中划分了一块区域。数据库不仅把经常读取的数据放在这里,所有的修改操作也都是优先在这里进行的。
- Clean Page (干净页):内存里的数据和磁盘里的数据一模一样。
- Dirty Page (脏页):事务在内存(Buffer Pool)中修改了某个数据页,但这个修改还没来得及写回到磁盘的数据文件中。此时,内存里的数据比磁盘里的新,这个数据页就叫“脏页”。
- Binlog(二进制日志)—— 复制与恢复的桥梁
- 核心作用:记录所有修改数据的SQL语句(逻辑日志),主要用于主从复制和基于时间点的数据恢复。
- 与Redo Log的区别:
- 产生层级:Redo Log是InnoDB引擎层特有的;Binlog是MySQL Server层实现的。
- 日志内容:Redo Log记录物理修改(对数据页的改动);Binlog记录逻辑操作(SQL语句或行变更)。
- 写入时机:事务提交时,Redo Log采用循环写,Binlog采用追加写。
- 两阶段提交:为保持主从数据一致性,InnoDB在事务提交时采用两阶段提交:先写Redo Log,再写Binlog。
隔离性的实现:锁与MVCC
下面是SQL标准的隔离级别,MySQL基本解决幻读问题,到底解决没有这个属于哲学问题。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | √ | √ | √ |
| READ COMMITTED | × | √ | √ |
| REPEATABLE READ | × | × | √ |
| SERIALIZABLE | × | × | × |
隔离性主要解决并发事务之间的干扰问题,InnoDB采用锁机制和MVCC相结合的方式实现。
- 锁机制 InnoDB支持行级锁,通过给数据加锁来避免并发冲突。
- 共享锁(S锁):允许事务读一行数据,其他事务可以读但不能写。
- 排他锁(X锁):允许事务删除或更新一行数据,其他事务不能读也不能写。
- 行锁算法:包括Record Lock(记录锁)、Gap Lock(间隙锁)、Next-Key Lock(临键锁),在可重复读隔离级别下,Next-Key Lock用于防止幻读。
- MVCC(多版本并发控制) MVCC是InnoDB实现高并发的关键,它在很多情况下避免了加锁,实现了非阻塞读。
- 核心思想:为每个事务提供一个数据快照,使得读操作不需要加锁就能看到一致的历史数据。
- 实现元素:
- 隐藏字段:每行数据包含
DB_TRX_ID(最近修改事务ID)和DB_ROLL_PTR(回滚指针)。 - Undo Log版本链:通过回滚指针将一行数据的多个版本串联起来。
- Read View(一致性视图):事务开始时生成,包含“当前活跃事务ID列表”和“最大事务ID”,用于判断数据版本对该事务是否可见。
- 隐藏字段:每行数据包含
- 不同隔离级别的MVCC:
- 读已提交 (READ COMMITTED):每次查询都会生成一个新的Read View,能看到其他事务已提交的修改。
- 可重复读 (REPEATABLE READ):事务第一次查询时生成Read View,整个事务期间都使用这个快照,因此能看到事务开始时的一致性状态。
事务执行与崩溃恢复流程
理解ACID特性的实现,关键在于看清楚事务从开始到提交,以及发生崩溃后如何处理。
- 事务正常提交流程:
- 事务开始,分配事务ID。
- 修改数据前,先写入Undo Log。
- 在Buffer Pool中修改数据页(形成脏页),并将修改记录写入Redo Log Buffer。
- 事务提交时,根据
innodb_flush_log_at_trx_commit设置将Redo Log Buffer刷盘。 - 写入Binlog(两阶段提交的第二个阶段)。
- 提交完成,返回结果给客户端。
- 崩溃恢复流程:
- 数据库重启后,检查Redo Log。
- 前滚:对于已提交但未刷盘的事务,重新应用Redo Log,恢复数据。
- 回滚:对于未提交的事务,通过Undo Log撤销所有修改。
- 最终,数据库恢复到一致状态。
总结
MySQL(InnoDB)的事务特性实现是一个精密协作的体系:
- 原子性:由 Undo Log 实现,提供回滚能力。
- 持久性:由 Redo Log 实现,采用WAL机制保证崩溃恢复。
- 隔离性:由 锁机制 和 MVCC 共同实现,平衡了性能与正确性。
- 一致性:是以上三个特性(A、I、D)共同追求的目标,最终通过数据库的约束机制达成。 这套机制使得MySQL能够在高并发环境下,既保证了数据的可靠性和一致性,又提供了良好的性能。理解这些底层原理,对于数据库优化、故障排查以及高可用架构设计都至关重要。
优化
|
|
|
|
PostgreSQL
这个相对于mysql的烂大街八股,还是新潮一些的。来简单的学习一下事务的实现和mysql有啥区别。
原子性——事务回撤:
- 做法:当你更新一行数据时,PG 不会修改原有的行(Tuple)。它会插入一行全新的数据,并将旧行标记为“过期”。
- MVCC 实现:每行数据有两个隐藏字段:
xmin(创建该行的事务 ID)和xmax(删除/更新该行的事务 ID)。事务根据这两个 ID 和自己的活跃事务快照(Snapshot)进行对比,决定哪一行对自己可见。 - 优点:回滚(Rollback)极其快!只需要把事务标记为已中断(Aborted),之前的修改由于
xmin不符合条件,自动就不可见了。不需要像 MySQL 那样去做任何物理上的逆向操作。 - 代价:那么代价是什么呢?很显然如果不定期清除,历史表就爆炸了。开始吟唱:Vacuum 是 PostgreSQL 独有的数据清理维护功能,它主要负责清理这些废弃数据、回收可用空间,同时更新数据库统计信息来优化查询效率,还能避免事务 ID 异常引发的数据问题。普通的 Vacuum 不会锁表,业务可以正常读写,仅将空间标记为可复用;Vacuum Full 会锁表、彻底收缩表空间并把空闲空间返还给操作系统,生产环境要在低峰期谨慎使用。PG 默认有 autovacuum 自动清理机制,后台会自动运行,大批量增删改数据后,可在业务低峰手动执行 Vacuum Analyze,防止表膨胀、提升查询性能。
持久性——防断电:
只有 WAL (Write Ahead Log)。PG 的 WAL 承担了 Redo 的功能,用于宕机恢复。由于新旧版本都在主表里,回滚时不需要 Undo 信息,所以 WAL 不需要记录复杂的撤销逻辑。
隔离级别:
- 幻读(Phantom Read):
- MySQL RR:通过 Next-Key Locks(锁)和 MVCC(快照读)共同解决。
- PostgreSQL RR:由于其天然的多版本机制,PG 的 Repeatable Read 级别本身就不允许幻读。
- 人话补充:MySQL的read view 是语句级别的,使用当前读后就会破坏掉。而PG在一个事务里面的一直就是最开始哪个read view。
- 可串行化(Serializable):
- MySQL:通过强制加锁(Locking-based)实现。
- PostgreSQL:使用的是 SSI (Serializable Snapshot Isolation)。它不阻塞读写,而是通过监控事务间的“读写冲突”来发现可能的串行化异常,如果发现风险,直接报错让事务重试。这是一种更现代、学术界公认更优的做法。
索引:
MySQL:主键是聚簇索引,数据就在索引上。
PostgreSQL:表数据存储在 Heap(堆) 中,索引(如 B-Tree)存储的是指向堆中物理位置的 TID(页号+偏移量)。
- 写放大隐患:在 PG 中,即使你更新的是一个非索引字段,由于产生了新行,物理位置变了,该表所有的索引都得更新指向新的位置。
- HOT 优化 (Heap Only Tuple):为了解决这个问题,PG 引入了 HOT 技术。如果新行和旧行在同一个 Page 里,索引可以继续指向旧行,通过旧行的“指路牌”找到新行,从而避免更新所有索引。
注意此堆非彼堆,是硬盘的概念,堆由页组成。
一个标准 Heap Page 的内部构造从低地址到高地址依次为:
- Page Header(页头):24字节,记录页的元数据,如校验和(Checksum)、空闲空间起始与结束位置等。
- Line Pointers (ItemId / Linp):一个 4 字节的数组。每当插入一行数据,这里就增加一个元素。它充当了页内索引,存储了该行数据在页内的物理偏移量。
- Free Space(空闲空间):新数据插入时,从此处分配空间。
- Tuples(元组/行数据):实际的数据行。它是从页的尾部向上增长的。
每一行数据(Tuple)由两部分组成:
- HeapTupleHeader:包含 MVCC 控制字段。
xmin:插入该行的事务 ID。xmax:删除或更新该行的事务 ID。t_ctid:物理指针,指向自身或新版本(用于 HOT 链)。t_infomask:状态位(如该行是否已提交、是否有 OID 等)。
- User Data:实际的列数据。
RabbitMQ
学这个东西有一种命苦的感觉,虽然没啥用,也得背一点。
这个按照AMQP实现非常的好啊,非常的规范。
AMQP 模型的三大组件:
|
|
| 交换机类型 | 路由规则 | 适用场景 | 具体匹配示例 |
|---|---|---|---|
| Direct | 精确匹配:消息 routing-key 与绑定键完全一致 | 一对一通信(任务分发) | 绑定键 order.pay,仅路由键为 order.pay 的消息进入该队列 |
| Fanout | 广播模式:忽略 routing-key,发送到所有绑定队列 | 一对多通知(如事件广播) | 无论路由键是什么,消息都会发给所有绑定到此交换机的队列 |
| Topic | 模糊匹配:支持*(单级) 和#(多级) 通配符 |
多条件路由(如新闻分类订阅) | 绑定键 log.* 匹配 log.error;log.# 可匹配 log.error.mysql、log |
| Headers | 基于消息 headers 属性匹配,不依赖 routing-key | 复杂属性过滤场景 | 消息头 {"type":"order"},绑定 x-match=any 时,只要包含该键值对即可匹配 |