前言

不知道我是被pua了,还是真的不了解底层。但是我还是决定沉淀一下。

Redis

redis 作为常见考点,也是很常见的缓存方案,不过有观点认为考虑到网络延迟(TCP 往返 + 序列化 + 网络调度)有的时候还不如直接postgreSQL?

数据结构

  1. string:典中典,不过也不会有人问的

    其数据结构是SDS(Simple Dynamic String),说白了就是比C语言字符串多了个长度,放到结构体的头部(弱弱地插一句才知道redis是用c写的)

  2. 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] 为空表。

  3. set:集合。其实秒杀对应的就是集合。同上,只要你不存在逆天想要秒杀多个,人类都会用这个。

    数据结构是intsethashtableset-max-intset-entries默认是512,数量小于等于512且在无字符串的时候会用inset。

  4. zset(sorted set):有序集合。用的是skiplist/listpack,用来做排行榜,不过目前没遇到这个业务需求。

    我们先了解传统的跳表skiplist。每一个元素都有随机(通常50%,标准理论跳表用 p=1/2,但 Redis 源码固定 p=1/4(ZSKIPLIST_P=0.25))的概率来添加一个升到上一层的复制品,所以跳表的层数是随机的。

    1
    2
    3
    4
    5
    6
    7
    
    typedef struct SkipListNode {
        int key;               
        int value;              
        int level;    // 当前节点的层级高度 (1 到 MAX_LEVEL)             
        // 柔性数组:存储每一层的前进指针
        struct SkipListNode *forward[]; // forward[i] 指向第 i 层的下一个节点
    } SkipListNode;
    

    然后是redis的定制版本。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    typedef struct zskiplistNode {
        sds ele;          // 成员(member)
        double score;     // 分数
        struct zskiplistNode *backward; // 后退指针,用于反向遍历
        struct zskiplistLevel {
            struct zskiplistNode *forward; // 前进指针
            unsigned long span;            // 跨度,用于计算排名
        } level[];        // 柔性数组,表示多层
    } zskiplistNode;
    

    肉眼可见,它多了一个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 元素(远超需求)

    最后要指出头指针指向的是最高层的节点,然后推荐自己脑袋模拟一下。

  5. bitmap 位图,如果你做过二进制枚举或者二进制dp压缩的问题,这个很好理解,就是一样的道理,其底层是sds。其场景如:用户签到,是否在线等二元情景。

    常见问题如用户采用uuid,跨度过大,浪费内存,我们就使用hash keyid:mapping field uuid value 用lua脚本写一个自增来进行映射。

  6. HyperLogLog 基于 伯努利试验哈希函数。通过哈希函数将输入数据映射为比特串,统计比特串中第一个出现 1 的位置(称为 k 值),并利用数学公式估算集合的基数。为了降低误差,HyperLogLog 将数据分为多个桶(默认 16384 个),每个桶独立计算 k 值,最终通过 调和平均数 合并结果。(不会)

最后备注一下listpack和ziplist的区别。(这两个东西没有struct的定义,直接连续分布在内存,十分的黑魔法)至于为什么不用struct,是因为c语言会进行内存对齐,一般是8Byte的倍数,而且ziplist的prevlen和listpack的encoding是变长的,struct也无法支持。

ziplist分布

1
<zlbytes> <zltail> <zllen> <entry1> <entry2> ... <zlend>

zlbytes:表示整个ziplist占用的字节数 ztail:表示最后一个entry的偏移量 zllen:表示entry的数量

entry分布

1
2
| <---- prevlen ----> | <-- encoding --> | <--- data ---> |
| 1 byte OR 5 bytes   | 1/2/5 bytes        | variable       |

prevlen: 如果前一个节点长度1B能存下就用1B,否则高位固定为0xFE,剩下的4B存实际长度。encoding:最高两个bit存储类型,剩下的表示长度,如果是整数剩下的表示整数类型

listpack分布

1
<total_bytes> <num_elements> <entry1> <entry2> ... <end_byte>

total_bytes:表示总字节数 num_elements:表示数量 可见少了一个ztail,所以listpack访问尾部的方式变了,如果想要知道最后一个元素的位置只能O(n)便利。

entry分布

1
[encoding + (可选len字段) + payload] [backlen]

backlen:存储entry自身除了backlen的长度

持久化

这个就是Redis Database(RDB)和Append Only File(AOF)

RDB简单粗暴,直接把内存存到文件里面,并且默认是启用的。唯一需要记住的就是有同步save和异步bgsave两种,父进程fork后交给子进程。

关键参数 save <seconds> <changes>,多少时间内多少次改变触发保存。

AOF,顺序读写,顺序读写最快了,有三个等级

1
2
3
appendfsync always    # 每次写都同步,最安全,最慢
appendfsync everysec    # 每秒同步,推荐折中方案
appendfsync no        # 由OS决定何时同步,最快,最不安全

随着时间推移,AOF 文件会变得越来越大。Redis 会在后台自动触发 AOF 重写:创建一个当前数据集的最小体积 AOF 文件(例如,如果一个 key 被设置了多次,最终只保留最后一次的值)。

目前生产常用混合持久化?

当开启混合持久化后,在进行 AOF 重写时,新产生的 AOF 文件不再全是纯命令日志,而是:

  1. 前半部分:以 RDB 二进制格式存储当前内存的全量快照。
  2. 后半部分:以 AOF 文本格式存储增量命令。

为什么快

内存快。

单线程执行命令,避免上下文切换和锁竞争及一致性问题。

IO多路复用,使用epoll机制。这里扩展一下IO模型。

阻塞IO,非阻塞IO(轮询),IO多路复用。

总所周知,Linux一切皆文件,一个网络连接Socket被视为一个文件标识符FD

IO多路复用分为:

  1. select:将所有的FD拷贝到内核,内核遍历检查。检查遍历慢,FD数量上限为1024,每次用户态和内核态切换调用全拷贝FD集
  2. poll:用链表代替数组,仅突破数量限制
  3. epoll:采用红黑树logN增删改所有FD,维护一个就绪链表,返回就绪的FD。

分布式容灾

redis挂了怎么办,虽然你妈挂了mysql都不会挂,但是redis可能挂,因为可能会oom,算了oom也不太可能,你就当被人拔电源了。

  1. 主从复制(Master-Slave Replication)

    Master 负责写,Slave 负责读和备份。Master 将写命令异步发送给 Slave。一个 Master 可以有多个 Slave。数据只能从 Master 流向 Slave。

    同步分为全量同步和增量同步。全量的时候发送的是rdb,或者 Master 不生成 RDB 文件到磁盘,而是直接通过 Socket 发送给 Slave。适合磁盘慢但网络快的场景。

    增量同步时,有一个全局的offset,每次master写入的时候,把操作指令发送给slave,slave会ack自己目前的offset。

    然后就是master挂了怎么办,master挂了就炸了,所以生产没有用主从结构的。

  2. 哨兵模式(Sentinel)

    一组 Sentinel 节点监控 Master 和 Slave 的状态。当 Master 不可达时,Sentinel 选举新的 Master,并通知客户端更新连接地址。

    Sentinel既可以和redis在一个服务器上跑,也可以放到独立的服务器来保证独立性(有点土豪的),一般建议至少三台。

    哨兵系统主要完成以下三个任务:

    1. 监控(Monitoring): 哨兵会不断检查主节点和从节点是否运作正常。
    2. 通知(Notification): 当被监控的某个 Redis 节点出现问题时,哨兵可以通过 API 向管理员或其他应用程序发送通知。
    3. 自动故障转移(Automatic Failover): 如果主节点无法正常工作,哨兵会启动故障转移流程, 将一个从节点升级为主节点。 让其他的从节点改为复制新的主节点。 当客户端尝试连接失效的主节点时,集群也会向客户端返回新主节点的地址,使得集群可以使用新的主节点代替失效的主节点。

    然后哨兵也需要有一个主节点,当多个 Sentinel 认为 Master 客观下线(ODOWN)后,需要有一个 Sentinel 来主导故障转移流程(比如选择新的 Master、通知其他 Sentinel 和客户端)。这个主导者就是 Leader Sentinel。其过程类似Raft。

  3. 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

  • 消费策略

    • 偏移提交策略:
      1. 自动提交:每5秒自动提交
      2. 手动提交
      3. 手动异步提交
      4. 事务+手动提交
    • 负载均衡策略:
      1. 范围分配:按照Partition序号范围分配给消费者
      2. 轮询
      3. 粘性分配:优先上次分配结果
      4. 增量粘性分配

拉模式:Kafka 的 Broker 原生不支持 Push 模式,消费者与 Broker 之间的数据传递是纯粹的 Pull(拉取)模式

Recoder:我们称一条消息为Record,里面有包含headers,key,value,timestamp,其中headers和key是可选的。

  1. Key 消息键,字节数组,可为空。用于分区路由(哈希取模)和日志压缩去重,决定消息发往哪个分区。
  2. Value 消息体,字节数组,不能为空。存储实际业务数据,是消息的核心内容。
  3. Timestamp 时间戳,long 类型。有两种:生产者发送时间(CreateTime)、Broker 写入时间(LogAppendTime),用于消息过期、时序计算、按时间回溯消费。
  4. 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触发场景:

  1. 组成员数量发生变化。
  2. 订阅主题数量发生变化。
  3. 订阅主题的分区数发生变化。

对于2.3版本之前,基于eager 协议,非常的简单粗暴。Coordinator 只知道“现在的状态”和“下一个状态”,转换状态的方式就是全部清空再重填。

过程如下:

  1. 触发与心跳发现:当组内成员发生变动(新加入、离开或被认为死亡)或订阅的Topic分区数变化时,再平衡被触发。消费者通过心跳(Heartbeat)通信感知到再平衡开始。
  2. 停止消费与提交位移 (Revoke):收到通知后,消费者需在当前poll()循环结束前完成消息处理,并触发onPartitionsRevoked()回调。在此回调中,必须同步提交Offset以避免消息丢失或重复。随后,消费者将释放所有分区并停止拉取新消息。
  3. 加入组 (JoinGroup):所有消费者重新向协调者发送JoinGroup请求,上报自身订阅信息。协调者会将第一个加入的成员指定为"组领导者",并将所有成员信息发送给它。
  4. 领导者分配 (Leader Assignment):组领导者根据partition.assignment.strategy(分区分配策略,如Range、RoundRobin、Sticky)计算分区分配方案,并通过SyncGroup请求将结果发送给协调者。
  5. 同步分配 (SyncGroup) 并恢复消费:协调者将分配方案下发给所有消费者。消费者收到分区后触发onPartitionsAssigned()回调,开始从各自的分区拉取消息,再平衡过程结束。

其中的重点是停止消费与提交位移这一步,我们要考虑消费者在poll这一轮必须把获取的所有消息在不超过max.poll.interval.ms的时间消费完(默认是5分钟其实绰绰有余),如果真做不到可以考虑调整max.poll.recordsmax.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:若学生表中有“学号、姓名、系名、系主任”,主键是学号。这里“系主任”依赖“系名”,而“系名”又依赖“学号”,构成了传递依赖(学号 -> 系名 -> 系主任)。这会导致数据冗余(同系的学生重复存储系主任)和更新异常(换系主任要改全表)。应将表拆分为:学生表(学号、姓名、系名)和系表(系名、系主任)。

外键
1
2
3
4
5
6
CREATE TABLE students ( 
id INT PRIMARY KEY,
name VARCHAR(50),
course_id INT,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
);

不过其实应用层来控制删除日志记录和控制性更好,只要不疏忽

索引
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
mindmap
  root((MySQL索引分类体系))
    按数据结构
      B+tree索引
        :最常用,支持范围查询
      Hash索引
        :等值查询极快,不支持范围
      Full-text索引
        :用于全文搜索
    按物理存储
      聚簇索引
        :数据与索引一体
        :叶子存整行数据
      二级索引
        :叶子存主键值
        :需“回表”查询
    按字段特性
      主键索引
        :唯一且非空
      唯一索引
        :允许NULL值
      普通索引
        :基本类型
      前缀索引
        :节省空间
    按字段个数
      单列索引
        :单个字段
      联合索引
        :多个字段
        :遵循最左前缀

最左匹配:

如果创建了一个 (a, b, c) 联合索引。 可用

1
2
where a = ? and b = ?;
where a = ?;

失效

1
2
where b = ?;
where b = ? and c = ?;

前缀索引是一种特殊索引类型,它仅对文本字段的前N个字符建立索引,而不是对整个字段进行索引。这种方式特别适用于那些字段值很长,但查询时通常只基于字段值前几个字符进行的情况。

CREATE INDEX idx_name ON table_name (column_name(length));

要注意覆盖率和长度的平衡。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- 1. 计算完整列的选择性(作为基准)
SELECT COUNT(DISTINCT column_name) / COUNT(*) FROM table_name;

-- 2. 计算不同前缀长度的选择性
SELECT 
    COUNT(DISTINCT LEFT(column_name, 3)) / COUNT(*) AS sel_3,
    COUNT(DISTINCT LEFT(column_name, 4)) / COUNT(*) AS sel_4,
    COUNT(DISTINCT LEFT(column_name, 5)) / COUNT(*) AS sel_5,
    COUNT(DISTINCT LEFT(column_name, 6)) / COUNT(*) AS sel_6
    -- ...继续测试更多长度
FROM table_name;

找到前缀选择性最接近且不低于全列选择性的最小长度。

MySQL事务的ACID特性是其核心,而InnoDB存储引擎通过Redo Log、Undo Log、Binlog以及MVCC和锁机制共同协作来实现这些特性。下面我将为您详细解析其原理与实现。

事务

ACID是数据库事务正确执行的四个关键特性,它们共同保证数据库操作的可靠性。

特性 含义 核心实现机制
原子性 (Atomicity) 事务是不可分割的工作单位,事务中的操作要么全部成功,要么全部失败回滚。 Undo Log (回滚日志)
一致性 (Consistency) 事务必须使数据库从一个一致性状态变换到另一个一致性状态。 原子性、隔离性、持久性共同保障
隔离性 (Isolation) 多个事务并发执行时,一个事务的执行不应影响其他事务的执行。 锁机制 + MVCC (多版本并发控制)
持久性 (Durability) 一个事务一旦提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和故障不应该对其有任何影响。 Redo Log (重做日志)
核心日志系统

MySQL(InnoDB)通过三类关键日志来实现ACID,它们协同工作,构成了事务可靠性的基石。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
flowchart LR
    subgraph A[事务执行流程]
        direction TB
        A1[事务开始] --> A2[记录Undo Log]
        A2 --> A3[修改Buffer Pool<br>生成脏页]
        A3 --> A4[写入Redo Log Buffer]
        A4 --> A5{事务提交?}
        A5 -- 是 --> A6[刷Redo Log到磁盘]
        A6 --> A7[写Binlog]
        A7 --> A8[提交成功]
        A5 -- 否 --> A9[通过Undo Log回滚]
    end
    subgraph B[崩溃恢复流程]
        direction TB
        B1[数据库重启] --> B2{检查Redo Log}
        B2 -- 有已提交事务 --> B3[重放Redo Log<br>前滚]
        B3 --> B4[数据恢复]
        B2 -- 有未提交事务 --> B5[通过Undo Log回滚]
        B5 --> B4
    end
  1. Undo Log(回滚日志)—— 原子性
  • 核心作用:记录数据修改前的旧值,用于事务回滚和实现MVCC的多版本读取。
  • 工作原理
    • 当执行 INSERTUPDATEDELETE 等操作时,InnoDB会先将修改前的数据写入Undo Log。
    • 例如,对于一条 UPDATE 语句,Undo Log会记录反向的 UPDATE 操作,将数据改回旧值。
    • 如果事务需要回滚,InnoDB就会根据Undo Log中的记录执行逆向操作,将数据恢复到事务开始前的状态。
  • 额外作用:Undo Log是InnoDB实现MVCC(多版本并发控制)的关键。它通过版本链将一行数据的历史版本串联起来,使得读操作可以无锁地访问数据的一致性快照。
  1. 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)中修改了某个数据页,但这个修改还没来得及写回到磁盘的数据文件中。此时,内存里的数据比磁盘里的新,这个数据页就叫“脏页”。
  1. 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相结合的方式实现。

  1. 锁机制 InnoDB支持行级锁,通过给数据加锁来避免并发冲突。
  • 共享锁(S锁):允许事务读一行数据,其他事务可以读但不能写。
  • 排他锁(X锁):允许事务删除或更新一行数据,其他事务不能读也不能写。
  • 行锁算法:包括Record Lock(记录锁)、Gap Lock(间隙锁)、Next-Key Lock(临键锁),在可重复读隔离级别下,Next-Key Lock用于防止幻读。
  1. MVCC(多版本并发控制) MVCC是InnoDB实现高并发的关键,它在很多情况下避免了加锁,实现了非阻塞读。
  • 核心思想:为每个事务提供一个数据快照,使得读操作不需要加锁就能看到一致的历史数据。
  • 实现元素
    1. 隐藏字段:每行数据包含 DB_TRX_ID(最近修改事务ID)和 DB_ROLL_PTR(回滚指针)。
    2. Undo Log版本链:通过回滚指针将一行数据的多个版本串联起来。
    3. Read View(一致性视图):事务开始时生成,包含“当前活跃事务ID列表”和“最大事务ID”,用于判断数据版本对该事务是否可见。
  • 不同隔离级别的MVCC
    • 读已提交 (READ COMMITTED):每次查询都会生成一个新的Read View,能看到其他事务已提交的修改。
    • 可重复读 (REPEATABLE READ):事务第一次查询时生成Read View,整个事务期间都使用这个快照,因此能看到事务开始时的一致性状态。
事务执行与崩溃恢复流程

理解ACID特性的实现,关键在于看清楚事务从开始到提交,以及发生崩溃后如何处理。

  1. 事务正常提交流程
    • 事务开始,分配事务ID。
    • 修改数据前,先写入Undo Log。
    • 在Buffer Pool中修改数据页(形成脏页),并将修改记录写入Redo Log Buffer。
    • 事务提交时,根据 innodb_flush_log_at_trx_commit 设置将Redo Log Buffer刷盘。
    • 写入Binlog(两阶段提交的第二个阶段)。
    • 提交完成,返回结果给客户端。
  2. 崩溃恢复流程
    • 数据库重启后,检查Redo Log。
    • 前滚:对于已提交但未刷盘的事务,重新应用Redo Log,恢复数据。
    • 回滚:对于未提交的事务,通过Undo Log撤销所有修改。
    • 最终,数据库恢复到一致状态。
总结

MySQL(InnoDB)的事务特性实现是一个精密协作的体系:

  • 原子性:由 Undo Log 实现,提供回滚能力。
  • 持久性:由 Redo Log 实现,采用WAL机制保证崩溃恢复。
  • 隔离性:由 锁机制MVCC 共同实现,平衡了性能与正确性。
  • 一致性:是以上三个特性(A、I、D)共同追求的目标,最终通过数据库的约束机制达成。 这套机制使得MySQL能够在高并发环境下,既保证了数据的可靠性和一致性,又提供了良好的性能。理解这些底层原理,对于数据库优化、故障排查以及高可用架构设计都至关重要。

优化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
-- 1. 建表
DROP TABLE IF EXISTS employee;
CREATE TABLE employee (
    empid  INTEGER PRIMARY KEY,
    name   TEXT NOT NULL,
    dept   TEXT NOT NULL,
    salary NUMERIC(10,2)
);

-- 2. 批量插入 1000 条数据
INSERT INTO employee (empid, name, dept, salary)
SELECT
    id,
    'User_' || id,
    CASE WHEN id % 5 = 0 THEN 'Sales'
         WHEN id % 5 = 1 THEN 'HR'
         WHEN id % 5 = 2 THEN 'Tech'
         WHEN id % 5 = 3 THEN 'Finance'
         ELSE 'Admin'
    END,
    3000 + random() * 7000
FROM generate_series(1, 1000) AS id;

-- 3. 未加索引:真实执行计划 + 真实耗时
EXPLAIN ANALYZE
SELECT * FROM employee WHERE dept = 'Tech' AND salary > 5000;

-- 4. 创建联合索引
CREATE INDEX idx_employee_dept_salary ON employee(dept, salary);

-- 5. 加索引后:再次真实执行计划对比
EXPLAIN ANALYZE
SELECT * FROM employee WHERE dept = 'Tech' AND salary > 5000;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Output:

117 ms
DROP TABLE
CREATE TABLE
INSERT 0 1000
                                               QUERY PLAN                                                
---------------------------------------------------------------------------------------------------------
 Seq Scan on employee  (cost=0.00..20.80 rows=1 width=84) (actual time=0.009..0.088 rows=137.00 loops=1)
   Filter: ((salary > '5000'::numeric) AND (dept = 'Tech'::text))
   Rows Removed by Filter: 863
   Buffers: shared hit=8
 Planning:
   Buffers: shared hit=11
 Planning Time: 0.069 ms
 Execution Time: 0.098 ms
(8 rows)

CREATE INDEX
                                                              QUERY PLAN                                                              
--------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on employee  (cost=4.30..9.33 rows=2 width=84) (actual time=0.027..0.038 rows=137.00 loops=1)
   Recheck Cond: ((dept = 'Tech'::text) AND (salary > '5000'::numeric))
   Heap Blocks: exact=8
   Buffers: shared hit=8 read=2
   ->  Bitmap Index Scan on idx_employee_dept_salary  (cost=0.00..4.29 rows=2 width=0) (actual time=0.022..0.022 rows=137.00 loops=1)
         Index Cond: ((dept = 'Tech'::text) AND (salary > '5000'::numeric))
         Index Searches: 1
         Buffers: shared read=2
 Planning:
   Buffers: shared hit=23 read=1
 Planning Time: 0.099 ms
 Execution Time: 0.049 ms
(12 rows)

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 的内部构造从低地址到高地址依次为:

  1. Page Header(页头):24字节,记录页的元数据,如校验和(Checksum)、空闲空间起始与结束位置等。
  2. Line Pointers (ItemId / Linp):一个 4 字节的数组。每当插入一行数据,这里就增加一个元素。它充当了页内索引,存储了该行数据在页内的物理偏移量。
  3. Free Space(空闲空间):新数据插入时,从此处分配空间。
  4. Tuples(元组/行数据):实际的数据行。它是从页的尾部向上增长的

每一行数据(Tuple)由两部分组成:

  • HeapTupleHeader:包含 MVCC 控制字段。
    • xmin:插入该行的事务 ID。
    • xmax:删除或更新该行的事务 ID。
    • t_ctid:物理指针,指向自身或新版本(用于 HOT 链)。
    • t_infomask:状态位(如该行是否已提交、是否有 OID 等)。
  • User Data:实际的列数据。

RabbitMQ

学这个东西有一种命苦的感觉,虽然没啥用,也得背一点。

这个按照AMQP实现非常的好啊,非常的规范。

AMQP 模型的三大组件

1
生产者(Producer) → 交换机(Exchange) → 队列(Queue) → 消费者(Consumer)
交换机类型 路由规则 适用场景 具体匹配示例
Direct 精确匹配:消息 routing-key 与绑定键完全一致 一对一通信(任务分发) 绑定键 order.pay,仅路由键为 order.pay 的消息进入该队列
Fanout 广播模式:忽略 routing-key,发送到所有绑定队列 一对多通知(如事件广播) 无论路由键是什么,消息都会发给所有绑定到此交换机的队列
Topic 模糊匹配:支持*(单级) 和#(多级) 通配符 多条件路由(如新闻分类订阅) 绑定键 log.* 匹配 log.errorlog.# 可匹配 log.error.mysqllog
Headers 基于消息 headers 属性匹配,不依赖 routing-key 复杂属性过滤场景 消息头 {"type":"order"},绑定 x-match=any 时,只要包含该键值对即可匹配