[{"content":"","date":"2026年4月28日","externalUrl":null,"permalink":"/categories/interview/","section":"技术专栏","summary":"","title":"Interview","type":"categories"},{"content":"","date":"2026年4月28日","externalUrl":null,"permalink":"/tags/interview/","section":"Tags","summary":"","title":"Interview","type":"tags"},{"content":"","date":"2026年4月28日","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"2026年4月28日","externalUrl":null,"permalink":"/categories/redis/","section":"技术专栏","summary":"","title":"Redis","type":"categories"},{"content":"","date":"2026年4月28日","externalUrl":null,"permalink":"/series/redis/","section":"专题系列","summary":"","title":"Redis","type":"series"},{"content":"","date":"2026年4月28日","externalUrl":null,"permalink":"/tags/redis/","section":"Tags","summary":"","title":"Redis","type":"tags"},{"content":" Redis经常作为MySQL的缓存来使用，为什么？ # 有几个要点：\nMySQL是磁盘操作，性能不高\nRedis内存操作，性能非常高\n缓存本质是高速存储临时存储低速存储的数据\n回答 # MySQL是关系型数据库系统，操作数据需要访问磁盘，性能不高，通常就几千QPS，而Redis基于内存并做了很多优化，性能高达10W QPS，一些热点数据就可以缓存到Redis，查询时先查Redis，不存在才查MySQL，这种Redis+MySQL结合的方式可以有效提高系统QPS。\nRedis 和 Mysql 的一致性问题⭐⭐⭐⭐⭐ # 回答 # 如何保证删除缓存操作一定能成功？ # 缓存删除是一次 Redis 操作，可能因为网络波动等原因执行失败，提高删除成功率有2种思路，一个是失败就扔入重试队列，但是扔入队列这步也可能失败，所以只能说是提高，第二种就是绕过这个问题，缓存删除一般是为了数据同步，可以使用订阅MySQL BinLog的方式来同步数据。\n回答 # 可以引入消息队列，删除缓存的操作由消费者来做，删除失败的话重新去消息队列拉取相应的操作，超过一定次数没有删除成功就像业务层报错。\n如果是MySQL、Redis缓存同步场景，为了保证成功率，可以用一个消费服务订阅 MySQL binlog 日志，拿到具体要操作的数据，然后再向Redis执行缓存删除操作。\n业务缓存一致性要求高怎么办？ # 既然用了缓存，就始终存在不一致性的时间，只能说尽可能减少这个时间，不可能完全一致，如果需要完全一致就应该用缓存。\n回答 # 延迟双删是提高一致性的方案，先删除缓存，然后更新数据库，等待一段时间再删除缓存。保证第一个操作再睡眠之后，第二个操作完成更新缓存操作。但是具体睡眠多久其实是个玄学，很难评估出来，这个方案也只是尽可能保证一致性而已，依然也会出现缓存不一致的现象。\n如何避免缓存失效 # 缓存正常来说都是有过期时间的，过期时间到了，这时候缓存就会被删除，对于MySQL（或其它数据源）而言也就是缓存失效了\n回答 # 首先业务发现缓存失效，是会去读MySQL数据重新加载进去的，但是为了尽可能避免缓存失效，我们可以由后台线程频繁地检测缓存是否有效，检测到即将失效了马上从数据库读取数据，并更新到缓存。\n另外，在业务刚上线的时候，最好提前把数据缓起来，而不是等待用户访问才来触发缓存构建，这就是所谓的缓存预热。\nRedis 做秒杀场景 # Redis在秒杀场景主要的应用方式是两个。\n一个是把Redis当消息队列用，用于削峰，一个是用Redis记录库存，进行库存的加减。\n回答 # Redis可以用来记录库存，利用Redis的高性能进行库存的扣减，一个Redis处理6W的请求问题不大，100W/s流量就20台Redis来支撑，当然，每个节点都要做主从容灾。\n另一个方式就是把Redis作为轻量级消息队列，来接受请求，但是不如kafka这种可靠。\nRedis管道有什么用？ # Redis 管道（Pipeline）是一种优化客户端与 Redis 服务器之间通信的机制，主要用于减少网络往返时间（RTT，Round-Trip Time），从而提升性能。\n管道的工作原理：\n管道技术本质上是客户端提供的功能，而非 Redis 服务器端的功能。\n客户端：\n客户端将多个命令缓存到本地，而不是立即发送到服务器。 当缓存达到一定数量或显式调用EXEC时，客户端一次性将所有命令发送到服务器。 服务器：\n服务器按照接收到的顺序依次执行所有命令。 执行完成后，服务器将所有结果一次性返回给客户端。 结果返回：\n客户端接收到所有命令的执行结果，并按顺序处理。 管道的使用场景：\n批量写入：\n需要一次性写入大量数据时（如初始化缓存、批量插入数据），使用管道可以显著提高性能。 示例：批量设置多个键值对。 批量读取：\n需要一次性读取多个键的值时，使用管道可以减少网络延迟。 示例：批量获取多个用户的信息。 高并发场景：\n在高并发场景下，使用管道可以减少客户端与服务器之间的通信次数，降低系统负载。 事务优化：\n在事务（MULTI/EXEC）中，使用管道可以避免多次网络往返。 回答 # 管道技术是客户端提供的一种批处理技术，用于一次处理多个 Redis 命令，从而提高整个交互的性能。\nPipeline的本质，是将请求在客户端打包，然后一次发送给服务端，服务端处理完成之后，会将结果存起来，等Pipeline中所有命令都完成了，再一起回包，这样可以节约很多网络交互的时间。\n但使用管道技术也要注意避免发送的命令过大，或管道内的数据太多而导致的网络阻塞。\nRedis如何处理大key？ # 一般而言String 类型的值大于 10 KB；Hash、List、Set、ZSet 类型的元素的个数超过5000个；\n影响：\n客户端超时阻塞：由于 Redis 执行命令是单线程处理，然后在操作大 key 时会比较耗时。客户端认为很久没有响应。 引发网络阻塞：每次获取大 key 产生的网络流量较大。 阻塞工作线程：如果使用 del 删除大 key 时，会阻塞工作线程，这样就没办法处理后续的命令，一般要用unlink去异步删除 内存分布不均：集群模型在 slot 分片均匀情况下，会出现数据和查询倾斜情况，部分有大 key 的 Redis 节点占用内存多，QPS 也会比较小。 处理：\n当vaule是string时，比较难拆分，则使用序列化、压缩算法将key的大小控制在合理范围内，但是序列化和反序列化都会带来更多时间上的消耗。 当value是string，压缩之后仍然是大key，则需要进行拆分，一个大key分为不同的部分，记录每个部分的key，使用multiget等操作实现事务读取。 分拆成几个key-value，存储在一个hash中，每个field代表一个具体的属性，使用hget，hmget来获取部分的value，使用hset，hmset来更新部分属性 当value是list/set等集合类型时，根据预估的数据规模来进行分片，不同的元素计算后分到不同的片。 Redis支持事务回滚吗 # Redis不具备完整的ACID特性，执行的命令都没有回滚之说，无论是事务还是LUA脚本中的命令，都是一样的。\n回答 # 不支持，Redis 不具备完整的 ACID 特性，执行的命令都没有回滚之说，Redis 提供的 DISCARD 命令只能用来主动放弃事务执行，把暂存的命令队列清空，起不到回滚的效果。至于 LUA 脚本也是一样，比如LUA里面有 2 个写操作，执行了第一个如果 Redis 挂掉，那么第二个不会执行，第一个也不回撤回。\nRedis 如何实现延迟队列？ # 首先要知道什么是延迟队列：\n延迟队列是指把当前要做的事情，往后推迟一段时间再做：\n在淘宝、京东等购物平台上下单，超过一定时间未付款，订单会自动取消 打车的时候，在规定时间没有车主接单，平台会取消你的单并提醒你暂时没有车主接单 点外卖的时候，如果商家在10分钟还没接单，就会自动取消订单 知道延迟队列是什么之后，不难发现Redis中ZSet最适合模拟这个功能\n回答 # 使用ZSet，ZSet 有一个 Score 属性可以用来存储延迟执行的时间。使用 zadd score1 value1 命令，再利用 zrangebysocre 查询符合条件的所有待处理的任务，通过循环执行队列任务。\nRedis可以做消息队列吗？什么时候能用Redis做消息队列？ # 在某些场景，其实我们并不是一定需要有多可靠、多完善的消息队列，比如发用消息队列发短信，我们肯定也经常遇到过，短信没收到的场景吧？没收到重试就行了。\n所以，轻量级消息队列也有了市场需要，Redis就很适合来做一个不那么完善的消息队列。\n回答 # Redis可以作为轻量级消息队列。如果是本身业务轻量级，且团队没有已经接入完备的消息队列，这个时候没有必要引入一个重量消息队列，使用Redis即可满足要求，没有不能用的组件，只有不合适的场景。\n什么是分布式锁？ # 分布式锁是用于分布式环境下并发控制的一种机制，用于控制某个资源在同一时刻只能被一个应用所使用。如下图所示：\n回答 # 分布式锁是控制分布式系统之间同步访问共享资源的一种方式。在多线程或者多进程并发的情况下，使用锁来保证一个代码块在同一时间内只能由一个线程执行。\n如何理解Redis原子性操作原理？ # Redis 原子性操作的原理：\n单线程模型 保证单条命令原子性。 内置命令（INCR、HSET、LPUSH）本身不可分割。 MULTI / EXEC 确保事务性操作不会被中断（可以不提这个，很少后端真的用Redis事务，都是用LUA） Lua 脚本 执行过程中不可被其他命令插入，确保整体原子性。 这些特性让 Redis 在高并发场景下能够安全、高效地执行原子操作\n回答 # Redis提供的API都是单线程串行处理的，所以我们用单条对象操作命令都不用担心被中断，如果是多条命令要实现原子性，通常都是用LUA脚本来支持。\n分布式锁实现要点是什么 # redis分布式锁的加锁命令（一行命令实现互斥效果+过期时间，原子性）：\n// lock_key 就是 key 键； // unique_value 是客户端生成的唯一的标识，区分来自不同客户端的锁操作； // NX 代表只在 lock_key 不存在时，才对 lock_key 进行设置操作； // PX 10000 表示设置 lock_key 的过期时间为 10s，这是为了避免客户端发生异常而无法释放锁 SET lock_key unique_value NX PX 10000 要注意 setnx 这个命令，是没办法携带过期时间参数的！如果 setnx + expire 2 个命令，就没办法保证加锁的原子性了！所以要用 set 命令，携带 nx 和 px 参数，才能保证加锁的原子性！\n而解锁的过程就是将 lock_key 键删除（del lock_key），但不能乱删，要保证执行操作的客户端就是加锁的客户端。所以，解锁的时候，我们要先判断锁的 unique_value 是否为加锁客户端，是的话，才将 lock_key 键删除。\n可以看到，解锁是有两个操作，这时就需要 Lua 脚本来保证解锁的原子性，因为 Redis 在执行 Lua 脚本时，可以以原子性的方式执行，保证了锁释放操作的原子性。\n// 释放锁时，先比较 unique_value 是否相等，避免锁的误释放 if redis.call(\u0026#34;get\u0026#34;, KEYS[1]) == ARGV[1] then return redis.call(\u0026#34;del\u0026#34;, KEYS[1]) else return 0 end 这样一来，就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。\n回答的两种讲述思路：\n一种是直接说有几个要点，要设置owner，要设置ttl，释放时需要用lua。 另一种思路是直接说最重要的是加锁、解锁流程，其中加锁是怎样的命令，解锁是怎样的命令。 回答 # 分加锁解锁来说。\n加锁是用SET命令，然后带上NX参数，key是锁名字，value是持有者id，再加一个过期时间，这里value要用持有者id的原因是谁申请、谁释放的原则，会在解锁时进行检查，过期时间是为了兜底，防止异常情况下锁被永久占据。 解锁的话，主要涉及两步操作，一个是查看是不是自己的锁，如果是接着就是释放锁，为了保证原子性，解锁需要用LUA脚本进行。 为什么需要引入 owner 的概念 # 这个问题本质是问为什么分布式锁要这种对称性，对称性也就是说同一个锁，加锁和解锁必须是同一个竞争者。不能把其他竞争者持有的锁给释放了（超时自动释放除外）。\n回答没有对称性会怎样，举个场景说明。\n回答 # 分布式锁需要保证对称性，假设没有这种对称性，会有问题，举个例子，服务A获取了锁，由于业务流程比较长，或者网络延迟、GC卡顿等原因，导致锁过期，而业务还会继续进行。这时候，业务B已经拿到了锁，准备去执行，这个时候服务A恢复过来并做完了业务，就会释放锁，而B却还在继续执行，等B完成下次释放的可能又是别人的锁，这种情况是需要避免的。\n你提到了lua，用lua一定能保证原子性？ # 回答 # lua 本身不具备原子性，上面提到用 lua 来保证原子性是因为 Redis 是单线程执行，一个流程放进 lua 来执行，相当于是打包在一起，Redis 执行他的过程中不会被其他请求打断，所以说保证了原子性。\n这里我们也提到，我们是在释放的时候将查询key，删除key打包到一起，其中只有最后删除是写操作，所以这个流程本身是保证了原子性的。\n基于 Redis 实现分布式锁有什么优缺点？ # 基于 Redis 实现分布式锁的优点：\n性能高效（这是选择缓存实现分布式锁最核心的出发点）。 实现方便。很多研发工程师选择使用 Redis 来实现分布式锁，很大成分上是因为 Redis 提供了 setnx 方法，实现分布式锁很方便。 避免单点故障（因为 Redis 是跨集群部署的，自然就避免了单点故障）。 基于 Redis 实现分布式锁的缺点：\n超时时间不好设置：如果锁的超时时间设置过长，会影响性能，如果设置的超时时间过短会保护不到共享资源。比如在有些场景中，一个线程 A 获取到了锁之后，由于业务代码执行时间可能比较长，导致超过了锁的超时时间，自动失效，注意 A 线程没执行完，后续线程 B 又意外的持有了锁，意味着可以操作共享资源，那么两个线程之间的共享资源就没办法进行保护了。 那么如何合理设置超时时间呢？ 我们可以基于续约的方式设置超时时间：先给锁设置一个超时时间，然后启动一个守护线程，让守护线程在一段时间后，重新设置这个锁的超时时间。实现方式就是：写一个守护线程，然后去判断锁的情况，当锁快失效的时候，再次进行续约加锁，当主线程执行完成后，销毁续约锁即可，不过这种方式实现起来相对复杂。 **Redis 主从复制模式中的数据是异步复制的，这样导致分布式锁的不可靠性：**如果在 Redis 主节点获取到锁后，在没有同步到其他节点时，Redis 主节点宕机了，此时新的 Redis 主节点依然可以获取锁，所以多个应用服务就可以同时获取到锁。 回答 # Redis 实现分布式锁的主要优点在于其性能高效、实现简单和被业界成熟应用。但是也有其局限性，首先是 Redis 的可用性直接影响到锁的可靠性，如果 Redis 服务出现故障，可能会导致锁服务不可用，虽然 Redis 提供了持久化机制，但在极端情况下，如 Redis 突然崩溃，可能会导致锁信息的丢失，从而引发锁失效的问题。\n如何为Redis分布式锁设置合理的超时时间？ # 两个方面，核心是评估业务逻辑：\n正常情况下业务耗时：估算分布式锁保护的代码块或任务执行时间，包括数据库操作、外部API调用等。 **分布式锁访问耗时：**Redis操作通常很快，但在高延迟环境中，需考虑客户端与Redis服务器之间的通信时间。 考虑最坏情况：确定任务在最坏情况下的执行时间（如网络抖动、资源竞争等） 回答 # 核心是根据业务逻辑来评估，其次是网络延迟等交互消耗，比如业务最大执行时间是3s，网络延迟为200毫秒，多给1s缓冲，则超时时间可设置为4.2秒，向上取整为5秒。\n怎么用Redis实现可重入的分布式锁？ # 可重入锁即同一个线程或客户端可以多次获取同一把锁，核心目的是允许同一客户端多次获取锁，减少不必要的等待，提升效率，比较适用于嵌套调用或递归场景。\nRedis 实现可重入的分布式锁的实现思路如下：\n基本思路\n锁标识：使用 Redis 的 SET 命令设置一个键值对，键为锁的名称，值为持有锁的客户端标识（如 UUID）。 可重入性：维护一个计数器，记录锁的重入次数。 实现步骤\n**获取锁：**使用 SET 命令设置键值对，并设置过期时间，如果键已存在且值为当前客户端标识，则增加重入计数。\nif redis.call(\u0026#39;set\u0026#39;, lock_name, client_id, \u0026#39;NX\u0026#39;, \u0026#39;EX\u0026#39;, 30) then return 1 elseif redis.call(\u0026#39;get\u0026#39;, lock_name) == client_id then redis.call(\u0026#34;INCR\u0026#34;, \u0026#34;lock_name:count\u0026#34;) //比如lock_name为abc，这里计数器就是abc:count​ return 1 else return 0 end 释放锁：减少重入计数，如果计数为 0，则删除键。\nif redis.call(\u0026#34;GET\u0026#34;, lock_name) == client_id then local count = redis.call(\u0026#34;DECR\u0026#34;, \u0026#34;lock_name:count\u0026#34;) if count == 0 then redis.call(\u0026#34;DEL\u0026#34;, KEYS[1]) redis.call(\u0026#34;DEL\u0026#34;, \u0026#34;lock_name:count\u0026#34;) end return 1 else return 0 end 回答 # 可重入锁的核心是计数，即额外维护一个计数器，记录锁的重入次数，我们拿加锁流程举例，即先尝试Set命令（结合NX参数），如果不存在就Set成功，存在的话则查询是否是自己的id，是的话就增加计数，解锁也是一个思路，加锁解锁都需要用LUA保证原子性。\n","date":"2026年4月28日","externalUrl":null,"permalink":"/posts/interview/redis/04%E4%B8%A8redis-%E5%9C%BA%E6%99%AF/","section":"Posts","summary":"","title":"Redis 场景","type":"posts"},{"content":" 持久化介绍 # Redis 是跑在内存里的，当程序重启或者服务崩溃，数据就会丢失，如果业务场景希望重启之后数据还在，就需要持久化，即把数据保存到可永久保存的存储设备中。\nRedis提供两种方式来持久化：\nRDB（Redis Database Backup），记录 Redis 某个时刻的全部数据，这种方式本质就是数据快照，直接保存二进制数据到磁盘，后续通过加载RDB文件恢复数据。\nAOF（Append Only File），记录执行的每条命令，重启之后通过重放命令来恢复数据，AOF本质是记录操作日志，后续通过日志重放恢复数据。\nRDB是快照恢复，AOF是日志恢复，这是两者本质区别，我们甚至都不用去学习他们具体的实现，也能推测出他们如有下差别：\n体积方面：相同数据量下，RDB体积更小，因为RDB是记录的二进制紧凑型数据； 恢复速度：RDB是数据快照，可以直接加载，而AOF文件恢复，相当于重放情况，RDB显然会更快； 数据完整性：AOF记录了每条日志，RDB是间隔一段时间记录一次，用AOF恢复数据通常会更为完整。 RDB 详解 # 怎么开启RDB持久化 # 我们先从实践上入手，看一下怎么开启RDB持久化。\n首先，我们打开 redis 配置文件：/usr/local/etc/redis.conf\n在其中搜索可以发现如下配置：\nsave 900 1\rsave 300 10\rsave 60 10000 这里的配置语法是 save interval num，表示每间隔 interval 秒，至少有 num 条写数据操作，写数据操作指增加、删除及更新，就会激活 RDB 持久化。\n他们之间是并集关系，即只要满足其中一个条件，就达到了RDB持久化的条件，有同学也问到，这里触发的save命令，还是bgsave命令？这里显然是bgsave，不然如果用save，时不时阻塞一下怎么行。\n这三条配置不是我们增加，是默认就存在的，这就是说redis默认已经开启了RDB持久化。\nRDB 文件存哪里？ # 下面的参数决定了文件会存到哪里\n# The filename where to dump the DB\rdbfilename dump.rdb\r# The working directory.\rdir /Users/niuniumart/code/redis 什么时候进行持久化 # Redis持久化会在下面四种情况下进行：\n主动执行命令save\n127.0.0.1:6379\u0026gt; save\rOK Redis服务会有对应输出\n71359:M 12 Feb 2026 08:29:26.507 * DB saved on disk 执行了 save 命令，就会在主线程生成 RDB 文件，由于和执行操作命令在同一个线程，所以如果写入 RDB 文件的时间太长，会阻塞主线程，这个命令慎用。\n主动执行命令bgsave\n127.0.0.1:6379\u0026gt; bgsave\rBackground saving started Redis服务会有对应输出：\n71359:M 12 Feb 2026 08:29:33.412 * Background saving started by pid 71902\r71902:C 12 Feb 2026 08:29:33.414 * DB saved on disk\r71359:M 12 Feb 2026 08:29:33.456 * Background saving terminated with success 和save不同，会创建一个子进程来生成 RDB 文件，这样可以避免主线程的阻塞。\n达到持久化配置阈值\n上面有提到，Redis可以配置持久化策略，达到策略就会触发持久化，这里的持久化使用的方式是后台save，从这可以看到Redis比较推荐的方式也是后台执行持久化，尽可能减少对主流程影响。达到阈值之后，是由周期函数触发持久化。\n还会在程序正常关闭的时候执行\n在关闭时，Redis 会启动一次阻塞式持久化，以记录更全的数据\n54354:M 01 Jan 2026 09:17:26.527 # User requested shutdown...\r54354:M 01 Jan 2026 09:17:26.528 * Saving the final RDB snapshot before exiting.\r54354:M 01 Jan 2026 09:17:26.529 * DB saved on disk 所以正常关闭丢失的数据不会丢失，崩溃才会。\nRDB 具体做了什么 # 我们这里聚焦达到 RDB 持久化策略时，是如何进行持久化的，我们先看一下Redis 输出：\n62581:M 01 Jan 2026 09:04:26.279 * 1 changes in 3600 seconds. Saving...\r62581:M 01 Jan 2026 09:04:26.281 * Background saving started by pid 50162\r50162:C 01 Jan 2026 09:04:26.283 * DB saved on disk\r62581:M 01 Jan 2026 09:04:26.383 * Background saving terminated with success 从输出能看出，RDB 确实是通过子进程来进行的，具体做了什么呢？官网写得很清楚\nHow it works\rWhenever Redis needs to dump the dataset to disk, this is what happens:\r1.Redis forks. We now have a child and a parent process.\r2.The child starts to write the dataset to a temporary RDB file.\r3.When the child is done writing the new RDB file, it replaces the old one.\rThis method allows Redis to benefit from copy-on-write semantics. 从整体上，是做了以下事项：\nFork 出一个子进程来专门做 RDB 持久化\n子进程写数据到临时的 RDB 文件\n写完之后，用新 RDB 文件替换旧的RDB文件。\n整体流程如下：\n下面还有一句：This method allows Redis to benefit from copy-on-write semantics.\n就是说这种方式让Redis从写时复制技术受益，Redis官方文档基本没废话，这句话看似无关轻重，实际上说明了：执行 RDB持久化过程中，Redis 依然可以继续处理操作命令的，也就是数据是能被修改的，这就是通过写时复制技术实现的。\n具体而言：fork 创建子进程之后，通过写时复制技术，子进程和父进程是共享同一片内存数据的，因为创建子进程的时候，会复制父进程的页表，但是页表指向的物理内存还是一个。\n只有在发生修改内存数据的情况时，物理内存才会被复制一份。\n就是这样，Redis 使用 bgsave 对当前内存中的所有数据做快照，这个操作是由 bgsave 子进程在后台完成的，执行时不会阻塞父进程中的主线程，这就使得主线程同时可以修改数据。\n这样的目的是为了减少创建子进程时的性能损耗，从而加快创建子进程的速度，毕竟创建子进程的过程中，是可能阻塞主线程的。\n可以看到，复制期间，读数据互不影响，如果有写操作发生，则主进程复制一份内存，在这个复制的内存基础上，主进程再修改原来的数据，子进程持久化的依然是修改之前的数据。\nAOF 详解 # 怎么开启 AOF # 打开 redis 配置文件，例如 /usr/local/etc/redis.conf，\n在其中可以看到如下配置：\nappendonly no\r# The name of the append only file (default: \u0026#34;appendonly.aof\u0026#34;)\rappendfilename \u0026#34;appendonly.aof\u0026#34; appendonly 设置为yes，即可打开AOF，从这个现象来看，可以看到Redis官方认为RDB绝大多数时候都还是需要的，而AOF更依赖实际的业务场景。\nappendonly yes 打开之后，Redis每条更改数据的操作都会记录到AOF文件中，当你重启，AOF会助你重建状态，相当于就是请求全部重放一次，所以AOF恢复起来会比较慢。\nAOF写入流程 # 从上面的描述，我们可以看出，执行请求时，每条日志都会写入到AOF。\n这不免会让人担心，是否会影响Redis的执行性能，答案是肯定的，多了一步操作，或多或少都会带来些损耗，但是Redis实际是提供了不同的策略来选择不同程度的损耗。这里我们先从比较的宏观视角，介绍Redis提供的 3 种刷盘策略，以便根据需要进行不同的选择。\nappendfsync always，每次请求都刷入AOF，用官方的话说，非常慢，非常安全 appendfsync everysec，每秒刷一次盘，用官方的话来说就是足够快了，但是在崩溃场景下你可能会丢失1秒的数据。 appendfsync no，不主动刷盘，让操作系统自己刷，一般情况Linux会每30秒刷一次盘，这种策略下，可以说对性能的影响最小，但是如果发生崩溃，可能会丢失相对比较多的数据。 Redis 官方建议是方案二，也就是每秒刷一次盘，这种方式下速度也足够快了，同时崩溃时损失的数据只有1s，这在大多数场景都是可以接受的。\n当然了，我们要根据实际业务来选择，比如就是做简单的缓存，并且不存在什么超级热点缓存，那么丢失30秒也不是什么大事，这时候如果追求性能的机制，可以选择方案3。\n方案一说实话倒是很少有场景会使用，因为Redis本身是无法做到完全不丢数据，Redis的定位就不是完全可靠，通常也就没必要损耗大量性能去追求立刻刷盘。\n写入 AOF 细节 # 写入AOF，其实是分了好几步来的。\n第一步：其实是将数据写入 AOF 缓存中，这个缓存名字是 aof_buf，其实就是一个 sds 数据\nsds aof_buf; /* AOF buffer, written before entering the event loop */ 第二步：aof_buf 对应数据刷入磁盘缓冲区，什么时候做这个事情呢？事实上，Redis 源码中一共有4个时机，会调用一个叫 flushAppendOnlyFile 的函数，这个函数会使用 write 函数来将数据写入操作系统缓冲区：\n处理完事件处理后，等待下一次事件到来之前，也就是beforeSleep中。 周期函数serverCron中，这也是我们打过很多次交道的老朋友了 服务器退出之前的准备工作时 通过配置指令关闭AOF功能时 第三步：刷盘，即调用系统的 flush 函数，刷盘其实还是在flushAppendOnlyFile函数中，是在write之后，但是不一定调用了flushAppendOnlyFile，flush就一定会被调用，这里其实是支持一个刷盘时机的配置，这一步受刷盘策略影响是最深的，如下面代码所示，如果是appendfsync always策略，那么就立刻调用redis_fsync刷盘，如果是AOF_FSYNC_EVERYSEC策略，满足条件后会用aof_background_fsync使用后台线程异步刷盘。\n/* Perform the fsync if needed. */ if (server.aof_fsync == AOF_FSYNC_ALWAYS) { /* redis_fsync is defined as fdatasync() for Linux in order to avoid * flushing metadata. */ latencyStartMonitor(latency); redis_fsync(server.aof_fd); /* Let\u0026#39;s try to get this data on the disk */ latencyEndMonitor(latency); latencyAddSampleIfNeeded(\u0026#34;aof-fsync-always\u0026#34;,latency); server.aof_last_fsync = server.unixtime; } else if ((server.aof_fsync == AOF_FSYNC_EVERYSEC \u0026amp;\u0026amp; server.unixtime \u0026gt; server.aof_last_fsync)) { if (!sync_in_progress) aof_background_fsync(server.aof_fd); server.aof_last_fsync = server.unixtime; } 讲到这里，我们应该可以很清晰画出一个相对宏观的完整写入示意图：\n下面，我们来看一个AOF写入例子，以进一步掌握AOF。\nAOF 例子 # 执行如下命令：\n127.0.0.1:6379\u0026gt; set city sz\rOK\r127.0.0.1:6379\u0026gt; hset mart a b\r(integer) 1\r127.0.0.1:6379\u0026gt; hset mart c d\r(integer) 1 对应会生成了如下的AOF文件：\n打开 aof 文件可以看到：\ncommands are logged using the same format as the Redis protocol itself. aof 记录的方式，和 Redis 协议本身是一样的，可以看到这个协议可读性还是比较强的。\n*3\r$3\rset\r$4\rcity\r$2\rsz\r*4\r$4\rhset\r$4\rmart\r$1\ra\r$1\rb\r*4\r$4\rhset\r$4\rmart\r$1\rc\r$1\rd AOF 重写 # AOF是不断写入的，这很容易带来一个疑问，如此下去AOF不是会不断膨胀吗？\n针对这个问题，Redis采用了重写的方式来解决：\nRedis可以在AOF文件体积变得过大时，自动地在后台Fork一个子进程，专门对AOF进行重写。说白了，就是针对相同Key的操作，进行合并，比如同一个Key的set操作，那就是后面覆盖前面。\n注意这里的合并是逻辑上的合并，实际操作是从数据库读取数据，生成一份新的AOF文件。\n在重写过程中，Redis不但将新的操作记录在原有的AOF缓冲区，而且还会记录在AOF重写缓冲区。一旦新AOF文件创建完毕，Redis 就会将重写缓冲区内容，追加到新的AOF文件，再用新AOF文件替换原来的AOF文件。\nAOF缓冲区不可以替代AOF重写缓冲区的原因是AOF重写缓冲区记录的是从重写开始后的所有需要重写的命令，而AOF缓冲区可能只记录了部分的命令。\n这里可能会问，AOF达到多大会重写，实际上，这也是配置决定，默认如下，同时满足这两个条件则重写。\n# 相比上次重写时候数据增长100%\rauto-aof-rewrite-percentage 100\r# 超过\rauto-aof-rewrite-min-size 64mb 也就是说，超过64M的情况下，相比上次重写时的数据大一倍，则触发重写，很明显，最后实际上还是在周期函数来检查和触发的。\nRDB 和 AOF 的本质区别是什么？ # 如果是讲区别可以从 文件类型，文件恢复速度，安全性 来进行回答，但本质区别就是 RDB 是使用快照进行持久化，AOF是日志。\n其他可以列举的区别点 都是能从 快照 与 日志 中的对比找出的\n文件类型：RDB生成的是 二进制文件（快照），AOF生成的是 文本文件（追加日志） 安全性：缓存宕机时，RDB 容易丢失较多的数据，AOF 根据策略决定（默认的everysec 可以保证最多有一秒的丢失） 文件恢复速度：由于RDB是二进制文件，所以恢复速度也比 AOF更快 操作的开销：每一次RDB保存都是一次全量保存，操作比较重，通常设置至少5分钟保存一次数据。而AOF的刷盘是一次追加操作，操作比较轻，通常设置策略为每一秒进行一次刷盘 回答 # 本质区别就是 RDB 是保存快照进行持久化，而 AOF 则是 追加日志文件进行持久化。因为这个本质区别，所以还会有文件恢复速度、安全性、操作开销等区别，需要展开讲吗？（这里其实摸不准面试官想法，所以可以询问一下）\n如果RDB和AOF只能选一种，你选哪个？ # 两个方向分析：\n性能和可靠 之间做一个选择\n分析Redis为什么默认打开的是RDB，以及在只开一个情况，实际是更推荐RDB的。\n回答 # 如果从业务需要来看，如果我们能接受分钟级别的数据丢失，可以考虑选择RDB，如果需要尽量保证数据安全，可以考虑混合持久化，如果只用AOF，那么优先选择everysec策略进行刷盘（在可靠和性能之间有一个平衡）。\n从持久化理念来看，始终开启快照是一个推荐的方式，这也是Redis官方为什么默认开启RDB，而不开启AOF，同时官网也明确不推荐只开AOF\n介绍一下 AOF 的三种写回策略 # 回答 # Always、Everysec 和 No，这三种策略在可靠性上是从高到低，而在性能上从低到高。\nAlways是每次写操作命令执行完后，同步将 AOF 日志数据写回硬盘；Everysec每次写操作命令执行完后，先将命令写入到 AOF 文件的内核缓冲区，然后每隔一秒将缓冲区里的内容写回到硬盘；No就是不控制写回硬盘的时机。每次写操作命令执行完后，先将命令写入到 AOF 文件的内核缓冲区，再由操作系统决定何时将缓冲区内容写回硬盘。\n为什么先执行 Redis 命令，再把数据写入 AOF 日志呢？ # 好处：\n保证正确写入：如果当前的命令语法有问题，错误的命令记录到 AOF 日志里后可能还会进行语法检查。先执行Redis命令，再把数据写入AOF日志可以保证写入的都是正确可执行的命令。 不阻塞当前写操作：因为当写操作命令执行成功后才会将命令记录到AOF日志，避免写入阻塞。 缺陷：\n数据可能会丢失： 执行写操作命令和记录日志是两个过程，Redis还没来得及将命令写入到硬盘时发生宕机，数据会有丢失的风险。 阻塞其他操作： 不会阻塞当前命令的执行，但因为 AOF 日志也是在主线程中执行，所以当 Redis 把日志文件写入磁盘的时候，还是会阻塞后续的操作无法执行。 回答 # 有2点好处。\n保证正确写入：如果当前的命令语法有问题，错误的命令记录到 AOF 日志里后可能还会进行语法检查。先执行Redis命令，再把数据写入AOF日志可以保证写入的都是正确可执行的命令。 不阻塞当前写操作：因为当写操作命令执行成功后才会将命令记录到AOF日志，避免写入阻塞。 AOF子进程的内存数据跟主进程的内存数据不一致怎么办？ # Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的，这有两个好处：\n子进程进行 AOF 重写期间，主进程可以继续处理命令请求，从而避免阻塞主进程； 子进程带有主进程的数据副本，使用子进程而不是线程，因为如果是使用线程，多线程之间会共享内存，那么在修改共享内存数据的时候，需要通过加锁来保证数据的安全，而这样就会降低性能。创建子进程时，父子进程是共享内存数据的，不过这个共享的内存只能以只读的方式，而当父子进程任意一方修改了该共享内存，会发生写时复制，于是父子进程就有了独立的数据副本，不用加锁来保证数据安全。 AOF 子进程产生的时刻，数据和主进程是一致的，这里面试官是想问重写过程中主进程数据增加了，如何保持最后结果一致，回答的关键在 AOF 重写缓冲区。\n回答 # Redis设置了一个 AOF 重写缓冲区，这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。在重写 AOF 期间，当 Redis 执行完一个写命令之后，它会同时将这个写命令写入到AOF 缓冲区和AOF 重写缓冲区。当子进程完成 AOF 重写工作后，会向主进程发送一条信号。主进程收到该信号后，会调用一个信号处理函数，将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中，使得新旧两个 AOF 文件所保存的数据库状态一致；新的 AOF 的文件进行改名，覆盖现有的 AOF 文件。\nRDB 在执行快照的时候，数据能修改吗？ # 推荐学习官网对RDB行为的说明\nHow it works\rWhenever Redis needs to dump the dataset to disk, this is what happens:\r1.Redis forks. We now have a child and a parent process.\r2.The child starts to write the dataset to a temporary RDB file.\r3.When the child is done writing the new RDB file, it replaces the old one.\rThis method allows Redis to benefit from copy-on-write semantics. 注意最后一句话：\nThis method allows Redis to benefit from copy-on-write semantics.\n就是说这种方式让Redis从写时复制技术受益，Redis官方文档基本没废话，这句话看似无关轻重，实际上说明了：执行 RDB持久化过程中，Redis 依然可以继续处理操作命令的，也就是数据是能被修改的，这就是通过写时复制技术实现的。\n回答 # 可以。执行 bgsave 过程中，Redis 依然可以继续处理操作命令的，数据是能被修改的，采用的是写时复制技术（Copy-On-Write, COW）。执行 bgsave 命令的时候，会通过 fork() 创建子进程，此时子进程和父进程是共享同一片内存数据的，因为创建子进程的时候，会复制父进程的页表，但是页表指向的物理内存还是一个，由于共享父进程的所有数据，可以直接读取主线程里的内存数据，并将数据写入到 RDB 文件。此时如果主线程执行读操作，则主线程和 bgsave 子进程互相不影响。如果主线程要修改共享数据里的某一块数据，就会发生写时复制，数据的物理内存就会被复制一份，主线程在这个数据副本进行修改操作。与此同时，子进程可以继续把原来的数据写入到 RDB 文件。\nRedis用RDB持久化时对过期键会如何处理的？ # 在 Redis 使用 RDB（Redis Database Backup）持久化 时，过期键 的处理方式取决于 RDB 生成和恢复的阶段，具体情况如下：\nRedis 在 生成 RDB 快照时，不会 直接删除过期的键，而是检查每个 key 的 TTL，如果某个 key 已经过期，则 不会写入 RDB 文件；如果 key 未过期，则会 连同其 TTL 一起写入 RDB。这样，生成的 RDB 文件中 不会包含已过期的键，避免存储无用数据。 当 Redis 重启并加载 RDB 文件 时：Redis 会 正常加载所有 key 及其 TTL，但不会立即清理所有过期 key，而是由数据清理机制来保证（在客户端访问 key 时，Redis 发现 key 已过期，则立即删除。Redis 运行时的定期清理机制 可能也会被触发，主动删除过期 key） 回答 # RDB 分为生成阶段和加载阶段，生成阶段会对 key 进行过期检查，过期的 key 不会保存到RDB文件中；加载阶段在载入 RDB 文件时，Redis 会正常加载所有 key 及其 TTL，而过期 Key 的删除，是由专门的数据清理机制来保证，和RDB无关。\nRedis用AOF持久化时对过期键会如何处理的？ # 总结为下表：\n阶段 AOF 处理方式 AOF 追加日志 记录 EXPIRE 命令，过期后不自动删除，只有触发惰性删除或定期清理时才写入 DEL AOF 重写 过滤掉已过期的 key，生成更精简的 AOF 文件 AOF 恢复 加载 AOF 时仍然恢复所有 key，过期 key 需要等待惰性删除或定期清理 回答 # 恢复时会恢复所有 过期key，等待惰性删除或定期清理，写入时会记录EXPIRE命令，当此过期键被删除后，Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值。重写阶段会对 Redis 中的键值对进行检查，已过期的键不会被保存到重写后的 AOF 文件中。\nAOF模式下，Redis主从模式中，对过期键会如何处理？ # 主库会记录一条 del 指令到 AOF 文件，从库也会同步这条指令\n回答 # 从库不会进行过期扫描，从库的过期键处理依靠主服务器控制，主库在 key 到期时，会在 AOF 文件里增加一条 del 指令，同步到所有的从库，从库通过执行这条 del 指令来删除过期的 key。\n如果主从同步发生意外，原本主库的 key 过期了，但是 del 指令没有同步给从库成功，导致从库内存中存在已经过期但没有删除的 key，这时候有客户端访问从库时，即使 key 还是内存的，但是从库发现 key 是过期的，就不会返回 key 的数据给客户端了。\nRDB持久化的触发时机？ # 主要有这么几个地方，一个是调用 save 或者 bgsave 命令，一个是根据我们配置周期进行，一个是Redis关闭之前，这三个是比较常见的，其它边缘一点的还有主从全量复制发送RDB文件等。（如果追问可以再说还有客户端执行数据库清空命令FLUSHALL）\nAOF混合持久化方案是什么？ # 需要记住的时，AOF 混合持久化，就是在 AOF 重写的基础上 做了一些改动\n回答 # AOF混合持久化 会使用RDB持久化函数 将内存数据写入到新的AOF文件中 （数据格式也是RDB格式） 而重写期间 新的写入命令 追加到 新的AOF文件 仍然是AOF格式 此时新的AOF文件 就是 由 RDB格式 和 AOF格式组成的 日志文件 简单描述AOF重写流程 # AOF重写就三个关键点：\n子进程读取Redis DB中的数据以字符串命令的格式（也可以看作AOF文件格式）写入到新AOF文件中； 如果有新数据，由主进程将数据写入到aof重写缓冲区（aof_rewrite_buf） 当子进程完成重写操作后，主进程通过管道将aof重写缓冲区中的数据传输给子进程，然后子进程追加到新aof文件中 回答 # 当 aof 重写触发那一刻，主进程就会 fork 出一个子进程，然后这个子进程读取 Redis DB 中的数据，以字符串命令的格式写入到新 AOF 文件中\n如果这个时候 Redis 接收到了新的写入命令，那么主进程会将这些\u0026quot;增量数据\u0026quot;写入到AOF重写缓冲区中\n在子进程将数据都写入到新AOF文件后，主进程会通过管道将AOF重写缓冲区里面的数据发送给子进程，子进程再将这一份数据追加到新AOF文件中，保证新AOF文件的完整性。\nAOF重写你觉得有什么不足之处么？ # 重写期间 新的写命令，会将数据写入到两处地方（AOF缓冲和AOF重写缓冲）中，这是额外的CPU和内存开销；\n重写时会将AOF 缓冲 和 AOF重写缓冲 分别 写入到 旧日志 和 新日志中，这是额外的磁盘开销\n除了清楚 AOF的不足之处，如果还知道改进方案，那么会令面试官刮目相看，Redis 7.0就对此做了新的改进。\n回答 # 我认为主要有3点不足之处：\n额外的 CPU 开销： 在重写时，主进程需要将新的写入数据 写入到 AOF重写缓冲(aof_rewrite_buf) 主进程 需要通过管道向子进程发送AOF重写缓冲的数据 子进程还需要将这些数据 写入到新的AOF日志中 额外的内存开销：在重写时，AOF缓冲 和 AOF重写缓冲 中的数据都是一样的（浪费了一份） 额外的磁盘开销：在重写时，AOF缓冲需要刷入 旧的AOF日志，AOF重写缓冲也需要刷入 到新的AOF日志，导致在重写时磁盘多占一份数据 Redis在7.0版本也做了对应的优化，我可以讲一下吗？\n针对AOF重写的不足，你有什么优化思路呢？ # 改进之处：\n在Redis 7.0版本，对AOF重写作出了优化，提出了 MP-AOF 方案，原来的AOF重写缓冲被移除，AOF日志也分成了 Base AOF日志 ，Incr AOF日志\nMP-AOF: Multi Part AOF = one BASE AOF + many INCR AOFs\nBase AOF日志 记录重写之前的命令；Incr AOF 日志记录 重写时 新的写入命令（正常AOF刷盘的时候写的是Incr AOF）\n当重写发生时，主进程fork出一个 子进程，对Base AOF日志 进行重写（将当前内存数据写入到新的Base AOF日志）；如果此时有新的写入命令，会由主进程 写入到aof_buf，再将缓冲数据刷入 新的Incr AOF日志。这样 新的Incr AOF日志 + 新的Base AOF日志 就构成了完整的新的AOF日志\n子进程重写 结束时，主进程会负责更新 manifest 文件，将新生成的 BASE AOF 和 INCR AOF 信息加进清单，并将之前的 BASE AOF 和 INCR AOF 标记为 HISTORY。\nmianfest 用于追踪管理AOF文件\n这些 HISTORY 文件默认会被 Redis 异步删除（unlink），一旦 manifest 文件更新完成，就代表着整个 AOFRW 流程结束\n回答 # 其实在 Redis 7.0 版本，就使用 MP-AOF 方案对 AOF 重写做了优化，核心其实就是去掉原来的重写缓冲，同时将AOF日志拆分为 Base AOF日志 ，Incr AOF日志，由manifest来管理。重写时，还是开一个子进程，对Base AOF日志 进行重写，但是新命令会往 新的Incr AOF日志写，Incr AOF日志 + 新的Base AOF日志 就构成了完整的新的AOF日志。\n","date":"2026年4月28日","externalUrl":null,"permalink":"/posts/interview/redis/03%E4%B8%A8redis-%E6%8C%81%E4%B9%85%E5%8C%96/","section":"Posts","summary":"","title":"Redis 持久化","type":"posts"},{"content":" String # String 是最基本的 key-value 结构，key 是唯一标识，value 是具体的值，value 其实不仅是字符串， 也可以是数字（整数或浮点数），value 最多可以容纳的数据长度是 512M。\nString 底层数据结构 # String 类型的底层的数据结构实现主要是 int 和 SDS（简单动态字符串）。\nSDS 和我们认识的 C 字符串不太一样，之所以没有使用 C 语言的字符串表示，因为 SDS 相比于 C 的原生字符串：\nSDS 不仅可以保存文本数据，还可以保存二进制数据。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束，并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据，而且能保存图片、音频、视频、压缩文件这样的二进制数据。 SDS 获取字符串长度的时间复杂度是 O(1)。因为 C 语言的字符串并不记录自身长度，所以获取长度的复杂度为 O(n)；而 SDS 结构里用 len 属性记录了字符串长度，所以复杂度为 O(1)。 Redis 的 SDS API 是安全的，拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求，如果空间不够会自动扩容，所以不会导致缓冲区溢出的问题。 为何需要重新封装一个数据结构呢，而不是 C 底层的字符串表示？\nC 语言的字符串有如下问题：\n每次计算字符串长度的复杂度为O(N)； 对字符串进行追加，需要重新分配内存； 非二进制安全。 在Redis内部，字符串的追加和长度计算很常见，这两个简单的操作不应该成为性能的瓶颈，于是 Redis 封装了一个叫 SDS 的字符串结构，用来解决上述问题。下面我们来看看它是怎样的结构。\n首先，Redis 中 SDS 分为 sdshdr8、sdshdr16、sdshdr32、sdshdr64，它们的字段属性都是一样，区别在于应对不同大小的字符串，我们以 sdshdr8 为例：\n// from Redis 7.0.8 struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; len：当前字符串的真实长度； alloc：SDS 总共分配了多少字节； flags：标志位，低 3 位用来记录当前 SDS 的类型，即当前使用的是 sdshdr5、sdshdr8 还是 sdshdr16 等。高 5 位 保留未使用； buf：柔性数组，用来保存实际的字符串内容或二进制数据;在结构体定义中，柔性数组本身不占用大小，即 sizeof(struct sdshdr8) 的结果是 3 字节，但在实际分配内存时，Redis 会一次性分配一块连续的内存 [头部的3字节] + [alloc指定的容量] + [1字节的'\\0']。还是有末尾 \\0 的，用来兼容 C 语言标准库。 String 内部编码方式 # String看起来简单，但实际有三种编码方式，如下图所示：\nINT 编码：这个很好理解，就是存一个整型，可以用 long 表示的整数就以这种编码存储; EMBSTR 编码：如果字符串小于等于阈值字节，使用EMBSTR编码； RAW 编码：字符串大于阈值字节，则用RAW编码。 EMBSTR 和 RAW 都是由 redisObject 和 SDS 两个结构组成，它们的差异在于，EMBSTR 下 redisObject 和 SDS 是连续的内存，RAW 编码下 redisObject 和 SDS 的内存是分开的。\nEMBSTR 优点是 redisObject 和 SDS 两个结构可以一次性分配空间，缺点在于如果重新分配空间，整体都需要再分配，所以 EMBSTR 设计为只读，任何写操作之后 EMBSTR 都会变成 RAW，理念是发生过修改的字符串通常会认为是易变的。\n**EMBSTR内存： **\nRAW内存：\n编码格式的转换：\n随着我们的操作，编码可能会转换：\nINT -\u0026gt; RAW：当存的内容不再是整数，或者大小超过了long的时候；\nEMBSTR-\u0026gt;RAW：任何写操作之后 EMBSTR 都会变成 RAW，原因前面有解释。\n整体结构如下：\n面试 # Set一个已有的数据会发生什么？ # 回答：\n对于 SET 命令，Redis 通常不会去复用旧对象的内存，而是采取“创建新对象，丢弃旧对象”的策略。具体操作：\n创建新对象：Redis 会根据你传入的新 value 的大小，重新评估并创建一个全新的 redisObject 和 SDS（根据大小决定是 int、embstr 还是 raw 编码）。 替换字典指针：Redis 的顶层结构是一个巨大的 Hash 表（字典）。Redis 会找到这个 key 对应的哈希槽（dictEntry），将其内部指向 value 的指针，修改为指向刚刚新建的 redisObject。 释放旧对象（引用计数法）：Redis 内部使用引用计数机制来管理内存。新指针指向建立后，旧的 redisObject 的引用计数会被减 1（decrRefCount 函数）。当引用计数降为 0 时，旧的 redisObject 以及它绑定的旧 SDS 会被系统的内存分配器（如 jemalloc）回收。 浮点型在String是用什么表示？ # 回答：\n要将一个浮点数放入字符串对象里面，需要先将这个浮点数转换成字符串值，然后再保存转换所得的字符串值，比如浮点数3.14，对应就变成了\u0026quot;3.14\u0026quot;这个字符串。\n所以浮点数在字符串对象里面是用字符串值表示的。\nString可以有多大？ # 一个 Redis 字符串最大为512MB，但是可以根据 proto-max-bulk-len 参数进行调整。\nRedis 字符串是怎么实现的？ # Redis 字符串底层是 String 对象，String 对象有三种编码方式：INT 型、EMBSTR 型、RAW 型。如果是存一个整型，可以用 long 表示的整数就以 INT 编码存储；如果存字符串，当字符串长度小于等于一个阈值，使用 EMBSTR 编码；字符串大于阈值，则用 RAW 编码。\nSDS相比与C原生字符串有何区别？ # SDS包含已使用容量字段，O(1)时间快速返回有字符串长度，相比之下，C原生字符串需要O(n)。 有预留空间，在扩容时如果预留空间足够，就不用再重新分配内存，节约性能，缩容时也可以将减少的空间先保留下来，后续可以再使用。 不再以‘\\0’作为字符串结束判断标准，二进制安全，可以很方便地存储一些二进制数据，但还是保留了 ‘\\0’，用来兼容原生 C 标准库。 Redis 的 String 有哪些应用场景？ # 缓存对象：使用 String 来缓存对象可以直接使用整个对象作为 JSON 数据，或者将 Key 进行分离，采用 MSET 进行存储。\n常规计数：因为 Redis 处理命令是单线程，所以执行命令的过程是原子的。因此 String 数据类型适合计数场景，比如计算访问次数、点赞、转发、库存数量等等。\n分布式锁：SET 命令有个 NX 参数可以实现「key不存在才插入」，可以用它来实现分布式锁：\n如果 key 不存在，则显示插入成功，可以用来表示加锁成功； 如果 key 存在，则会显示插入失败，可以用来表示加锁失败。 加锁只有一步，但是解锁有两步，先判断锁的 unique_value 是否为加锁客户端，是的话，才将 lock_key 键删除。【需要 Lua 脚本来保证解锁的原子性】 共享 Session 信息：分布式系统使用同一个 Redis 存储 Session 流程图：\nList # List 列表是简单的字符串列表，按照插入顺序排序，可以从头部或尾部向 List 列表添加元素。\n列表的最大长度为 $2^{32} - 1$，也即每个列表支持超过 40 亿个元素。\nList 底层实现 # 第一阶段 # List 对象有两种编码方式，一种 ZIPLIST，另一种是 LINKEDLIST。\n当满足如下条件时，用 ZIPLIST 编码：\n列表对象保存的所有字符串对象长度都小于 64 字节； 列表对象元素个数少于512个，注意，这是 LIST 的限制，而不是 ZIPLIST 的限制； ZIPLIST底层用压缩列表实现，这里我们假设列表中包含\u0026quot;hello\u0026quot;、\u0026ldquo;niuniu\u0026rdquo;、 \u0026ldquo;mart\u0026quot;三个元素，ZIPLIST编码示意如下：\n可以看到，\u0026ldquo;hello\u0026rdquo;、“niuniu\u0026rdquo;、\u0026ldquo;mart\u0026rdquo; 都挨在一起，正如其名字一样，ZIPLIST 内存排列得很紧凑，可以有效节约内存空间。\n如果不满足ZIPLIST编码的条件，则使用LINKEDLIST编码。为了便于描述，我们还是假设列表中包含\u0026quot;hello\u0026quot;、\u0026ldquo;niuniu\u0026rdquo;、\u0026ldquo;mart\u0026quot;三个元素，如果用LINKEDLIST编码，则是几个String对象的链接结构，结构示意图如下：\n可以看到，\u0026ldquo;hello\u0026rdquo;、\u0026ldquo;niuniu\u0026rdquo;、\u0026ldquo;mart\u0026rdquo; 这几个数据是以链表的形式连接在一起，实际上删除更为灵活，但是内存不如ZIPLIST紧凑，所以只有在列表个数或节点数据长度比较大的时候，才会使用LINKEDLIST编码，以加快处理性能，一定程度上牺牲了内存。\n第二阶段 # ZIPLIST是为了在数据较少时节约内存，LINKEDLIST是为了数据多时提高更新效率，ZIPLIST数据稍多时插入数据会导致很多内存复制。但如果节点非常多的情况，LINKEDLIST链表的节点就很多，会占用不少的内存。\n第二阶段就引入了 QUICKLIST。QUICKLIST 其实就是 ZIPLIST 和 LINKEDLIST 的结合体。\nLINKEDLIST原来是单个节点，只能存一个数据，现在单个节点存的是一个ZIPLIST，即多个数据。\n这种方案其实是用 ZIPLIST、LINKEDLIST 综合的结构，取代二者本身。\n当数据较少的时候，QUICKLIST 的节点就只有一个，此时其实相当于就是一个 ZIPLIST；\n当数据很多的时候，则同时利用了 ZIPLIST 和 LINKEDLIST 的优势。\nZIPLIST 整体架构 # Redis代码注释中，非常清晰描述了ZIPLIST的结构：\n* The general layout of the ziplist is as follows: * * \u0026lt;zlbytes\u0026gt; \u0026lt;zltail\u0026gt; \u0026lt;zllen\u0026gt; \u0026lt;entry\u0026gt; \u0026lt;entry\u0026gt; .. \u0026lt;entry\u0026gt; \u0026lt;zlend\u0026gt; 比如这就是有3个节点的 ziplist 结构：\nzlbytes：表示该 ZIPLIST 一共占了多少字节数，这个数字是包含 zlbytes 本身占据的字节的。 zltail：ZIPLIST 尾巴节点相对于 ZIPLIST 的开头（起始指针），偏移的字节数。通过这个字段可以快速定位到尾部节点，例如现在有一个 ZIPLIST，zl 指向它的开头，如果要获取 tail 尾巴节点，即 ZIPLIST 里的最后一个节点，可以 zl + zltail的值，这样定位到它。如果没有尾节点，就定位到 zlend zllen：表示有多少个数据节点，在本例中就有3个节点。 entry1~entry3：表示压缩列表数据节点。 zlend：一个特殊的entry节点，表示ZIPLIST的结束。 ZIPLIST ENTRIES 定义\n\u0026lt;prevlen\u0026gt; \u0026lt;encoding\u0026gt; \u0026lt;entry-data\u0026gt; prevlen：表示上一个节点的数据长度。通过这个字段可以定位上一个节点的起始地址（或者说开头）也就是就是 p-prevlen 可以跳到前一个节点的开头位置，实现从后往前操作，所以压缩列表才可以从后往前遍历。 如果前一节点的长度，也就是前一个 ENTRY 的大小，小于254字节， 那么prevlen属性需要用1字节长的空间来保存这个长度值，255是特殊字符，被 zlend 使用了 如果前一节点的长度大于等于254字节，那么prevlen属性需要用5字节长的空间来保存这个长度值，注意5个字节中的第一个字节为11111110，也就是254，标志这是个5字节的prevlen信息，剩下4字节来表示大小。 encoding：编码类型。编码类型里还包含了一个entry的长度信息，可用于正向遍历 entry-data：实际的数据。 ZIPLIST更新数据\nZIPLIST提供头尾增减的能力，但是操作平均时间复杂度是O(N)，因为在头部增加一个节点会导致后面节点都往后移动，而尾部插入时间复杂度是O(1)，所以更新的平均时间复杂度，可以看作O(N)。\n其中要注意的是更新操作可能带来连锁更新。注意上面所说的增加节点导致后移，不是连锁更新。连锁更新是指这个后移，发生了不止一次，而是多次。\n比如增加一个头部新节点，后面依赖它的节点，需要 prevlen 字段记录它的大小，原本只用 1 字节记录，因为更新可能膨胀为 5 字节，然后这个entry的大小就也膨胀了。所以，当这个新数据插入导致的后移完成之后，还需要逐步迭代更新。这种现象就是连锁更新，时间复杂度是O(N^2)\n第三阶段 # 连锁更新原因分析\nLISTPACK 是为了解决 ZIPLIST 最大的痛点——连锁更新，我们先来看，ZIPLIST 的问题本源。\n我们知道，ZIPLIST 需要支持 LIST，LIST 是一种双端访问结构，所以需要能从后往前遍历，上面有讲，ZIPLIST 的数据节点的结构是这样的：\n\u0026lt;prevlen\u0026gt; \u0026lt;encoding\u0026gt; \u0026lt;entry-data\u0026gt; 其中，prevlen 就表示上一个节点的数据长度，通过这个字段可以定位上一个节点的数据，可以说，连锁更新问题，就是因为 prevlen 导致的。\n对症下药\n我们需要一种不记录prevlen，并且还能找到上一个节点的起始位置的办法，Redis使用了很巧妙的一种方式。\n我们直接看LISTPACK的节点定义：\n\u0026lt;encoding-type\u0026gt;\u0026lt;element-data\u0026gt;\u0026lt;element-tot-len\u0026gt; encoding-type：编码类型； element-data：数据内容； element-tot-len：存储整个节点除它自身之外的长度。 找到上一个节点的秘密就藏在 element-tot-len：\nelement-tot-len 所占用的每个字节的第一个 bit 用于标识是否结束。0是结束，1是继续，剩下7个bit来存储数据大小。当我们需要找到当前元素的上一个元素时，我们可以从后向前依次查找每个字节，找到上一个Entry的 element-tot-len 字段的结束标识，就可以算出上一个节点的首位置了。\n如果上个节点的element-tot-len为 00000001 10000100，每个字节第一个bit标志是否结束，所以这里的element-tot-len一共就两个字节，大小为0000001 0000100，即132字节。\n面试 # ZIPLIST有什么优点 # 回答：\n相比于LINKEDLIST链表式设计，压缩列表的内存都是紧凑排列在一起的，这就带来了几个优点，1.节约内存，2.方便一次性分配，3.遍历时局部性更强\nZIPLIST是怎么压缩数据的？ # 回答：\n压缩列表是一块连续的内存空间，元素之间是紧挨着存储的，一个压缩列表中可以包含多个节点（entry），是在连续的内存空间上实现的双端链表，\nZIPLIST下List可以从后往前遍历吗？ # 回答：\nZIPLIST 每个节点中都保存了上一个节点的长度，所以可以用当前节点地址减去上一个节点长度来找到上个节点起始位置，进而实现从后往前的遍历。\nZIPLIST下List如何从前往后遍历吗？ # 回答：\n其中 prevlen 是找上一个节点的起始位置，entry-data 是实际的内容，显然，答案就在 encoding 里，encoding 里无论是 String 还是 Int 都是可以知道长度信息的。\n在ZIPLIST数据结构下，查询节点个数的时间复杂度是多少？ # 回答：\n由于ZIPLIST的header中定义了记录节点数量的字段zllen，所以通常是可以在O(1)时间复杂度直接返回，为什么说通常呢？是因为zllen是2个字节的，当zllen大于65535时，zllen就存不下了，所以真实的节点数量需要遍历来得到。\n连锁更新是什么问题？ # 回答：\nZIPLIST每个节点会有个字段记录上一个节点的长度，如果上个节点小于254字节，这个记录字段则是1字节，否则是5。由于更新某个节点，会导致长度变化，如果从小于254变得大于等于254了，则会影响下个节点的长度，依次递推，就像多米诺骨牌一样，这种情况就是连锁更新。\n如何解决连锁更新问题？ # 回答：\n核心思路就是不去记录上一个节点的长度，而是记录自身长度，使用 LISTPACK 的第三部分 element-tot-len(backlen) 来做特殊化处理(记录 encoding 和 data 的长度)，以便遍历到前一个节点。\nSet # Redis 的 Set 是一个不重复、无序的字符串集合，这里额外说明一下，如果是 INTSET 编码的时候其实是有序的，不过一般不应该依赖这个，整体还是看成无序来用比较好。\nSet 编码方式 # Redis 出于性能和内存的综合考虑，也支持两种编码方式，如果集合元素都是整数，且元素数量不超过512个，就可以用 INTSET 编码，结构如下图，可以看到INTSET排列比较紧凑，内存占用少，但是查询时需要二分查找。\ntypedef struct intset { uint32_t encoding; //编码格式 uint32_t length; //元素数量 int8_t contents[]; //保存元素的数组 } intset; 如果不满足 INTSET 的条件，就需要用 HASHTABLE，HASHTABLE 结构如下图，可以看到HASHTABLE查询一个元素的性能很高，能O(1)时间就能找到一个元素是否存在。\nINTSET 对应少量整数集合下节约内存，HASHTABLE 适用于需要快速定位某个元素的场景。\n面试 # Set是有序的吗？ # 回答：\nSet 的底层实现是整数集合或字典，前者是有序的，后者是无序的。整体来看，建议不依赖 SET 的顺序。\nSet编码方式是什么？ # 回答：\nSet使用整数集合和字典作为底层编码，当元素都是整数同时元素个数不超过512个，会使用整数集合编码，否则使用字典编码。\nSet为什么要用两种编码方式? # 回答：\nSet的底层编码是整数集合和字典，当元素数量小并且全部是整数的时候，会使用整数集合编码，更加的节约内存。元素数量变大会使用字典编码，查找元素的速度会更快。\nHash # Redis Hash是一个field、value都为 string 的hash表，存储在 Redis 的内存中。\nRedis中每个hash可以存储$2^{32}-1$键值对（40多亿）。\nHash 底层编码 # Hash底层有两种编码结构，一个是压缩列表，一个是 HASHTABLE。同时满足以下两个条件，用压缩列表：\nHash对象保存的所有值和键的长度都小于64字节；\nHash对象元素个数少于512个。\n两个条件任何一条不满足，编码结构就用HASHTABLE。\nZIPLIST 之前有讲解过，其实就是在数据量较小时将数据紧凑排列，对应到 Hash，就是将 filed-value当作 entry 放入ZIPLIST，结构如下：\nHASHTABLE 在之前无序集合 Set 中也有应用，和 Set 的区别在于，在 Set 中 value 始终为 NULL，但是在 Hash 中，是有对应的值的。\nHASHTABLE # hash 是 Redis 中的一种数据类型，而 hashtable(哈希表) 是类型的具体底层实现\nHASHTABLE 结构 # Redis 中 HASHTABLE 的结构如下：\n// from Redis 5.0.5 /* This is our hash table structure. */ typedef struct dictht { dictEntry **table; unsigned long size; unsigned long sizemask; unsigned long used; } dictht; 最外层是一个封装的 dictht 结构，其中字段含义如下：\ntable：指向实际hash存储。存储可以看做一个数组，所以是 *table 的表示，在C语言中 *table 可以表示一个数组。 size：哈希表大小。实际就是dictEntry有多少元素空间。 sizemask: 哈希表大小的掩码表示，总是等于 size-1。这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面，规则 Index=hash\u0026amp;sizemask。 used：表示已有节点数量。通过这个字段可以很方便地查询到目前 HASHTABLE 元素总量。 dictEntry 结构如下：\ntypedef struct dictEntry { /* void * 类型的 key，可以指向任意类型的键 */ void *key; /* 联合体 v 中包含了指向实际值的指针 *val、无符号的 64 位整数、有符号的 64 位整数，以及 double 双精度浮点数。 * 这是一种节省内存的方式，因为当值为整数或者双精度浮点数时，由于它们本身就是 64 位的，void *val 指针也是占用 64 位（64 操作系统下）， * 所以它们可以直接存在键值对的结构体中，避免再使用一个指针，从而节省内存开销（8 个字节） * 当然也可以是 void *，存储任何类型的数据，最早 redis1.0 版本就只是 void* */​ union { void *val; uint64_t u64; int64_t s64; double d; } v; struct dictEntry *next; /* Next entry in the same hash bucket. */ /* 同一个 hash 桶中的下一个条目. * 通过形成一个链表解决桶内的哈希冲突. */ void *metadata[]; /* An arbitrary number of bytes (starting at a * pointer-aligned address) of size as returned * by dictType\u0026#39;s dictEntryMetadataBytes(). */ /* 一块任意长度的数据 (按 void* 的大小对齐), * 具体长度由 \u0026#39;dictType\u0026#39; 中的 * dictEntryMetadataBytes() 返回. */ } dictEntry; Hash 表渐进式扩容实现 # 其实为了实现渐进式扩容，Redis 中没有直接把 dictht 暴露给上层，而是再封装了一层：\ntypedef struct dict { dictType *type; void *privdata; dictht ht[2]; long rehashidx; /* rehashing not in progress if rehashidx == -1 */ unsigned long iterators; /* number of iterators currently running */ } dict; 可以看到dict结构里面，包含了2个dictht结构，也就是2个HASHTABLE结构。dictEntry是链表结构，也就是用拉链法解决Hash冲突，用的是头插法\n实际上平常使用的就是一个HASHTABLE，在触发扩容之后，就会两个HASHTABLE同时使用，详细过程是这样的：\n当向字典添加元素时，发现需要扩容就会进行Rehash。Rehash的流程如下：\n第一步：准备阶段（分配空间与状态切换）\n为字典的新 Hash 表 ht[1] 分配空间。 扩容大小规则：新表大小为第一个大于等于原表 ht[0].used * 2 的 $2^n$（2 的 n 次方幂）。 举例：原表如果 used=500，2 倍就是 1000，那第一个大于 1000 的 2 次方幂则为 1024。ht[1] 的容量就是 1024。 状态变更：此时字典同时持有 ht[0] 和 ht[1] 两个哈希表。字典的偏移索引 rehashidx 从静默状态 -1 设置为 0，标志着 Rehash 工作正式开始。 第二步：渐进迁移阶段（分摊工作量）\n事件驱动迁移：在 Rehash 进行期间，每次对字典执行增、删、改、查操作时，程序除了执行指定的操作外，还会顺带将 ht[0] 在 rehashidx 索引上的所有键值对迁移到 ht[1] 上。迁移完成后，rehashidx 的值加 1。 定时任务兜底：除了请求驱动，Redis 的定时周期函数（serverCron）也会在后台利用空闲时间进行部分迁移，防止冷数据（迟迟没有被访问的键）一直滞留在原表中。 当你对字典执行增删改查时，触发了单步迁移（n=1）： 如果遇到空桶，它会往下找。 但最多只允许连续找 10 次空桶。 如果连找 10 个全是空的，它会直接放弃本次任务并返回，把剩下的活儿留给下一次字典操作，或者交给后台的 serverCron 定时任务去大批量扫尾。 第三步：Rehash 期间的读写规则（🌟 重点）\n因为此时字典同时存在两个 Hash 表，所以数据的操作需要兼顾两边：\n查 (Find) / 删 (Delete) / 改 (Update)：程序会先在 ht[0] 中查找目标，如果没找到，会继续去 ht[1] 中查找，找到后再执行相应操作。 增 (Add)：所有的插入操作一律直接写入 ht[1]。这保证了 ht[0] 里的键值对数量只会“只减不增”，最终一定会变成空表，不会出现永远迁移不完的情况。 第四步：完成阶段（指针更替与收尾）\n随着字典操作的不断执行，最终在某个时间点，ht[0] 的所有键值对都会被全部迁移至 ht[1]。 每次迁移完一个 bucket 的元素，底层的 Rehash 函数都会检查是否完成了整个迁移。 一旦确认完成：释放 ht[0]，将 ht[1] 的指针赋值给 ht[0]（新表上位），并在 ht[1] 新创建一个空白哈希表，为下一次 Rehash 做准备。 最后，将 rehashidx 重新置为 -1，表示本次 Rehash 圆满结束。 扩容时机 # Redis提出了一个负载因子的概念，负载因子表示目前 Redis HASHTABLE的负载情况，是游刃有余，还是不堪重负了。我们设负载因子为 k，那么 k=ht[0].used/ht[0].size，也就是使用空间和总空间大小的比例。\nRedis会根据负载因子的情况来扩容：\n没有执行 BGSAVE 或 BGREWRITEAOF（后台持久化）时，负载因子 $\\ge 1$ 触发扩容。 正在执行 BGSAVE 或 BGREWRITEAOF 时，为了避免写时复制 (COW) 造成过多内存消耗，触发阈值会提高，负载因子 $\\ge 5$ 时才触发扩容。 缩容时机 # 当负载因子小于0.1，即负载率小于10%，此时进行缩容，新表大小为第一个大于等于原表used的2次方幂。当然，如果有BGSAVE或BGREWRITEAOF这两个复制命令，缩容也会受影响，不会进行。\n面试 # HASHTABLE查找元素总数的平均时间复杂度是多少? # dictht 的结构如下：\ntypedef struct dictht { dictEntry **table; unsigned long size; unsigned long sizemark; // 键值对数量 unsigned long used; } dictht; 回答：\nHASHTABLE 查找元素总数的平均时间复杂度是O(1)，因为HASHTABLE的表头结构中有储存键值对数量的字段used。\nHASHTABLE怎么扩容? # 回答：\n首先程序会为HASHTABLE的1号表分配空间，空间大小是第一个大于等于used 两倍的最近二次幂。在rehash进行期间，标记位rehashidx从0开始，每次对字典的键值对执行增删改查操作后，都会将rehashidx位置的数据迁移到1号表，然后将rehashidx加1，随着字典操作的不断执行，最终0号表的所有键值对都会被rehash到1号表上。之后，1号表会被设置成0号表，接着在1号表的位置创建一个新的空白表。\n什么时候扩容，什么时候缩容 # 回答：\n扩容：\n当以下两个条件中的任意一个被满足时，哈希表会自动开始扩容：【k=ht[0].used/ht[0].size】\n服务器目前没有在执行BGSAVE或者BGREWRITEAOF，并且负载因子 \u0026gt;= 1 服务器目前正在执行BGSAVE或者BGREWRITEAOF，并且负载因子 \u0026gt;= 5 缩容：\n当哈希表的负载因子小于0.1时，程序会自动开始对哈希表进行收缩操作。\n跳表 # 为了提升查找的性能，Redis 就引入了跳表，跳表在链表的基础上，给链表增加了多级的索引，通过索引可以一次实现多个节点的跳跃，提高性能。\n跳表的结构 # 下面我们来看看跳表他具体的结构是怎样的，我们先看这个示意图：\n可以看到，这个图某些节点不光只有一层，如果只用普通链表，只能一步一步往后走，如果用这种有高层的节点，那是不是可以一次多走几步，理论上，层次越高平均步长越大，但并不完全像示意图一样是绝对均衡的，节点的层高其实是概率随机的，\n标准的跳表（Redis不是用的标准的跳表，下面会讲）有如下限制：\nscore 值不能重复；\n只有向前指针，没有回退指针。\n在 Redis 中，跳表是用来支持有序集合的，所以 Redis 对跳表做了一些优化，就包括 score 可以重复、增加回退指针，下面我们来看看 Redis 的跳表。\nRedis 跳表 # 我们直接看这个示意图，score可以重复并且我们的每个节点多了一下回退指针。\n下面我们结合源码，来和示意图对照学习，以加深理解，这是Redis 跳表单个节点的定义：\ntypedef struct zskiplistNode { sds ele; double score; struct zskiplistNode *backward; struct zskiplistLevel { struct zskiplistNode *forward; unsigned long span; } level[]; } zskiplistNode; 我们来看看字段的含义：\nele：很熟悉的SDS结构，用来存储数据。 score：节点的分数，浮点型数据 backward：指向上一个节点的回退指针，支持从表尾向表头遍历，也就是ZREVRANGE这个命令 level：是个 zskiplistLevel 结构体数组，zskiplistLevel 这个结构体包含了两个字段，一个是forward，指向该层下个能跳到的节点，span记录了距离下个节点的步数，数组结构就表示每个节点都可能是个多层结构。 面试 # Redis跳表单个节点有几层？ # 回答：\n层次的决定，需要比较随机，才能在各个场景表现出较为平均的性能，这里Redis使用概率均衡的思路来确定新插入节点的层数：\nRedis 跳表决定每一个节点，是否能增加一层的概率为25%，而最大层数限制在Redis 5.0是64层，在Redis 7.0是32层。\nRedis跳表的性能优化了多少 # 回答：\n这个其实可以很容易想到，跳表的查找过程，其实是走高层，行得通跳过去，行不通走相对下层，很像二叉树的另一种表现形式，实际上他们性能也是差不多的，平均时间复杂度都是O(logn)，区别是二叉树最坏情况下也是O(logn)比较稳定，而跳表的最坏时间复杂度是O(N)。当然，实际的生产过程中，体现出来的基本都是跳表的平均时间复杂度。\n跳表是什么，和普通的链表有什么区别？ # 回答：\n跳表也算链表，不过相对普通链表，增加了多级索引，通过索引可以实现O(logN)的元素查找效率。\n聊聊跳表的查找过程 # 回答：\n从高级索引往后查找，如果下个节点的数值比目标节点小，则继续找，否则不跳过去，而是用下级索引往下找。\n跳表中一个节点的层高是怎么决定的？ # Redis 采用的是一种基于幂次定律（Power Law）的随机算法。具体流程如下：\n初始层高：每个新插入的节点，默认至少有 1 层。 随机晋升：程序会生成一个随机数。如果这个随机数满足特定的晋升概率 $p$（Redis 中这个常量是 0.25），那么该节点的层高就增加 1 层。 循环判断：只要随机数持续满足概率 $p$，层高就会继续不断 +1，直到随机数不再满足条件，或者达到了 Redis 设定的最大层数限制（Redis 5.0 之后最大层数是 64，早期版本是 32）。 ZSet # ZSet 底层编码有两种，一种是 ZIPLIST，一种是 SKIPLIST+HASHTABLE。\nZIPLIST我们可以说很熟悉了，之前List、Hash中都有打过交道，同样在，在ZSet中ZIPLIST也是用于数据量比较小时候的内存节省，结构如下：\n如果满足如下规则，ZSet就用ZIPLIST编码：\n列表对象保存的所有字符串对象长度都小于64字节； 列表对象元素个数少于128个。 两个条件任何一条不满足，编码机构就用 SKIPLIST + HASHTABLE，其中 SKIPLIST 也是底层数据结构。\nSKIPLIST是一种可以快速查找的多级链表结构，通过SKIPLIST可以快速定位到数据所在。它的排名操作、范围查询性能都很高。\n除了SKIPLIST，Redis还使用了HASHTABLE来配合查询，这样可以在O(1)时间复杂度查到成员的分数值。\n注意，同时维护 SkipList 和 HashTable 不会造成两倍内存浪费，Redis 在底层 C 语言源码的实现中，精妙地共享了数据。HashTable 和 SkipList 的节点中，关于 Member（字符串对象）和 Score（双精度浮点数）的值，都是通过指针指向同一块内存地址的。\ntypedef struct zset { dict *dict; // HashTable，用于 O(1) 查找 member 对应的 score zskiplist *zsl; // SkipList，用于 O(log N) 的范围查找和排序 } zset; 面试 # ZSet 插入一条数据的时间复杂度？ # 回答：\n跳跃表是一种支持多级索引的结构，查询效率可以媲美二分查找，它插入一条数据也是需要先查找，找到之后会进行索引的重建，整体平均时间复杂度是O(logN)。\nZSet 为何使用跳表，相比于其他平衡树有何优点？ # 回答：\n相比于 B+ 树：\nB+树的数据都存在叶子节点中，而且它是多叉树，相同数据量下多叉树的层高比二叉树低，这两个特点使得它适合磁盘存储。而 Redis 是一个内存数据库，B+树层高低的优势荡然无存，所以选择了实现更加简单的跳表。 跳表的插入性能更高，虽然两者的插入平均时间复杂度相当，但是跳表插入数据后只需要修改前进和后退指针即可，而B+树还需要维护树的平衡，细节上有额外开销。 相比于红黑树：\nZSet 最常用的操作之一就是提取某个范围的元素，红黑树要先进行范围查询，找到起点后，需要进行中序遍历。在树形结构中进行跨节点的中序遍历，逻辑相对复杂，且由于内存地址不连续，对 CPU 缓存（Cache Line）不够友好。 Redis 的底层把跳表的最底层节点通过双向链表连接了起来。找到范围查询的起点后，只需要顺着最底层的链表向后（或向前）遍历即可，就像遍历数组一样简单高效。 足够简单，这是最核心的。 ","date":"2026年4月28日","externalUrl":null,"permalink":"/posts/interview/redis/01%E4%B8%A8%E5%BA%95%E5%B1%82%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/","section":"Posts","summary":"","title":"Redis 底层数据结构","type":"posts"},{"content":" 缓存穿透是什么？怎么解决？ # 当用户访问的数据，既不在缓存中，也不在数据库中，导致请求在访问缓存时，发现缓存缺失，再去访问数据库时，发现数据库中也没有要访问的数据，没办法构建缓存数据，来服务后续的请求。那么当有大量这样的请求到来时，数据库的压力骤增，这就是缓存穿透的问题。\n最常见的场景就是有攻击者伪造了大量的请求，请求某个不存在的数据。这会造成两个后果。\n缓存里面没有对应的数据，所以查询会落到数据库上。 数据库也没有数据，所以没有办法回写缓存，下一次请求同样的数据，请求还是会落到数据库上。 解决手段：\n**回写特殊值：**在缓存未命中，而且数据库里也没有的情况下，往缓存里写入一个特殊的值。这个值就是标记数据不存在，那么下一次查询请求过来的时候，看到这个特殊值，就知道没有必要再去数据库里查询了。但如果如果攻击者每次都用不同的且都不存在的 key 来请求数据，那么\u0026quot;回写特殊值\u0026quot;这种手段会丧失它的效果，而且因为要回写特殊值，会浪费不少 Redis 的内存。这可能会进一步引起另外一个问题，就是 Redis 在内存不足，执行淘汰的时候，把其他有用的数据淘汰掉，而更好的就是考虑使用布隆过滤器 使用布隆过滤器：布隆过滤器是一种快速判断元素是否存在的数据结构，它可以在很小的内存占用下，快速判断一个元素是否在一个集合中。将所有可能存在的数据哈希到一个足够大的位数组中，当一个请求过来时，可以先通过查询布隆过滤器快速判断数据是否存在，如果不存在，则直接返回，避免对数据库的查询。 限流策略：针对频繁请求的特定数据，可以设置限流策略，例如使用令牌桶算法或漏桶算法，限制对这些数据的请求频率，减轻数据库的压力。 回答 # 缓存穿透是指查询一个不存在的数据，由于缓存未命中，请求直接穿透到数据库，导致数据库压力骤增。这种现象通常由恶意攻击或错误查询引发。\n有很多解决策略，比如：\n布隆过滤器：在缓存层前设置布隆过滤器，快速判断数据是否存在，避免无效查询。 缓存空对象（回写特殊值）：即使查询结果为空，也往缓存里写入一个特殊的值，这个值就是标记数据不存在，设置较短过期时间，防止重复查询冲击数据库。 限流策略：针对频繁请求的特定数据，可以设置限流策略 通过这些措施，可以有效缓解缓存穿透问题，保护数据库不受过度查询的压力。\n布隆过滤器是怎么工作的？ # 布隆过滤器由初始值都为0的位图数组和N个哈希函数两部分组成。在写入数据库数据时，在布隆过滤器里做个标记，这样下次查询数据是否在数据库时，只需要查询布隆过滤器，如果查询到数据没有被标记，说明不在数据库中。\n第一步，使用 N 个哈希函数分别对数据做哈希计算，得到 N 个哈希值 第二步，将第一步得到的 N 个哈希值对位图数组的长度取模，得到每个哈希值在位图数组的对应位置 第三步，将每个哈希值在位图数组的对应位置的值设置为 1 举个例子，假设有一个位图数组长度为 8，哈希函数 3 个的布隆过滤器。\n在数据库写入数据 x 后，把数据 x 标记在布隆过滤器时，数据 x 会被 3 个哈希函数分别计算出 3 个哈希值，然后在对这 3 个哈希值对 8 取模，假设取模的结果为 1、4、6，然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时，通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1，只要有一个为 0，就认为数据 x 不在数据库中。\n布隆过滤器由于是基于哈希函数实现查找的，高效查找的同时存在哈希冲突的可能性，比如数据 x 和数据 y 可能都落在第 1、4、6 位置，而事实上，可能数据库中并不存在数据 y，存在误判的情况。\n所以，查询布隆过滤器说数据存在，并不一定证明数据库中存在这个数据，但是查询到数据不存在，数据库中一定就不存在这个数据。\n布隆过滤器有什么缺陷？ # 布隆过滤器特性：\n高效存储：相比哈希表，布隆过滤器可以用很小的空间存储大规模数据。\n无误判删除：无法删除元素（除非使用变体，如计数布隆过滤器）。\n存在误判率：可能误判元素存在，但不会误判不存在。\n无法获取原始数据：只能用于查询，不能存储数据本身。\n其中布隆过滤器最重要的缺陷就在于无法确定一个数据是一定存在，只能判断数据一定不存在，所以适用场景也受这个缺陷（特性）的制约，所以这个缺陷（特性）一定要提到，其它的比如无法删除可以酌情扩展。\n回答 # 布隆过滤器由于是基于哈希函数实现查找的，会存在哈希冲突的可能性，数据可能落在相同位置，存在误判的情况。查询布隆过滤器说数据存在，并不一定证明数据库中存在这个数据，但是查询到数据不存在，数据库中一定就不存在这个数据。\n不支持一个关键字的删除，因为一个关键字的删除会牵连其他的关键字。改进方法就是counting Bloom filter，用一个counter数组代替位数组，就可以支持删除了。\n缓存击穿是什么？怎么解决？ # 如果缓存中的某个热点数据过期了，此时大量的请求访问了该热点数据，就无法从缓存中读取，直接访问数据库，数据库很容易就被高并发的请求冲垮。\n缓存击穿的关注点是热点数据不存在于缓存上，因为如果是普通数据，那就是正常的缓存未命中，反之热点数据不在缓存中了，容易导致大量请求落到数据库上\n解决手段：\n设置热点数据的热度时间窗口：对于热点数据，可以设置一个热度时间窗口，在这个时间窗口内，如果一个数据被频繁访问，就将其缓存时间延长，避免频繁刷新缓存导致缓存击穿。 使用互斥锁或分布式锁：在缓存失效时，只允许一个线程去查询数据库，其他线程等待查询结果。可以使用互斥锁或分布式锁来实现，确保只有一个线程能够查询数据库，其他线程等待结果，避免多个线程同时查询数据库造成数据库压力过大。 缓存永不过期：对于一些热点数据，可以将其缓存设置为永不过期，避免缓存击穿。 异步更新缓存：在缓存失效时，可以异步地去更新缓存，而不是同步地去查询数据库并刷新缓存。这样可以减少对数据库的直接访问，并且不会阻塞其他请求的响应。 回答 # 如果缓存中的某个热点数据过期了，此时大量的请求访问了该热点数据，就无法从缓存中读取，直接访问数据库，数据库很容易就被高并发的请求冲垮。\n我们可以通过延长热点数据过期时间、使用互斥锁或分布式锁控制查询频率、缓存永不过期等手段来解决这个问题。\n缓存雪崩是什么？怎么解决？ # 场景1：缓存集中失效。比如为了双十一，将一批商品放入到缓存中，设置两小时过期，凌晨2点过期了，导致对这一批商品的访问都落到了数据库，数据库就会产生压力波峰。 场景2：当然缓存雪崩一个最严重特殊情况是，在流量高峰，一个缓存节点直接出现问题，甚至扩大到缓存集群出现问题，那么就会导致本应该访问缓存的流量透传到数据库上。 解决手段：\n大量数据同时过期的解决手段：\n设置缓存数据的随机过期时间：在设置缓存数据的过期时间时，加上一个随机值，使得不同的缓存数据在过期时刻不一致。这样可以避免大量数据同时过期，减轻数据库负荷。 分布式锁或互斥锁：在缓存失效时，使用分布式锁或互斥锁来保证只有一个请求可以重新加载缓存。其他请求等待该请求完成后，直接从缓存中获取数据。这样可以避免多个请求同时访问数据库。 数据预热：在系统启动或者非高峰期，提前将热点数据加载到缓存中，预热缓存。这样即使在高并发时，也能够从缓存中获取到数据，减轻数据库的压力。 后台更新缓存：业务线程不再负责更新缓存，缓存也不设置有效期，而是让缓存“永久有效”，并将更新缓存的工作交由后台线程定时更新。 数据库优化：对于缓存雪崩问题，除了缓存层面的应对策略，还可以从数据库层面进行优化，如提升数据库性能、增加数据库的容量等，以应对大量请求导致的数据库压力。 Redis故障宕机的解决手段：\n服务熔断或请求限流机制：启动服务熔断机制，暂停业务应用对缓存服务的访问，直接返回错误，所以不用再继续访问数据库，保证数据库系统的正常运行，等到 Redis 恢复正常后，再允许业务应用访问缓存服务。服务熔断机制是保护数据库的正常允许，但是暂停了业务应用访问缓存服系统，全部业务都无法正常工作。也可以启用请求限流机制，只将少部分请求发送到数据库进行处理，再多的请求就在入口直接拒绝服务。 提高缓存本身的可用性：通过主从节点的方式构建 Redis 缓存高可靠集群，如果 Redis 缓存的主节点故障宕机，从节点可以切换成为主节点，继续提供缓存服务，避免了由于 Redis 故障宕机而导致的缓存雪崩问题。 回答 # 缓存雪崩是指缓存中大量数据同时过期，导致所有请求直接访问数据库，造成数据库压力激增甚至崩溃。解决方法包括：\n设置缓存过期时间的随机值，避免同时过期； 使用分布式锁或队列控制数据库访问，防止瞬间高并发； 实现缓存高可用，如主从复制或集群部署，确保缓存服务不中断； 热点数据永不过期，定时异步更新缓存； 提前预加载缓存，确保数据在过期前已更新。通过这些措施，可以有效缓解缓存雪崩问题。 ","date":"2026年4月28日","externalUrl":null,"permalink":"/posts/interview/redis/05%E4%B8%A8redis-%E7%BC%93%E5%AD%98%E5%BC%82%E5%B8%B8/","section":"Posts","summary":"","title":"Redis 缓存异常","type":"posts"},{"content":" Redis 集群架构模式有哪几种？ # Redis 提供了三种集群模式：主从架构、哨兵集群、切片集群。\n主从复制\n主从复制是 Redis 高可用服务的最基础的保证，实现方案就是将从前的一台 Redis 服务器，同步数据到多台从 Redis 服务器上，即一主多从的模式，且主从服务器之间采用的是「读写分离」的方式。\n主服务器可以进行读写操作，当发生写操作时自动将写操作同步给从服务器，而从服务器一般是只读，并接受主服务器同步过来写操作命令，然后执行这条命令。\n也就是说，所有的数据修改只在主服务器上进行，然后将最新的数据同步给从服务器，这样就使得主从服务器的数据是一致的。\n注意，主从服务器之间的命令复制是异步进行的。\n具体来说，在主从服务器命令传播阶段，主服务器收到新的写命令后，会发送给从服务器。但是，主服务器并不会等到从服务器实际执行完命令后，再把结果返回给客户端，而是主服务器自己在本地执行完命令后，就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令，主从服务器间的数据就不一致了。\n所以，无法实现强一致性保证（主从数据时时刻刻保持一致），数据不一致是难以避免的。\n哨兵集群\n在使用 Redis 主从服务的时候，会有一个问题，就是当 Redis 的主从服务器出现故障宕机时，需要手动进行恢复。\n为了解决这个问题，Redis 增加了哨兵模式（Redis Sentinel），因为哨兵模式做到了可以监控主从服务器，并且提供主从节点故障转移的功能。\n切片集群\n当 Redis 缓存数据量大到一台服务器无法缓存时，就需要使用 Redis 切片集群（Redis Cluster ）方案，它将数据分布在不同的服务器上，以此来降低系统对单主节点的依赖，从而提高 Redis 服务的读写性能。\nRedis Cluster 方案采用哈希槽（Hash Slot），来处理数据和节点之间的映射关系。在 Redis Cluster 方案中，一个切片集群共有 16384 个哈希槽，这些哈希槽类似于数据分区，每个键值对都会根据它的 key，被映射到一个哈希槽中，具体执行过程分为两大步：\n根据键值对的 key，按照 CRC16 算法计算一个 16 bit 的值。 再用 16bit 值对 16384 取模，得到 0~16383 范围内的模数，每个模数代表一个相应编号的哈希槽。 接下来的问题就是，这些哈希槽怎么被映射到具体的 Redis 节点上的呢？有两种方案：\n平均分配： 在使用 cluster create 命令创建 Redis 集群时，Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点，则每个节点上槽的个数为 16384/9 个。 手动分配： 可以使用 cluster meet 命令手动建立节点间的连接，组成集群，再使用 cluster addslots 命令，指定每个节点上的哈希槽个数。 为了方便你的理解，我通过一张图来解释数据、哈希槽，以及节点三者的映射分布关系。\n上图中的切片集群一共有 2 个节点，假设有 4 个哈希槽（Slot 0～Slot 3）时，我们就可以通过命令手动分配哈希槽，比如节点 1 保存哈希槽 0 和 1，节点 2 保存哈希槽 2 和 3。\nredis-cli -h 192.168.1.10 –p 6379 cluster addslots 0,1\rredis-cli -h 192.168.1.11 –p 6379 cluster addslots 2,3 然后在集群运行的过程中，key1 和 key2 计算完 CRC16 值后，对哈希槽总个数 4 进行取模，再根据各自的模数结果，就可以被映射到哈希槽 1（对应节点1） 和 哈希槽 2（对应节点2）。\n需要注意的是，在手动分配哈希槽时，需要把 16384 个槽都分配完，否则 Redis 集群无法正常工作。\n回答 # Redis 提供了三种集群模式：主从架构、哨兵集群、切片集群。\n主从：选择一台作为主服务器，将数据同步多台从服务器上，构建一主多从的模式，主从之间读写分离。主服务器可读可写，发生写操作会同步给从服务器，而从服务器一般是只读，并接受主服务器同步过来写操作命令。主从服务器之间的命令复制是异步进行的，所以无法实现强一致性保证（主从数据时时刻刻保持一致）。 哨兵：当 Redis 的主从服务器出现故障宕机时，需要手动进行恢复，为了解决这个问题，Redis 增加了哨兵模式，哨兵监控主从服务器，并且提供主从节点故障转移的功能。 切片集群：当数据量大到一台服务器无法承载，需要使用Redis切片集群(Redis Cluster)方案，它将数据分布在不同的服务器上，以此来降低系统对单主节点的依赖，提高 Redis 服务的读写性能。 Redis主从复制过程是怎样的？ # Redis 集群支持的主从复制，数据同步主要有两种方法：一种是全量同步，一种是增量同步。\n全量同步\n刚开始搭建主从模式时，从机需要从主机上获取所有数据，这时就需要 Slave 将 Master 上所有的数据进行同步复制。复制的步骤为：\n从服务器发送 SYNC 命令，链接主服务器； 主服务器收到 SYNC 命令后，进行存盘的操作，并继续收集后续的写命令，存储缓冲区； 存盘结束后，将对应的数据文件发送到 Slave 中，完成一次全量同步； 主服务数据发送完毕后，将进行增量的缓冲区数据同步； Slave 加载数据文件和缓冲区数据，开始接受命令请求，提供操作。 增量同步\n从节点完成了全量同步后，就可以正式的开启增量备份。当 Master 节点有写操作时，都会自动同步到 Slave 节点上。Master 节点每执行一个命令，都会同步向 Slave 服务器发送相同的写命令，当从服务器接收到命令，会同步执行。\n回答 # 当从节点初次连接到主节点，或者掉线重连后进度落后较多时会进行一次全量数据同步。此时，主节点会生成 RDB 快照并传输给从节点，在此期间，主节点接受到的增量命令将会先写入 replication_buffer 缓冲区，等到从节点加载完 RDB 快照的数据后，再将缓冲区的命令传输给从节点，以此完成初次同步。\n当从节点掉线重连后，如果进度落后的不多，将会进行增量同步。主节点内部维护了一个环形的固定大小的 repl_backlog_buffer 缓冲区，它用于记录最近传播的命令。其中，主节点和从节点会分别在该缓冲区维护一个 offset ，用于表示自己的写进度和读进度。当从节点掉线重连后，将会检查主节点和从节点 offset 之差是否小于缓冲区大小，如果确实小于，说明从节点同步进度落后不多，则主节点将该缓冲区中的两 offset 之间的增量命令发送给从节点，完成增量同步。\n当主从节点完成初次同步后，将会建立长连接进行命令传播。简单的来说，就是每当主节点执行一条命令，它就会写入 replication_buffer 缓冲区，随后再将缓冲区的命令通过节点间的长连接发送给对应的从节点。\nRedis 的主从复制模式有什么优缺点？ # 回答 # 主从复制的模式相对于单节点的好处在于，实行读写分离提高了系统的读写效率，提高了网站数据的读取加载速度。但是缺点是由于写数据主要在主节点上操作，主节点内存空间有限，并且主节点存在单点风险。\n哨兵机制是什么？ # 回答 # 因为在主从架构中读写是分离的，如果主节点挂了，将没有主节点来响应客户端的写操作请求，也无法进行数据同步。哨兵作用是实现主从节点故障转移。哨兵会监测主节点是否存活，如果发现主节点挂了，会选举一个从节点切换为主节点，并且把新主节点的相关信息通知给从节点和客户端。\n哨兵机制的工作原理？ # 回答 # 判断节点是否存活\n哨兵会周期性给所有主节点发送PING命令来判断其他节点是否正常运行。如果PING命令响应失败哨兵会将节点标记为主观下线，然后该哨兵会向其他节点发出投票命令，当票数达到设定的阈值之后这个主节点就被标记为客观下线。然后哨兵会从从节点中选择一个作为主节点。\n投票\n哨兵集群中会选择一个leader来负责主从切换，选举是一个投票过程：判断主节点为客观下线的是候选者，候选者向其他哨兵发送命令表示要成为leader，其他哨兵会进行投票，每个哨兵只有一票，可以投给自己或投给别人，但是只有候选者才能把票投给自己。候选者之后拿到半数以上的赞成票并且票数大于设置的阈值，就会成为leader。\n选出新主节点\n把网络状态不好的从节点给排除：先把已经下线的从节点过滤掉，然后把以往网络连接状态不好的从节点排除掉。接下来要对所有从节点进行三轮考察：优先级、复制进度、ID号。在进行每一轮考察的时候，哪个从节点优先胜出，就选择其作为新主节点：\n第一轮考察：哨兵首先会根据从节点的优先级来进行排序，优先级的值越小排名越靠前。\n第二轮考察：如果优先级相同，则查看复制的下标，哪个接收的复制数据多哪个就靠前。\n第三轮考察：如果优先级和下标都相同，选择ID较小的那个。\n更换主节点\n选出新主节点之后，哨兵leader让已下线主节点属下的所有从节点指向新主节点。\n通知客户的主节点已更换\n客户端和哨兵建立连接后，客户端会订阅哨兵提供的频道。主从切换完成后，哨兵就会向 +switch-master 频道发布新主节点的 IP 地址和端口的消息，这个时候客户端就可以收到这条信息，然后用这里面的新主节点的 IP 地址和端口进行通信了。\n将旧主节点变为从节点\n继续监视旧主节点，当旧主节点重新上线时，哨兵集群就会向它发送SLAVEOF命令，让它成为新主节点的从节点。\nRedis sentinel（哨兵）模式优缺点有哪些？ # 回答 # Redis 哨兵的好处在于可以保证系统的高可用，各个节点可以对故障自动转移。但缺点是使用的主从模式，主节点单点风险高，主从切换过程可能会出现丢失数据的问题。\n说说 Redis 哈希槽的概念？ # 回答 # Redis 集群并没有使用一致性 hash，而是引入了哈希槽的概念。Redis 集群有 16384（2^14）个哈希槽，每个 key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽，集群的每个节点负责一部分 hash 槽。\n哈希槽和Redis节点是如何对应的？ # 在创建集群的时候，我们可以为cluster中每个节点，划分指责，也就是给他们分配负责的数据区间，这里Redis使用的是一个叫Hash槽的概念，即将数据分为了多个槽，每个节点负责一些槽。\nredis-cli -p 7000 cluster addslots {0..5461}\rredis-cli -p 7001 cluster addslots {5462..10922}\rredis-cli -p 7002 cluster addslots {10923..16383} 如果想自动平均分配，也可以使用 CLUSTER REBALANCE 命令。\n回答 # 主要有自动分配和手动分配两种方式。自动分配是集群创建或者节点添加减少时，Redis自动将哈希槽平均分配到集群节点上；手动分配是使用命令指定每个节点上面的哈希槽数目，使用手动分配时要把16384个槽位给分完，否则集群不会正常工作。\n主从模式的同步过程 # Redis主从复制流程主要分为以下三个核心步骤：\n连接协商阶段\n从服务器向主服务器发送PSYNC命令，携带主服务器的runID和复制偏移量（offset）参数\n主服务器响应从服务器，返回自身的runID和当前复制偏移量\n从服务器接收并持久化存储这两个关键参数，为后续数据同步建立基础\n数据全量同步阶段\n主服务器执行BGSAVE生成RDB快照文件\n从服务器接收RDB文件后，会先清空现有数据集，再加载RDB文件完成全量数据同步，为确保数据一致性，主服务器在此期间将新写入操作暂存至复制缓冲区（replication buffer）\n增量同步阶段\n主服务器将复制缓冲区中的写操作按顺序发送至从服务器\n从服务器依次执行接收到的写命令，完成最终数据同步\n至此，首次主从数据同步完整流程执行完毕，进入增量数据同步阶段，也就是Redis主节点利用缓冲区将写入操作持续同步给Redis从节点\n回答 # 主要分为建立连接协商、主从数据同步、发送新操作三个步骤，连接协商主要确定复制偏移量等关键数据，为同步建立基础；首次主从同步数据是通过RDB文件传递来同步，期间的命令是利用复制缓冲区同步，完成首次同步之后，后续写入操作持续同步给Redis从节点，保证增量数据也是同步的。\n从服务重新上线之后，主服务器如何知道要将哪些增量数据发送给从服务器？ # 这里主要要点明如何决策是增量同步还是全量同步，这个决策取决于读的数据是不是在repl_backlog_buffer中。\n什么是repl_backlog_buffer？\nrepl_backlog_buffer是一个环形缓冲区，用于主从服务器断连后，从中找到差异的数据；replication offset标记缓冲区的同步进度。\n回答 # 网络断开从服务器重新上线之后，会发送自己的复制偏移量到主服务器，主服务器根据偏移量之间的差距判断要执行的操作：如果从服务器要读的数据在repl_backlog_buffer中，则采用增量复制；如果不在，采用全量复制。\nRedis如何减少主从数据的不一致？ # Redis从节点会向主节点同步数据，但是同步总有延迟，有延迟就有一段时间的不一致，回答要点是如何减少不一致时间，或者对外屏蔽不一致的从节点数据。\n回答 # 为了优化Redis主从节点不一致的问题，可以采取以下措施：\n同机房部署：将主从节点部署在同一机房，以降低网络延迟，减少数据同步的时间差。 外部监控程序：通过外部程序实时监控主从节点的复制进度。计算主从节点之间的复制进度差，如果复制进度差超过预设阈值，客户端将不再从该从节点读取数据，从而减少数据不一致对业务的影响，进度差阈值设置不宜太小，确保在主从同步延迟较高时，客户端仍能正常访问部分从节点。 主从模式是同步复制还是异步复制？ # 搞清楚同步复制和异步复制的区别，就不难判断，Redis是异步复制。\n同步复制 (Synchronous Replication)\n定义：主服务器在执行写操作时，必须等待所有从服务器成功写入数据并返回确认后，才向客户端返回成功响应。\n特点：\n强一致性：主从数据完全一致，确保数据的可靠性。 高延迟：由于需要等待从服务器的响应，写操作的延迟较高。 可靠性高：即使主服务器宕机，从服务器也能提供完整的数据。 性能开销大：网络延迟或从服务器性能不足会拖慢整体性能。 适用场景：\n对数据一致性要求极高的场景，如金融交易、支付系统。 允许较高延迟但对数据可靠性要求严格的场景。 异步复制 (Asynchronous Replication)\n定义：主服务器执行写操作后，立即向客户端返回成功响应，而不等待从服务器的写入确认。数据复制在后台异步进行。\n特点：\n弱一致性：主从数据可能存在短暂的不一致（复制延迟）。 低延迟：主服务器无需等待从服务器，写操作响应速度快。 性能高：适合高并发、低延迟的场景。 可靠性较低：如果主服务器在数据复制完成前宕机，可能导致数据丢失。 适用场景：\n对性能要求高、允许短暂数据不一致的场景，如社交网络、日志系统。 数据丢失风险可接受的场景。 回答 # 异步复制。因为主节点收到写命令之后，先写到内部的缓冲区，然后再异步发送给从节点。这样做的好处是对主流程影响小，不干扰Redis的高性能。\n","date":"2026年4月28日","externalUrl":null,"permalink":"/posts/interview/redis/06%E4%B8%A8redis-%E9%9B%86%E7%BE%A4/","section":"Posts","summary":"","title":"Redis 集群","type":"posts"},{"content":" Redis 的执行 # 网络 I/O 部分 # 在 Redis 6.0+ 中，这部分工作由多个 I/O 线程并行处理，主要负责以下任务：\n网络连接与数据读取： 监听和接收来自客户端的 Socket 网络连接，并将 TCP 缓冲区中的字节流读取到用户态的 Buffer 中。 协议解析 (RESP 解析)： 将读取到的无意义字节流，按照 Redis 的通信协议（RESP - Redis Serialization Protocol）解析成 Redis 可以理解的命令和参数（例如将字节流转换成 SET key value 的结构）。 数据回写 (Socket Write)： 当 CPU 完成命令执行后，I/O 线程会将内存中的执行结果（例如 +OK\\r\\n）序列化，并并发地写入回对应的 Socket 缓冲区，发送给客户端。 网络数据的读写、系统调用（read/write）以及协议的装箱拆箱是纯粹的 I/O 密集型操作。引入多线程处理这部分，可以充分利用多核 CPU 的优势，大幅度提升 Redis 的吞吐量（QPS）。\nCPU 执行核心部分 # 无论网络 I/O 怎么并发，Redis 真正执行具体命令的阶段，始终是绝对单线程的。 这部分主要负责：\n命令路由与执行： 从解析好的命令队列中取出命令，去哈希字典（Dict）中寻址，并执行具体的数据结构操作（如操作 String、Zset 的跳表、Hash、List 等）。 内存管理： 处理内存的分配与回收。 过期与淘汰策略： 定期或惰性地清理带有 TTL 的过期 Key，或者在内存触及 maxmemory 阈值时，执行 LRU/LFU 淘汰算法。 高级特性处理： 维护和执行 Lua 脚本、事务（MULTI/EXEC）、Pub/Sub 消息分发等机制。 核心特点（为什么坚持单线程执行）：\n无锁化： 因为执行是单线程的，操作底层的 Dict 和各种复杂数据结构时，不需要加任何互斥锁（Mutex）和并发控制，避免了锁竞争带来的开销。 原子性： 单个命令（或 Lua 脚本）的执行天然具备原子性。 避免上下文切换： 避免了多线程抢占 CPU 资源时的内核上下文切换开销。内存操作的速度处于纳秒级，单线程足以跑满单核的极限。 Redis 是单线程还是多线程 # 核心处理逻辑，Redis一直都是单线程的，其它辅助模块也会有一些多线程、多进程的功能，比如：\n复制模块用的多进程；\n某些异步流程从4.0开始用的多线程，例如 UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC 等非阻塞的删除操作。\n网络I/O解包从6.0开始用的是多线程；\n但是这种分支模块，都只是辅助，最核心的还是处理架构，这块Redis始终是单线程的。\n回答 # Redis 核心处理是单线程的，在6.0中使用了多线程进行I/O解包、回包。其他一些边缘点的，比较使用多线程进行删除数据等异步任务，这个倒是4.0就引入了。\nRedis 为什么选择单线程做核心处理 # 为什么用单线程，潜台词其实对应的是为什么不用多线程。\n很多同学只说多线程有多大成本、多复杂，这样就容易被追问多线程这么多问题，那为什么MySQL等很多组件都是用多线程呢，总有优势吧？为啥Redis不利用这种优势？\n所以这里需要从投入产出比分析，我们先看产出：\n首先Redis的定位，是内存 k-v 存储，是做短平快的热点数据处理，一般来说执行会很快，执行本身不应该成为瓶颈，而瓶颈通常在网络I/O，处理逻辑多线程并不会有太大收益。\n再看投入：\n引入多线程带来极大的复杂度，比如原来的顺序执行特性就不复存在，为了支持事务的原子性、隔离性，Redis就不得不引入一些很复杂的实现，多线程模式也使得程序调试更加复杂和麻烦，会带来额外的开发成本及运营成本，也更容易犯错。同时，也带来上下文切换成本、同步机制的开销、线程本身也占据内存大小等投入成本。\n回答 # 我们从投入产出来看。首先如果引入多线程，主要是希望充分利用多核的性能，但Redis的定位，是内存k-v存储，是做短平快的热点数据处理，一般来说执行会很快，执行本身不应该成为瓶颈，而瓶颈通常在网络I/O，处理逻辑多线程并不会有太大收益。同时，支持多线程的话，我们需要付出更大的复杂度、以及多线程上下文切换、同步机制的开销等成本。这样综合来看，成本高且收益不大，所以最终选择了不做，事实也证明，单线程的Redis也确实足够高效\nRedis 单线程性能如何？ # 这个主要考察直观的认知，Redis实际表现为单机（普通8核16G的机器）读10多万，写几万，非常炸裂。可以说自己也用过 redis-benchmark 来测试过。\n命令： redis-benchmark -h 127.0.0.1 -p 6379 -t set,get -n 10000 -q 结果： SET: 108695.65 requests per second​ GET: 149253.73 requests per second 回答 # Redis 单线程的性能是很好的，在普通机器每秒能 10 多万的读性能、几万的写性能。\n为什么单线程还能这么快 # 这里很多同学都能答到内存存储、高效的数据结构，但是遗漏 I/O 多路复用，其实某种意义上 I/O 复用是非常核心的，因为Redis使用单线程的核心，是瓶颈在I/O而不是CPU，那 I/O 复用正好能对症下药。\n基于内存操作：Redis的绝大部分操作在内存里就可以实现，数据也存在内存中，与传统的磁盘文件操作相比减少了IO，提高了操作的速度。 高效的数据结构：Redis有专门设计了STRING、LIST、HASH等高效的数据结构，依赖各种数据结构提升了读写的效率。 采用单线程：单线程操作省去了上下文切换带来的开销和CPU的消耗，同时不存在资源竞争，避免了死锁现象的发生。 I/O多路复用：采用 I/O 多路复用机制同时监听多个 Socket，根据 Socket 上的事件来选择对应的事件处理器进行处理。 回答 # 我认为有三点，一个是内存存储，这是Redis的定位也是快的前提，一个是高效的数据结构，Redis的数据结构可以说是追求极致，持续调优，最后一个是I/O多路复用，Redis的瓶颈在I/O而不是CPU，那I/O复用正好能对症下药。\nRedis6.0之后引入了多线程，你知道为什么吗？ # 要回答这个问题，就要回归到Redis瓶颈所在：是 I/O 而不是CPU，但是随着互联网发展，请求量巨大的时候单线程在进行同步读写 I/O 的时间，单核 CPU 有时候也是不够用了。\n回答 # Redis主要瓶颈是 I/O 而不是CPU，但随着互联网的高速发展，在部分高并发场景，单核 CPU 也不见得处理得过来了，所以针对核心处理流程中的解包、发包这两个CPU耗时操作，进行了多线程优化，充分发挥多核优势\nRedis6.0的多线程是默认开启的吗？ # 回答 # 默认是关闭的，如果想要开启需要用户在 redis.conf 配置文件中修改。默认关闭第一是为了兼容以前的，毕竟很多用户的认知中，Redis是单线程的，第二可能也是认为多线程并不是必要的，在大多数场景不开启也是完全够用的。\nRedis 6.0 的多线程主要负责命令执行的哪一块 # Redis主要瓶颈是I/O而不是CPU，但随着互联网的高速发展，在部分高并发场景，单核CPU也不见得处理得过来了，所以针对核心处理流程中的解包、发包这两个CPU耗时操作，进行了多线程优化，充分发挥多核优势\n回答 # 原来核心流程中的 I/O 处理，包括解包和回包，也就是读写客户端 socket 的 I/O ，这两部分都消耗CPU时间，多线程的引入主要也是为了解决单核 CPU 在大数据下还是不够用的问题。\n","date":"2026年4月28日","externalUrl":null,"permalink":"/posts/interview/redis/02%E4%B8%A8redis-%E6%89%A7%E8%A1%8C/","section":"Posts","summary":"","title":"Redis 执行流程","type":"posts"},{"content":"","date":"2026年4月28日","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"2026年4月28日","externalUrl":null,"permalink":"/","section":"Zenith","summary":"","title":"Zenith","type":"page"},{"content":"","date":"2026年4月28日","externalUrl":null,"permalink":"/categories/","section":"技术专栏","summary":"","title":"技术专栏","type":"categories"},{"content":"","date":"2026年4月28日","externalUrl":null,"permalink":"/series/","section":"专题系列","summary":"","title":"专题系列","type":"series"},{"content":"","date":"2026年4月27日","externalUrl":null,"permalink":"/tags/go/","section":"Tags","summary":"","title":"Go","type":"tags"},{"content":" Mutex 的基本使用 # Locker 接口 # 在 Go 的标准库中，package sync 提供了锁相关的一系列同步原语，这个 package 还定义了一个 Locker 的接口，Mutex 就实现了这个接口。\nLocker 的接口定义了锁同步原语的方法集：\ntype Locker interface { Lock() Unlock() } Go 定义的锁接口的方法集很简单，就是请求锁（Lock）和释放锁（Unlock）这两个方法。但在实际中，我们一般会直接使用具体的同步原语，而不是通过接口。\nMutex 和 RWMutex 都实现了 Locker 接口。\nMutex # 互斥锁 Mutex 就提供两个方法 Lock 和 Unlock：进入临界区之前调用 Lock 方法，退出临界区的时候调用 Unlock 方法：\nfunc(m *Mutex)Lock() func(m *Mutex)Unlock() 当一个 goroutine 通过调用 Lock 方法获得了这个锁的拥有权后， 其它请求锁的 goroutine 就会阻塞在 Lock 方法的调用上，直到锁被释放并且自己获取到了这个锁的拥有权。\n使用场景 # 例如：我们创建了 10 个 goroutine，同时不断地对一个变量（count）进行加 1 操作，每个 goroutine 负责执行 10 万次的加 1 操作，我们期望的最后计数的结果是 10 * 100000 = 1000000 (一百万)\nimport ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; ) func main() { var count = 0 // 使用WaitGroup等待10个goroutine完成 var wg sync.WaitGroup wg.Add(10) for i := 0; i \u0026lt; 10; i++ { go func() { defer wg.Done() // 对变量count执行10次加1 for j := 0; j \u0026lt; 100000; j++ { count++ } }() } // 等待10个goroutine完成 wg.Wait() fmt.Println(count) } 使用 sync.WaitGroup 来等待所有的 goroutine 执行完毕后，再输出最终的结果。每次运行，你都可能得到不同的结果，基本上不会得到理想中的一百万的结果。\n$ go run counter.go 855347 $ go run counter.go 802752 $ go run counter.go 789473 这是因为，count++ 不是一个原子操作，它至少包含几个步骤，比如读取变量 count 的当前值，对这个值加 1，把结果再保存到 count 中。因为不是原子操作，就可能有并发的问题。\n比如，10 个 goroutine 同时读取到 count 的值为 9527，接着各自按照自己的逻辑加 1，值变成了 9528，然后把这个结果再写回到 count 变量。但是，实际上，此时我们增加的总数应该是 10 才对，这里却只增加了 1，好多计数都被“吞”掉了。这是并发访问共享数据的常见错误。\n针对这个问题：Go 提供了一个检测并发访问共享资源是否有问题的工具：race detector，它可以帮助我们自动发现程序有没有 data race 的问题。\n在编译（compile）、测试（test）或者运行（run）Go 代码的时候，加上 race 参数，就有可能发现并发问题。比如在上面的例子中，我们可以加上 race 参数运行，检测一下是不是有并发问题。如果你 go run -race counter.go，就会输出警告信息。\n$ go run -race counter.go ================== WARNING: DATA RACE Read at 0x00c00009a078 by goroutine 14: main.main.func1() /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.1counter/counter.go:19 +0x99 Previous write at 0x00c00009a078 by goroutine 7: main.main.func1() /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.1counter/counter.go:19 +0xab Goroutine 14 (running) created at: main.main() /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.1counter/counter.go:15 +0x84 Goroutine 7 (running) created at: main.main() /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.1counter/counter.go:15 +0x84 ================== ================== WARNING: DATA RACE Write at 0x00c00009a078 by goroutine 14: main.main.func1() /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.1counter/counter.go:19 +0xab Previous write at 0x00c00009a078 by goroutine 7: main.main.func1() /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.1counter/counter.go:19 +0xab Goroutine 14 (running) created at: main.main() /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.1counter/counter.go:15 +0x84 Goroutine 7 (running) created at: main.main() /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.1counter/counter.go:15 +0x84 ================== 850860 Found 2 data race(s) exit status 66 这个警告不但会告诉你有并发问题，而且还会告诉你哪个 goroutine 在哪一行对哪个变量有写操作，同时，哪个 goroutine 在哪一行对哪个变量有读操作，就是这些并发的读写访问，引起了 data race。\n虽然这个工具使用起来很方便，但是，因为它的实现方式，只能通过真正对实际地址进行读写访问的时候才能探测，所以它并不能在编译的时候发现 data race 的问题。而且，在运行的时候，只有在触发了 data race 之后，才能检测到，如果碰巧没有触发（比如一个 data race 问题只能在 2 月 14 号零点或者 11 月 11 号零点才出现），是检测不出来的。\n而且，把开启了 race 的程序部署在线上，还是比较影响性能的。\n这个工具的实现机制：通过在编译的时候插入一些指令，在运行时通过这些插入的指令检测并发读写从而发现 data race 问题。\n这里的共享资源是 count 变量，临界区是 count++，只要在临界区前面获取锁，在离开临界区的时候释放锁，就能完美地解决 data race 的问题了。\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; ) func main() { var mu sync.Mutex count := 0 // 使用 WaitGroup 等待 10 个 goroutine 完成. var wg sync.WaitGroup wg.Add(10) for i := 0; i \u0026lt; 10; i++ { go func() { defer wg.Done() // 对变量进行执行 100000 次加 1. for j := 0; j \u0026lt; 100000; j++ { mu.Lock() count++ mu.Unlock() } }() } // 等待 10 个 goroutine 完成. wg.Wait() fmt.Println(count) } 如果你再运行一下程序，就会发现，data race 警告没有了，系统干脆地输出了 1000000。\n这里有一点需要注意：Mutex 的零值是还没有 goroutine 等待的未加锁的状态，所以你不需要额外的初始化，直接声明变量（如 var mu sync.Mutex）即可。\n这样，就可以通过将 Mutex 嵌入到其他 struct 中使用。\ntype Counter struct { mu sync.Mutex Count uint64 } 在初始化嵌入的 struct 时，也不必初始化这个 Mutex 字段，不会因为没有初始化出现空指针或者是无法获取到锁的情况。\n有时候，我们还可以采用嵌入字段的方式。通过嵌入字段，你可以在这个 struct 上直接调用 Lock/Unlock 方法。\ntype Counter struct { sync.Mutex Count uint64 } func main() { var counter Counter var wg sync.WaitGroup wg.Add(10) for i := 0; i \u0026lt; 10; i++ { go func() { defer wg.Done() for j := 0; j \u0026lt; 100000; j++ { counter.Lock() counter.Count++ counter.Unlock() } }() } wg.Wait() fmt.Println(counter.Count) } 如果嵌入的 struct 有多个字段，我们一般会把 Mutex 放在要控制的字段上面，然后使用空格把字段分隔开来。即使你不这样做，代码也可以正常编译，只不过，用这种风格去写的话，逻辑会更清晰，也更易于维护。\n还可以把获取锁、释放锁、计数加一的逻辑封装成一个方法，对外不需要暴露锁等逻辑：\nfunc main() { // 封装好的计数器 var counter Counter var wg sync.WaitGroup wg.Add(10) // 启动10个goroutine for i := 0; i \u0026lt; 10; i++ { go func() { defer wg.Done() // 执行10万次累加 for j := 0; j \u0026lt; 100000; j++ { counter.Incr() // 受到锁保护的方法 } }() } wg.Wait() fmt.Println(counter.Count()) } // 线程安全的计数器类型 type Counter struct { CounterType int Name string mu sync.Mutex count uint64 } // 加1的方法，内部使用互斥锁保护 func (c *Counter) Incr() { c.mu.Lock() c.count++ c.mu.Unlock() } // 得到计数器的值，也需要锁保护 func (c *Counter) Count() uint64 { c.mu.Lock() defer c.mu.Unlock() return c.count } Mutex 的进化史 # Mutex 的架构演进分成了四个阶段，下面给你画了一张图来说明。\n“初版” 的 Mutex 使用一个 flag 来表示锁是否被持有，实现比较简单； 后来照顾到新来的 goroutine，所以会让新的 goroutine 也尽可能地先获取到锁，这是第二个阶段，我把它叫作“给新人机会”； 那么，接下来就是第三阶段“多给些机会”，照顾新来的和被唤醒的 goroutine； 但是这样会带来饥饿问题，所以目前又加入了饥饿的解决方案，也就是第四阶段“解决饥饿”。 初版的 mutex # 通过一个 flag 变量，标记当前的锁是否被某个 goroutine 持有。如果这个 flag 的值是 1，就代表锁已经被持有，那么，其它竞争的 goroutine 只能等待；如果这个 flag 的值是 0，就可以通过 CAS（compare-and-swap，或者 compare-and-set）【CAS操作，但是没有抽象出 atomic 包】将这个 flag 设置为 1，标识锁被当前的这个 goroutine 持有了。\n// CAS操作，当时还没有抽象出atomic包 func cas(val *int32, old, new int32) bool func semacquire(*int32) func semrelease(*int32) // 互斥锁的结构，包含两个字段 type Mutex struct { key int32 // 锁是否被持有的标识 sema int32 // 信号量专用，用以阻塞/唤醒goroutine } // 保证成功在 val 上增加 delta 的值 func xadd(val *int32, delta int32) (new int32) { for { v := *val if cas(val, v, v+delta) { return v + delta } } panic(\u0026#34;unreached\u0026#34;) } // 请求锁 func (m *Mutex) Lock() { if xadd(\u0026amp;m.key, 1) == 1 { // 标识加1，如果等于1，成功获取到锁 return } semacquire(\u0026amp;m.sema) // 否则阻塞等待 } func (m *Mutex) Unlock() { if xadd(\u0026amp;m.key, -1) == 0 { // 将标识减去1，如果等于0，则没有其它等待者 return } semrelease(\u0026amp;m.sema) // 唤醒其它阻塞的goroutine } CAS 指令将给定的值和一个内存地址中的值进行比较，如果它们是同一个值，就使用新值替换内存地址中的值，并且这个操作是原子性的，原子性保证这个指令总是基于最新的值进行计算，如果同时有其它线程已经修改了这个值，那么，CAS 会返回失败。CAS 是实现互斥锁和同步原语的基础。\nMutex 结构体包含两个字段：\n字段 key：是一个 flag，用来标识这个排外锁是否被某个 goroutine 所持有，如果 key 大于等于 1，说明这个排外锁已经被持有； 字段 sema：是个信号量变量，用来控制等待 goroutine 的阻塞休眠和唤醒。 考虑：为何不就只使用 key 来管理？key 用于快路径+计数，sema 用于慢路径，要把 Goroutine 挂起。挂起和唤醒涉及到复杂的调度逻辑，成本很高。sema 就是专门为这种“重负载”操作提供挂载点的。 调用 Lock 请求锁的时候，通过 xadd 方法进行 CAS 操作（第 24 行），xadd 方法通过循环执行 CAS 操作直到成功，保证对 key 加 1 的操作成功完成。如果比较幸运，锁没有被别的 goroutine 持有，那么，Lock 方法成功地将 key 设置为 1，这个 goroutine 就持有了这个锁；如果锁已经被别的 goroutine 持有了，那么，当前的 goroutine 会把 key 加 1，而且还会调用 semacquire 方法（第 27 行），使用信号量将自己休眠，等锁释放的时候，信号量会将它唤醒。\n持有锁的 goroutine 调用 Unlock 释放锁时，它会将 key 减 1（第 31 行）。如果当前没有其它等待这个锁的 goroutine，这个方法就返回了。但是，如果还有等待此锁的其它 goroutine，那么，它会调用 semrelease 方法（第 34 行），利用信号量唤醒等待锁的其它 goroutine 中的一个。\n初版的 Mutex 利用 CAS 原子操作，对 key 这个标志量进行设置。key 不仅仅标识了锁是否被 goroutine 所持有，还记录了当前持有和等待获取锁的 goroutine 的数量。\nUnlock 方法可以被任意的 goroutine 调用释放锁，即使是没持有这个互斥锁的 goroutine，也可以进行这个操作。这是因为，Mutex 本身并没有包含持有这把锁的 goroutine 的信息，所以，Unlock 也不会对此进行检查。Mutex 的这个设计一直保持至今。\n示例：\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; ) func main() { var mu sync.Mutex var wg sync.WaitGroup done := make(chan struct{}) wg.Add(1) // A goroutine 加锁并执行工作. go func() { mu.Lock() fmt.Println(\u0026#34;Goroutine A: 资源已锁定\u0026#34;) // 在这里完成一些操作... // 然后通知另一 goroutine 进行解锁. done \u0026lt;- struct{}{} }() // B goroutine 等待通知后解锁. go func() { \u0026lt;-done mu.Unlock() fmt.Println(\u0026#34;Goroutine B: 资源已解锁\u0026#34;) wg.Done() // 演示完成. }() // 等待演示完成. wg.Wait() } 运行结果：\n$ go run main.go Goroutine A: 资源已锁定 Goroutine B: 资源已解锁 这就带来了一个有趣而危险的功能。其它 goroutine 可以强制释放锁，这是一个非常危险的操作，因为在临界区的 goroutine 可能不知道锁已经被释放了，还会继续执行临界区的业务操作，这可能会带来意想不到的结果，因为这个 goroutine 还以为自己持有锁呢，有可能导致 data race 问题。\n我们在使用 Mutex 的时候，必须要保证 goroutine 尽可能不去释放自己未持有的锁，一定要遵循 “谁申请，谁释放” 的原则。在真实的实践中，我们使用互斥锁的时候，很少在一个方法中单独申请锁，而在另外一个方法中单独释放锁，一般都会在同一个方法中获取锁和释放锁。并且Go 对 defer 做了优化，采用更有效的内联方式，取代之前的生成 defer 对象到 defer chain 中，defer 对耗时的影响微乎其微了，这样就可以让 Lock/Unlock 总是成对紧凑出现，不会遗漏或者多调用，代码更少。\nfunc (f *Foo) Bar() { f.mu.Lock() defer f.mu.Unlock() if f.count \u0026lt; 1000 { f.count += 3 return } f.count++ return } 问题：\n初版的 Mutex 实现有一个问题：请求锁的 goroutine 会排队等待获取互斥锁。虽然这貌似很公平，但是从性能上来看，却不是最优的。因为如果我们能够把锁交给正在占用 CPU 时间片的 goroutine 的话，那就不需要做上下文的切换，在高并发的情况下，可能会有更好的性能。\n给新人机会 # NOTE:\n更加倾向给正在占用 CPU 时间片的 goroutine，减少上下文切换带来的开销。\nGo 开发者在 2011 年 6 月 30 日的 commit 中对 Mutex 做了一次大的调整，调整后的 Mutex 实现如下：\ntype Mutex struct { state int32 sema uint32 } const ( mutexLocked = 1 \u0026lt;\u0026lt; iota // mutex is locked mutexWoken mutexWaiterShift = iota ) 这里补一下 iota 的操作：\n遇 const 归零： 每当源码中出现 const 关键字时，iota 的值就会被重置为 0。\n按行递增： 在 const 块中，每新增一行常量声明，iota 的值就会自动加 1。\n隐式继承： 如果在常量声明中省略了等号和表达式，它会默认复制上一行的表达式，但会使用当前行最新的 iota 值去计算。\nmutexLocked：此时 iota = 0；1 \u0026laquo; 0 = 1；\nmutexWoken：此时 iota = 1；且继承上一行表达式，1 \u0026laquo; 1 = 2；\nmutexWaiterShift：此时 iota = 2；声明为 iota，即 2。\n虽然 Mutex 结构体还是包含两个字段，但是第一个字段已经改成了 state，它的含义也不一样了。\nstate 是一个复合型的字段，一个字段包含多个意义，这样可以通过尽可能少的内存来实现互斥锁。这个字段的第一位（最小的一位）来表示这个锁是否被持有，第二位代表是否有唤醒的 goroutine，剩余的位数代表的是等待此锁的 goroutine 数。所以，state 这一个字段被分成了三部分，代表三个数据。\nmutexWaiters：表示阻塞等待的 waiter 数量； mutexWoken：用于表示当前是否有被唤醒的 goroutine 正在尝试获取锁； mutexLocked：用于表示当前是否有 goroutine 持有该锁。 获取锁 # 请求锁的方法 Lock 也变得复杂了。复杂之处不仅仅在于对字段 state 的操作难以理解，而且代码逻辑也变得相当复杂。\nfunc (m *Mutex) Lock() { // Fast path: 幸运 case，能够直接获取到锁 if atomic.CompareAndSwapInt32(\u0026amp;m.state, 0, mutexLocked) { return } awoke := false // 每个 goroutine 的局部变量 for { old := m.state new := old | mutexLocked // 新状态加锁，默认都设置「想要持有锁」标志 if old\u0026amp;mutexLocked != 0 { new = old + 1\u0026lt;\u0026lt;mutexWaiterShift // 等待者数量加一 } if awoke { // 如果是从 runtime.Semacquire 被唤醒的 goroutine（awoke == true） // 就要去掉 mutexWoken，让下一个等待者可以被唤醒。 new \u0026amp;^= mutexWoken } if atomic.CompareAndSwapInt32(\u0026amp;m.state, old, new) { // 设置新状态，这里成功设置，不代表抢到了锁 if old \u0026amp; mutexLocked == 0 { // 锁原状态未加锁，说明抢到了锁 break } runtime.Semacquire(\u0026amp;m.sema) // 锁仍被占用，自己要阻塞在信号量上 awoke = true } } } 首先是通过 CAS 检测 state 字段中的标志（第 3 行）\nfunc CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) addr：指向int32变量的指针，表示需要操作的内存地址。\nold：预期的旧值，用于比较。\nnew：如果比较成功，将设置的新值。\nswapped：布尔值，表示是否成功交换。\n工作原理：\n原子性检查：函数会原子地检查addr指向的当前值是否等于old。 条件交换： 如果当前值等于old，则将addr的值设置为new，并返回true。 否则，不进行任何操作，直接返回false。 检查 state 是否为 0，state 为 0 则表示：\n锁未被持有（mutexLocked 位为 0）； 没有等待的协程被唤醒（mutexWoken 位为 0）； 没有协程在等待（mutexWaiterShift 部分为 0。 这样当前的 goroutine 就很幸运，可以直接获得锁，这也是注释中的 Fast path 的意思。\n如果不够幸运，即第 3 行代码的 CAS 失败，state 不是零值，说明有持有者，那么就通过一个循环进行检查。其整理逻辑为：如果想要获取锁的 goroutine 没有机会获取到锁，就会进行休眠，但是在锁释放唤醒之后，它并不能像先前一样直接获取到锁，还是要和正在请求锁的 goroutine 进行竞争。这会给后来请求锁的 goroutine 一个机会，也让 CPU 中正在执行的 goroutine 有更多的机会获取到锁，在一定程度上提高了程序的性能。\nfor 循环是不断尝试获取锁，如果获取不到，就通过 runtime.Semacquire(\u0026amp;m.sema) 休眠，休眠醒来之后 awoke 置为 true，尝试争抢锁。\n代码中的第 10 行将当前的 flag 设置为加锁状态，如果能成功地通过 CAS 把这个新值赋予 state（第 19 行和第 20 行），就代表抢夺锁的操作成功了。\n不过，需要注意的是，如果成功地设置了 state 的值，但是之前的 state 是有锁的状态，那么，state 只是清除 mutexWoken 标志或者增加一个 waiter 而已。\n请求锁的 goroutine 有两类，一类是新来请求锁的 goroutine，另一类是被唤醒的等待请求锁的 goroutine。锁的状态也有两种：加锁和未加锁。\n释放锁 # 释放锁的 Unlock 方法也有些复杂，我们来看一下。\nfunc (m *Mutex) Unlock() { // Fast path: drop lock bit. new := atomic.AddInt32(\u0026amp;m.state, -mutexLocked) // 去掉锁标志 if (new+mutexLocked)\u0026amp;mutexLocked == 0 { // 本来就没有加锁 panic(\u0026#34;sync: unlock of unlocked mutex\u0026#34;) } old := new for { if old\u0026gt;\u0026gt;mutexWaiterShift == 0 || old\u0026amp;(mutexLocked|mutexWoken) != 0 { // 没有等待者，或者有唤醒的waiter，或者锁原来已加锁 return } new = (old - 1\u0026lt;\u0026lt;mutexWaiterShift) | mutexWoken // 新状态，准备唤醒goroutine，并设置唤醒标志 if atomic.CompareAndSwapInt32(\u0026amp;m.state, old, new) { runtime.Semrelease(\u0026amp;m.sema) return } old = m.state } } 第 3 行是尝试将持有锁的标识设置为未加锁的状态，这是通过减 1 而不是将标志位置零的方式实现。第 4 到 6 行还会检测原来锁的状态是否已经未加锁的状态，如果是 Unlock 一个未加锁的 Mutex 会直接 panic。\n不过，即使将加锁置为未加锁的状态，这个方法也不能直接返回，还需要一些额外的操作，因为还可能有一些等待这个锁的 goroutine（有时候我也把它们称之为 waiter）需要通过信号量的方式唤醒它们中的一个。所以接下来的逻辑有两种情况。\n第一种情况，如果没有其它的 waiter，说明对这个锁的竞争的 goroutine 只有一个，那就可以直接返回了；如果这个时候有唤醒的 goroutine，或者是又被别人加了锁，那么，无需我们操劳，其它 goroutine 自己干得都很好，当前的这个 goroutine 就可以放心返回了。\n第二种情况，如果有等待者，并且没有唤醒的 waiter，那就需要唤醒一个等待的 waiter。在唤醒之前，需要将 waiter 数量减 1，并且将 mutexWoken 标志设置上，这样，Unlock 就可以返回了。通过这样复杂的检查、判断和设置，我们就可以安全地将一把互斥锁释放了。\n相对于初版的设计，这次的改动主要就是，新来的 goroutine 也有机会先获取到锁，甚至一个 goroutine 可能连续获取到锁，打破了先来先得的逻辑。但是，代码复杂度也显而易见。\n虽然这一版的 Mutex 已经给新来请求锁的 goroutine 一些机会，让它参与竞争，没有空闲的锁或者竞争失败才加入到等待队列中。\n多给些机会 # 在 2015 年 2 月的改动中，如果新来的 goroutine 或者是被唤醒的 goroutine 首次获取不到锁，它们就会通过自旋（spin，通过循环不断尝试，spin 的逻辑是在 runtime 实现的）的方式，尝试检查锁是否被释放。在尝试一定的自旋次数后，再执行原来的逻辑。\nfunc (m *Mutex) Lock() { // Fast path: 幸运之路，正好获取到锁 if atomic.CompareAndSwapInt32(\u0026amp;m.state, 0, mutexLocked) { return } awoke := false iter := 0 for { // 不管是新来的请求锁的goroutine, 还是被唤醒的goroutine，都不断尝试请求锁 old := m.state // 先保存当前锁的状态 new := old | mutexLocked // 新状态设置加锁标志 if old\u0026amp;mutexLocked != 0 { // 锁还没被释放 if runtime_canSpin(iter) { // 还可以自旋 if !awoke \u0026amp;\u0026amp; old\u0026amp;mutexWoken == 0 \u0026amp;\u0026amp; old\u0026gt;\u0026gt;mutexWaiterShift != 0 \u0026amp;\u0026amp; atomic.CompareAndSwapInt32(\u0026amp;m.state, old, old|mutexWoken) { awoke = true } runtime_doSpin() iter++ continue // 自旋，再次尝试请求锁 } new = old + 1\u0026lt;\u0026lt;mutexWaiterShift } if awoke { // 唤醒状态 if new\u0026amp;mutexWoken == 0 { panic(\u0026#34;sync: inconsistent mutex state\u0026#34;) } new \u0026amp;^= mutexWoken // 新状态清除唤醒标记 } if atomic.CompareAndSwapInt32(\u0026amp;m.state, old, new) { if old\u0026amp;mutexLocked == 0 { // 旧状态锁已释放，新状态成功持有了锁，直接返回 break } runtime_Semacquire(\u0026amp;m.sema) // 阻塞等待 awoke = true // 被唤醒 iter = 0 } } } 这次的优化，增加了第 13 行到 21 行、第 25 行到第 27 行以及第 36 行。\n自旋优化（第13-21行）\nif runtime_canSpin(iter) { // 还可以自旋 if !awoke \u0026amp;\u0026amp; old\u0026amp;mutexWoken == 0 \u0026amp;\u0026amp; old\u0026gt;\u0026gt;mutexWaiterShift != 0 \u0026amp;\u0026amp; atomic.CompareAndSwapInt32(\u0026amp;m.state, old, old|mutexWoken) { awoke = true } runtime_doSpin() iter++ continue // 自旋，再次尝试请求锁 } 通过自旋减少上下文切换的开销。\n逻辑：\n条件检查： !awoke，当前 goroutine 尚未被唤醒； old\u0026amp;mutexWoken == 0，没有其他 goroutine 被唤醒； old\u0026gt;\u0026gt;mutexWaiterShift != 0，存在其他等待锁的 goroutine； atomic.CompareAndSwapInt32(\u0026amp;m.state, old, old|mutexWoken)，将 mutexWoken 标记置为 1，通知解锁的 goroutine：“已有自旋中的 goroutine 准备获取锁，无需唤醒其他等待者”。 自旋操作： 调用 runtime_doSpin() 执行自旋（占用CPU时间片，忙等待），而非立即休眠。 自旋次数 iter 递增，当超过阈值后，停止自旋。 唤醒状态校验（第25-27行）\nif new\u0026amp;mutexWoken == 0 { panic(\u0026#34;sync: inconsistent mutex state\u0026#34;) } 为什么要新增唤醒状态校验？\n若 mutexWoken 未被设置，说明该唤醒是无效的（例如锁状态已被其他 goroutine 篡改），此时直接 panic 暴露问题。 如果当前 goroutine 是被唤醒的（awoke == true），但新状态 new 中未包含 mutexWoken 标记，说明存在逻辑错误，触发 panic。\n重置状态（第 36 行）\n目的：重置 iter = 0，为下一次可能的自旋做准备。\n自旋优化，这个对于临界区代码执行非常短的场景来说，这是一个非常好的优化。因为临界区的代码耗时很短，锁很快就能释放，而抢夺锁的 goroutine 不用通过休眠唤醒方式等待调度，直接 spin 几次，可能就获得了锁。\n解决饥饿 # 现在的 state 字段：\ntype Mutex struct { state int32 sema uint32 } const ( mutexLocked = 1 \u0026lt;\u0026lt; iota // mutex is locked mutexWoken mutexStarving // 从 state 字段中分出一个饥饿标记 mutexWaiterShift = iota starvationThresholdNs = 1e6 ) func (m *Mutex) Lock() { // Fast path: 幸运之路，一下就获取到了锁 if atomic.CompareAndSwapInt32(\u0026amp;m.state, 0, mutexLocked) { return } // Slow path：缓慢之路，尝试自旋竞争或饥饿状态下饥饿goroutine竞争 m.lockSlow() } func (m *Mutex) lockSlow() { var waitStartTime int64 starving := false // 此goroutine的饥饿标记 awoke := false // 唤醒标记 iter := 0 // 自旋次数 old := m.state // 当前的锁的状态 for { // 锁是非饥饿状态，锁还没被释放，尝试自旋 if old\u0026amp;(mutexLocked|mutexStarving) == mutexLocked \u0026amp;\u0026amp; runtime_canSpin(iter) { if !awoke \u0026amp;\u0026amp; old\u0026amp;mutexWoken == 0 \u0026amp;\u0026amp; old\u0026gt;\u0026gt;mutexWaiterShift != 0 \u0026amp;\u0026amp; atomic.CompareAndSwapInt32(\u0026amp;m.state, old, old|mutexWoken) { awoke = true } runtime_doSpin() iter++ old = m.state // 再次获取锁的状态，之后会检查是否锁被释放了 continue } new := old if old\u0026amp;mutexStarving == 0 { new |= mutexLocked // 非饥饿状态，加锁 } if old\u0026amp;(mutexLocked|mutexStarving) != 0 { new += 1 \u0026lt;\u0026lt; mutexWaiterShift // waiter数量加1 } if starving \u0026amp;\u0026amp; old\u0026amp;mutexLocked != 0 { new |= mutexStarving // 设置饥饿状态 } if awoke { if new\u0026amp;mutexWoken == 0 { throw(\u0026#34;sync: inconsistent mutex state\u0026#34;) } new \u0026amp;^= mutexWoken // 新状态清除唤醒标记 } // 成功设置新状态 if atomic.CompareAndSwapInt32(\u0026amp;m.state, old, new) { // 原来锁的状态已释放，并且不是饥饿状态，正常请求到了锁，返回 if old\u0026amp;(mutexLocked|mutexStarving) == 0 { break // locked the mutex with CAS } // 处理饥饿状态 // 如果以前就在队列里面，加入到队列头 queueLifo := waitStartTime != 0 if waitStartTime == 0 { waitStartTime = runtime_nanotime() } // 阻塞等待 runtime_SemacquireMutex(\u0026amp;m.sema, queueLifo, 1) // 唤醒之后检查锁是否应该处于饥饿状态 starving = starving || runtime_nanotime()-waitStartTime \u0026gt; starvationThresholdNs old = m.state // 如果锁已经处于饥饿状态，直接抢到锁，返回 if old\u0026amp;mutexStarving != 0 { if old\u0026amp;(mutexLocked|mutexWoken) != 0 || old\u0026gt;\u0026gt;mutexWaiterShift == 0 { throw(\u0026#34;sync: inconsistent mutex state\u0026#34;) } // 有点绕，加锁并且将waiter数减1 delta := int32(mutexLocked - 1\u0026lt;\u0026lt;mutexWaiterShift) if !starving || old\u0026gt;\u0026gt;mutexWaiterShift == 1 { delta -= mutexStarving // 最后一个waiter或者已经不饥饿了，清除饥饿标记 } atomic.AddInt32(\u0026amp;m.state, delta) break } awoke = true iter = 0 } else { old = m.state } } } func (m *Mutex) Unlock() { // Fast path: drop lock bit. new := atomic.AddInt32(\u0026amp;m.state, -mutexLocked) if new != 0 { m.unlockSlow(new) } } func (m *Mutex) unlockSlow(new int32) { if (new+mutexLocked)\u0026amp;mutexLocked == 0 { throw(\u0026#34;sync: unlock of unlocked mutex\u0026#34;) } if new\u0026amp;mutexStarving == 0 { old := new for { if old\u0026gt;\u0026gt;mutexWaiterShift == 0 || old\u0026amp;(mutexLocked|mutexWoken|mutexStarving) != 0 { return } new = (old - 1\u0026lt;\u0026lt;mutexWaiterShift) | mutexWoken if atomic.CompareAndSwapInt32(\u0026amp;m.state, old, new) { runtime_Semrelease(\u0026amp;m.sema, false, 1) return } old = m.state } } else { runtime_Semrelease(\u0026amp;m.sema, true, 1) } } 跟之前的实现相比，当前的 Mutex 最重要的变化，就是增加饥饿模式。\n第 12 行代码 starvationThresholdNs = 1e6 将饥饿模式的最大等待时间阈值设置成了 1 毫秒，这就意味着，一旦等待者等待的时间超过了这个阈值，Mutex 的处理就有可能进入饥饿模式，优先让等待者先获取到锁，新来的同学主动谦让一下，给老同志一些机会。\n通过加入饥饿模式，可以避免把机会全都留给新来的 goroutine，保证了请求锁的 goroutine 获取锁的公平性，对于我们使用锁的业务代码来说，不会有业务一直等待锁不被处理。\n正常模式和饥饿模式的比较 # Mutex 可能处于两种操作模式下：正常模式和饥饿模式。\n请求锁时调用的 Lock 方法中一开始是 fast path，这是一个幸运的场景，当前的 goroutine 幸运地获得了锁，没有竞争，直接返回，否则就进入了 lockSlow 方法。这样的设计，方便编译器对 Lock 方法进行内联，你也可以在程序开发中应用这个技巧。\n正常模式下，waiter 都是进入先入先出队列，被唤醒的 waiter 并不会直接持有锁，而是要和新来的 goroutine 进行竞争。新来的 goroutine 有先天的优势，它们正在 CPU 中运行，可能它们的数量还不少，所以，在高并发情况下，被唤醒的 waiter 可能比较悲剧地获取不到锁，这时，它会被插入到队列的前面。如果 waiter 获取不到锁的时间超过阈值 1 毫秒，那么，这个 Mutex 就进入到了饥饿模式。\n在饥饿模式下，Mutex 的拥有者将直接把锁交给队列最前面的 waiter。新来的 goroutine 不会尝试获取锁，即使看起来锁没有被持有，它也不会去抢，也不会 spin，它会乖乖地加入到等待队列的尾部。\n如果拥有 Mutex 的 waiter 发现下面两种情况的其中之一，它就会把这个 Mutex 转换成正常模式：\n此 waiter 已经是队列中的最后一个 waiter 了，没有其它的等待锁的 goroutine 了； 此 waiter 的等待时间小于 1 毫秒。 正常模式拥有更好的性能，因为即使有等待抢锁的 waiter，goroutine 也可以连续多次获取到锁。\n饥饿模式是对公平性和性能的一种平衡，它避免了某些 goroutine 长时间的等待锁。在饥饿模式下，优先对待的是那些一直在等待的 waiter。\n饥饿模式详细分析 # 接下来，我们逐步分析下 Mutex 代码的关键行，彻底搞清楚饥饿模式的细节。\n第 9 行对 state 字段又分出了一位，用来标记锁是否处于饥饿状态。现在一个 state 的字段被划分成了阻塞等待的 waiter 数量、饥饿标记、唤醒标记和持有锁的标记四个部分。\n第 25 行记录此 goroutine 请求锁的初始时间，第 26 行标记是否处于饥饿状态，第 27 行标记是否是被唤醒的，第 28 行变量 iter 记录 spin 的次数。\n第 31 行到第 40 行和以前的逻辑类似，只不过加了一个不能是饥饿状态的逻辑，增加部分为：\nif old\u0026amp;(mutexLocked|mutexStarving) == mutexLocked ... 可以分解为：\nold \u0026amp; mutexLocked == mutexLocked // 锁被占用 \u0026amp;\u0026amp; old \u0026amp; mutexStarving == mutexLocked // 处于饥饿 它会对正常状态抢夺锁的 goroutine 尝试 spin，和以前的目的一样，就是在临界区耗时很短的情况下提高性能。\n第 42 行到第 44 行，非饥饿状态下抢锁。怎么抢？就是要把 state 的锁的那一位，置为加锁状态，后续 CAS 如果成功就可能获取到了锁。\n第 46 行到第 48 行，如果锁已经被持有或者锁处于饥饿状态，我们最好的归宿就是等待，所以 waiter 的数量加 1。\n第 49 行到第 51 行，如果此 goroutine 已经处在饥饿状态，并且锁还被持有，那么，我们需要把此 Mutex 设置为饥饿状态。\n第 52 行到第 57 行，是清除 mutexWoken 标记，因为不管是获得了锁还是进入休眠，我们都需要清除 mutexWoken 标记。\n第 59 行就是尝试使用 CAS 设置 state。\n如果成功，第 61 行到第 63 行是检查原来的锁的状态是未加锁状态，并且也不是饥饿状态的话就成功获取了锁，返回。\n第 67 行判断是否第一次加入到 waiter 队列。到这里，你应 该就能明白第 25 行为什么不对 waitStartTime 进行初始化了，我们需要利用它在这里进行条件判断。\n第 72 行将此 waiter 加入到队列，如果是首次，加入到队尾，先进先出。如果不是首次，那么加入到队首，这样等待最久的 goroutine 优先能够获取到锁。此 goroutine 会进行休眠。\n第 74 行判断此 goroutine 是否处于饥饿状态。注意，执行这一句的时候，它已经被唤醒了，因为不被唤醒，就会一直阻塞在第 72 行代码。\n第 77 行到第 88 行是对锁处于饥饿状态下的一些处理。\nif old\u0026amp;(mutexLocked|mutexWoken) != 0 || old\u0026gt;\u0026gt;mutexWaiterShift == 0 { throw(\u0026#34;sync: inconsistent mutex state\u0026#34;) } 第 78 行代码检查非法状态，主要检查：锁是否已被占用、是否存在唤醒标记、等待者数量是否为 0\ndelta := int32(mutexLocked - 1\u0026lt;\u0026lt;mutexWaiterShift) 第 82 行，有点绕，对其操作分解\nmutexLocked：设置锁为“已占用”状态（例如 0x1）； -1\u0026lt;\u0026lt;mutexWaiterShift：将等待者数量减 1。 示例：假设 mutexWaiterShift 为 3：\ndelta = 0x1 - 8（即 0x1 表示加锁，-8 表示等待者减 1）； 最终 delta 的二进制为 0x1 - 0x8 = -7（十进制）。 if !starving || old\u0026gt;\u0026gt;mutexWaiterShift == 1 { delta -= mutexStarving // 最后一个waiter或者已经不饥饿了，清除饥饿标记 } 第 83 - 85 行，设置标志，在没有其它的 waiter 或者此 goroutine 等待还没超过 1 毫秒，则会将 Mutex 转为正常状态。\natomic.AddInt32(\u0026amp;m.state, delta) break 第 86 - 87 行则是将这个标识应用到 state 字段上。并退出循环。\n释放锁 # 释放锁（Unlock）时调用的 Unlock 的 fast path 代码量较少且逻辑简单（通过原子操作快速释放锁），所以我们主要看 unlockSlow 方法就行。\n如果 Mutex 处于饥饿状态，第 123 行直接唤醒等待队列中的 waiter。\n如果 Mutex 处于正常状态，如果没有 waiter，或者已经有在处理的情况了，那么释放就好，不做额外的处理（第 112 行到第 114 行）。\n否则，waiter 数减 1，mutexWoken 标志设置上，通过 CAS 更新 state 的值（第 115 行到第 119 行）。\n思考题：等待一个 mutex 的 goroutine 数最大数量 # 单从程序来看，可以支持 1 \u0026lt;\u0026lt; (32 - 3) - 1 ，约 0.5Billion 个\n其中 32 为 state 的类型 int32，3 位 waiter 字段的 shift，考虑到实际 goroutine 初始化的空间为2K，0.5Billin*2K 达到了 1TB，单从内存空间来说已经要求极高了。\nMutex 易错场景 # Lock / Unlock 不是成对出现 # 缺少 Unlock 的场景，常见的有三种情况：\n代码中有太多的 if-else 分支，可能在某个分支中漏写了 Unlock； 在重构的时候把 Unlock 给删除了； Unlock 误写成了 Lock。 缺少 Lock 的场景，这就很简单了，一般来说就是误操作删除了 Lock。这样直接 Unlock 一个为加锁的 Mutex 会 panic。\n综上，我们在可以在 Lock() 之后直接调用 defer Unlock()\nfunc foo() { var mu sync.Mutex mu.Lock() defer mu.Unlock() fmt.Println(\u0026#34;hello world!\u0026#34;) } Copy 已使用的 Mutex # Package sync 的同步原语在使用后是不能复制的。Mutex 是一个有状态的对象，它的 state 字段记录这个锁的状态。如果你要复制一个已经加锁的 Mutex 给一个新的变量，那么新的刚初始化的变量居然被加锁了，这显然不符合你的期望，因为你期望的是一个零值的 Mutex。关键是在并发环境下，你根本不知道要复制的 Mutex 状态是什么，因为要复制的 Mutex 是由其它 goroutine 并发访问的，状态可能总是在变化。\n例如：\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; ) type Counter struct { sync.Mutex Count int } func main() { var c Counter c.Lock() defer c.Unlock() c.Count++ foo(c) } // 这里 Counter 的参数是通过复制的方式传入的. func foo(c Counter) { c.Lock() defer c.Unlock() fmt.Println(\u0026#34;in foo\u0026#34;) } 第 18 行在调用 foo 函数的时候，调用者会复制 Mutex 变量 c 作为 foo 函数的参数，不幸的是，复制之前已经使用了这个锁，这就导致，复制的 Counter 是一个带状态 Counter。\n如果是因为这样的原因导致的死锁问题，可以使用 vet 工具，把检查写在 Makefile 文件中，在持续集成的时候跑一跑，这样可以及时发现问题，及时修复。我们可以使用 go vet 检查这个 Go 文件：\n$ go vet copy.go # command-line-arguments # [command-line-arguments] ./copy.go:18:6: call of foo copies lock value: command-line-arguments.Counter ./copy.go:22:12: foo passes lock by value: command-line-arguments.Counter vet 工具是通过 copylock 分析器静态分析实现的。这个分析器会分析函数调用、range 遍历、复制、声明、函数返回值等位置，有没有锁的值 copy 的情景，以此来判断有没有问题。可以说，只要是实现了 Locker 接口，就会被分析。我们看到，下面的代码就是确定什么类型会被分析，其实就是实现了 Lock/Unlock 两个方法的 Locker 接口：\nvar lockerType *types.Interface // Construct a sync.Locker interface type. func init() { nullary := types.NewSignature(nil, nil, nil, false) // func() methods := []*types.Func{ types.NewFunc(token.NoPos, nil, \u0026#34;Lock\u0026#34;, nullary), types.NewFunc(token.NoPos, nil, \u0026#34;Unlock\u0026#34;, nullary), } lockerType = types.NewInterface(methods, nil).Complete() } 其实，有些没有实现 Locker 接口的同步原语（比如 WaitGroup），也能被分析。\n重入 # 重入锁：当一个线程获取锁时，如果没有其它线程拥有这个锁，那么，这个线程就成功获取到这个锁。之后，如果其它线程再请求这个锁，就会处于阻塞等待的状态。但是，如果拥有这把锁的线程再请求这把锁的话，不会阻塞，而是成功返回，所以叫可重入锁（有时候也叫做递归锁）。只要你拥有这把锁，你可以可着劲儿地调用，比如通过递归实现一些算法，调用者不会阻塞或者死锁。\n需要注意的是：Mutex 不是可重入锁，想想也不奇怪，因为 Mutex 的实现中没有记录哪个 goroutine 拥有这把锁。理论上，任何 goroutine 都可以随意地 Unlock 这把锁，所以没办法计算重入条件。\n看一个重入 Mutex 的例子：\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; ) func foo(l sync.Locker) { fmt.Println(\u0026#34;in foo\u0026#34;) l.Lock() bar(l) l.Unlock() } func bar(l sync.Locker) { l.Lock() fmt.Println(\u0026#34;in bar\u0026#34;) l.Unlock() } func main() { l := \u0026amp;sync.Mutex{} foo(l) } 写完这个 Mutex 重入的例子后，运行一下，你会发现类似下面的错误。程序一直在请求锁，但是一直没有办法获取到锁，结果就是 Go 运行时发现死锁了，没有其它地方能够释放锁让程序运行下去，你通过下面的错误堆栈信息就能定位到哪一行阻塞请求锁：\n$ go run ReentrantLock.go in foo fatal error: all goroutines are asleep - deadlock! goroutine 1 [sync.Mutex.Lock]: internal/sync.runtime_SemacquireMutex(0xc000078e60?, 0xa?, 0x559660?) /home/going/go/go1.24.0/src/runtime/sema.go:95 +0x25 internal/sync.(*Mutex).lockSlow(0xc0000100f0) /home/going/go/go1.24.0/src/internal/sync/mutex.go:149 +0x15d internal/sync.(*Mutex).Lock(...) /home/going/go/go1.24.0/src/internal/sync/mutex.go:70 sync.(*Mutex).Lock(0xc0000100f8?) /home/going/go/go1.24.0/src/sync/mutex.go:46 +0x2c main.bar({0x4da100, 0xc0000100f0}) /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.3mutex3/ReentrantLock.go:16 +0x22 main.foo({0x4da100, 0xc0000100f0}) /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.3mutex3/ReentrantLock.go:11 +0x73 main.main() /home/going/golang/src/github.com/LiangNing7/conc/mutex/1.3mutex3/ReentrantLock.go:23 +0x29 exit status 2 实现可重入锁 # 可重入锁的关键就是，实现的锁要能记住当前是哪个 goroutine 持有这个锁。有两个方案：\n方案一：通过 hacker 的方式获取到 goroutine id，记录下获取锁的 goroutine id，它可以实现 Locker 接口。 方案二：调用 Lock/Unlock 方法时，由 goroutine 提供一个 token，用来标识它自己，而不是我们通过 hacker 的方式获取到 goroutine id，但是，这样一来，就不满足 Locker 接口了。 goroutine id # 该方案的关键是获取 goroutine id，方式有两种，简单方式和 hacker 方式。\n简单方式是直接通过 runtime.Stack 方法获取栈帧信息，栈帧信息里包含 goroutine id。runtime.Stack 方法可以获取当前的 goroutine 信息，第二个参数为 true 会输出所有的 goroutine 信息，信息的格式如下：\ngoroutine 1 [running]: main.main() ....../main.go:19 +0xb1 第一行格式为 goroutine xxx，其中 xxx 就是 goroutine id，你只要解析出这个 id 即可。解析的方法可以采用下面的代码：\nfunc GoID() int { var buf [64]byte n := runtime.Stack(buf[:], false) // 得到id字符串 idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), \u0026#34;goroutine \u0026#34;))[0] id, err := strconv.Atoi(idField) if err != nil { panic(fmt.Sprintf(\u0026#34;cannot get goroutine id: %v\u0026#34;, err)) } return id } hacker 方式首先，我们获取运行时的 g 指针，反解出对应的 g 的结构。每个运行的 goroutine 结构的 g 指针保存在当前 goroutine 的一个叫做 TLS 对象中。\n第一步：我们先获取到 TLS 对象；\n第二步：再从 TLS 中获取 goroutine 结构的 g 指针；\n第三步：再从 g 指针中取出 goroutine id。\n注意，不同 Go 版本的 goroutine 的结构可能不同，需要根据 Go 的不同版本进行调整。也可以使用第三方包petermattis/goid。\n$ go get github.com/petermattis/goid 知道了如何获取 goroutine id，接下来就是最后的关键一步了，我们实现一个可以使用的可重入锁：\npackage mutex import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; \u0026#34;sync/atomic\u0026#34; \u0026#34;github.com/petermattis/goid\u0026#34; ) // RecursiveMutex 包装了一个 Mutex，实现可重入. type RecursiveMutex struct { sync.Mutex owner int64 // 当前持有锁的 goroutine id. recursion int32 // 这个 goroutine 重入次数. } func (m *RecursiveMutex) Lock() { gid := goid.Get() // 如果当前持有锁的 goroutine 就是这次调用的 goroutine,说明是重入. if atomic.LoadInt64(\u0026amp;m.owner) == gid { m.recursion++ return } m.Mutex.Lock() // 获得锁的 goroutine 第一次调用，记录下它的 goroutine id, // 调用次数加 1. atomic.StoreInt64(\u0026amp;m.owner, gid) m.recursion = 1 } func (m *RecursiveMutex) Unlock() { gid := goid.Get() // 非持有锁的 goroutine 尝试释放锁，错误的使用. if atomic.LoadInt64(\u0026amp;m.owner) != gid { panic(fmt.Sprintf(\u0026#34;wrong the owner(%d): %d!\u0026#34;, m.owner, gid)) } // 调用次数减 1. m.recursion-- if m.recursion != 0 { // 如果这个 goroutine 还没完全释放，则直接返回. return } // 此时 goroutine 的最后一次调用，需要释放锁. atomic.StoreInt64(\u0026amp;m.owner, -1) m.Mutex.Unlock() } 上面这段代码你可以拿来即用。我们一起来看下这个实现，真是非常巧妙，它相当于给 Mutex 打一个补丁，解决了记录锁的持有者的问题。可以看到，我们用 owner 字段，记录当前锁的拥有者 goroutine 的 id；recursion 是辅助字段，用于记录重入的次数。有一点，我要提醒你一句，尽管拥有者可以多次调用 Lock，但是也必须调用相同次数的 Unlock，这样才能把锁释放掉。这是一个合理的设计，可以保证 Lock 和 Unlock 一一对应。\ntoken # 方案一是用 goroutine id 做 goroutine 的标识，我们也可以让 goroutine 自己来提供标识。不管怎么说，Go 开发者不期望你利用 goroutine id 做一些不确定的东西，所以，他们没有暴露获取 goroutine id 的方法。\n下面的代码是第二种方案。调用者自己提供一个 token，获取锁的时候把这个 token 传入，释放锁的时候也需要把这个 token 传入。通过用户传入的 token 替换方案一中 goroutine id，其它逻辑和方案一一致。\npackage mutex import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; \u0026#34;sync/atomic\u0026#34; ) // Token 方式的递归锁. type TokenRecursiveMutex struct { sync.Mutex token int64 recursion int32 } // 请求锁，需要传入 token. func (m *TokenRecursiveMutex) Lock(token int64) { // 如果传入的token和持有锁的token一致，说明是递归调用 if atomic.LoadInt64(\u0026amp;m.token) == token { m.recursion++ return } m.Mutex.Lock() // 传入的 token 不一致 // 抢到锁之后记录这个 token. atomic.StoreInt64(\u0026amp;m.token, token) m.recursion = 1 } // 释放锁. func (m *TokenRecursiveMutex) Unlock(token int64) { // 释放其他 token 持有的锁 if atomic.LoadInt64(\u0026amp;m.token) != token { panic(fmt.Sprintf(\u0026#34;wrong the owner(%d): %d!)\u0026#34;, m.token, token)) } // 当前持有这个锁的 token 释放锁. m.recursion-- if m.recursion != 0 { return } // 没有递归调用了，释放锁. atomic.StoreInt64(\u0026amp;m.token, 0) m.Mutex.Unlock() } 死锁 # 两个或两个以上的进程（或线程，goroutine）在执行过程中，因争夺共享资源而处于一种互相等待的状态，如果没有外部干涉，它们都将无法推进下去，此时，我们称系统处于死锁状态或系统产生了死锁。\n我们来分析一下死锁产生的必要条件。如果你想避免死锁，只要破坏这四个条件中的一个或者几个，就可以了。\n互斥： 至少一个资源是被排他性独享的，其他线程必须处于等待状态，直到资源被释放。【这个条件不能被破坏，甚至还需要加以保护】 持有和等待：goroutine 持有一个资源，并且还在请求其它 goroutine 持有的资源，也就是咱们常说的“吃着碗里，看着锅里”的意思。 不可剥夺：资源只能由持有它的 goroutine 来释放。 环路等待：一般来说，存在一组等待进程，P={P1，P2，…，PN}，P1 等待 P2 持有的资源，P2 等待 P3 持有的资源，依此类推，最后是 PN 等待 P1 持有的资源，这就形成了一个环路等待的死结。 Go 运行时，有死锁探测的功能，能够检查出是否出现了死锁的情况，如果出现了，这个时候你就需要调整策略来处理了。\nMutex 扩展 # TryLock # TryLock()，当一个 goroutine 调用这个 TryLock 方法请求锁的时候，如果这把锁没有被其他 goroutine 所持有，那么，这个 goroutine 就持有了这把锁，并返回 true；如果这把锁已经被其他 goroutine 所持有，或者是正在准备交给某个被唤醒的 goroutine，那么，这个请求锁的 goroutine 就直接返回 false，不会阻塞在方法调用上。\n如下图所示，如果 Mutex 已经被一个 goroutine 持有，调用 Lock 的 goroutine 阻塞排队等待，调用 TryLock 的 goroutine 直接得到一个 false 返回。\n在实际开发中，如果要更新配置数据，我们通常需要加锁，这样可以避免同时有多个 goroutine 并发修改数据。有的时候，我们也会使用 TryLock。这样一来，当某个 goroutine 想要更改配置数据时，如果发现已经有 goroutine 在更改了，其他的 goroutine 调用 TryLock，返回了 false，这个 goroutine 就会放弃更改。\n基于 Mutex 扩展 TryLock # 代码如下：\npackage mutex import ( \u0026#34;sync\u0026#34; \u0026#34;sync/atomic\u0026#34; \u0026#34;unsafe\u0026#34; ) // 复制 Mutex 定义的常量. const ( mutexLocked = 1 \u0026lt;\u0026lt; iota // 1, 加锁标志位置 mutexWoken // 2, 唤醒标志位置. mutexStarving // 4, 锁饥饿标识位置. mutexWaiterShift = iota // 标识 waiter 的起始 bit 位置。 ) // 扩展一个 Mutex 结构. type Mutex struct { sync.Mutex } // TryLock() 尝试获取锁. func (m *Mutex) TryLock() bool { // 如果能成功抢到锁. if atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(\u0026amp;m.Mutex)), 0, mutexLocked) { return true } // 如果处于唤醒、加锁或者饥饿状态，这次请求就不参与了，返回 false. old := atomic.LoadInt32((*int32)(unsafe.Pointer(\u0026amp;m.Mutex))) if old\u0026amp;(mutexLocked|mutexStarving|mutexWoken) != 0 { return false } // 尝试在竞争状态加锁. new := old | mutexLocked return atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(\u0026amp;m.Mutex)), old, new) } 第 25 行是一个 fast path，如果幸运，没有其他 goroutine 争这把锁，那么，这把锁就会被这个请求的 goroutine 获取，直接返回。\n如果锁已经被其他 goroutine 所持有，或者被其他唤醒的 goroutine 准备持有，那么，就直接返回 false，不再请求，代码逻辑在第 30 行。\n如果没有被持有，也没有其它唤醒的 goroutine 来竞争锁，锁也不处于饥饿状态，就尝试获取这把锁（第 37 行），不论是否成功都将结果返回。因为，这个时候，可能还有其他的 goroutine 也在竞争这把锁，所以，不能保证成功获取这把锁。\n测试如下：\npackage mutex import ( \u0026#34;fmt\u0026#34; \u0026#34;math/rand\u0026#34; \u0026#34;testing\u0026#34; \u0026#34;time\u0026#34; ) func TestTryLock(t *testing.T) { var mu Mutex go func() { mu.Lock() time.Sleep(time.Duration(rand.Intn(2)) * time.Second) mu.Unlock() }() time.Sleep(time.Second) ok := mu.TryLock() if ok { fmt.Println(\u0026#34;got the lock\u0026#34;) // do something. mu.Unlock() return } // 没有获取到 fmt.Println(\u0026#34;can\u0026#39;t get the lock\u0026#34;) } 程序运行时会启动一个 goroutine 持有这把我们自己实现的锁，经过随机的时间才释放。主 goroutine 会尝试获取这把锁。如果前一个 goroutine 一秒内释放了这把锁，那么，主 goroutine 就有可能获取到这把锁了，输出“got the lock”，否则没有获取到也不会被阻塞，会直接输出“can\u0026rsquo;t get the lock”。\n获取等待者的数量指标 # Mutex 的数据结构，如下面的代码所示。它包含两个字段，state 和 sema。前四个字节（int32）就是 state 字段。\ntype Mutex struct { state int32 sema uint32 } Mutex 结构中的 state 字段有很多个含义，通过 state 字段，你可以知道锁是否已经被某个 goroutine 持有、当前是否处于饥饿状态、是否有等待的 goroutine 被唤醒、等待者的数量等信息。但是，state 这个字段并没有暴露出来，所以，我们需要想办法获取到这个字段，并进行解析。\n怎么获取未暴露的字段呢？很简单，我们可以通过 unsafe 的方式实现。\n示例如下：\npackage mutex import ( \u0026#34;sync/atomic\u0026#34; \u0026#34;unsafe\u0026#34; ) // Count 持有和等待这把锁的 goroutine 的总数 func (m *Mutex) Count() int { // 获取 state 字段的值. v := atomic.LoadInt32((*int32)(unsafe.Pointer(\u0026amp;m.Mutex))) v = v\u0026gt;\u0026gt;mutexWaiterShift + (v \u0026amp; mutexLocked) return int(v) } // IsLocked 锁是否被持有. func (m *Mutex) IsLocked() bool { state := atomic.LoadInt32((*int32)(unsafe.Pointer(\u0026amp;m.Mutex))) return state\u0026amp;mutexLocked == mutexLocked } // IsWoken 是否有等待者被唤醒. func (m *Mutex) IsWoken() bool { state := atomic.LoadInt32((*int32)(unsafe.Pointer(\u0026amp;m.Mutex))) return state\u0026amp;mutexLocked == mutexWoken } // IsStarving 锁是否饥饿. func (m *Mutex) IsStarving() bool { state := atomic.LoadInt32((*int32)(unsafe.Pointer(\u0026amp;m.Mutex))) return state\u0026amp;mutexStarving == mutexStarving } Count()函数通过 atomic.LoadInt32((*int32)(unsafe.Pointer(\u0026amp;m.Mutex)))得到 state 字段的值，由于该字段末尾的 3 个 bit 表示其他含义，右移之后，就得到了当前等待者的数量.然后加上 当前持有者的数量就得到了当前持有和等待这把锁的 goroutine 的总数。\n其他函数也是通过如上处理得到。\n使用 Mutex 实现一个线程安全的队列 # 比如队列，我们可以通过 Slice 来实现，但是通过 Slice 实现的队列不是线程安全的，出队（Dequeue）和入队（Enqueue）会有 data race 的问题。这个时候，Mutex 就要隆重出场了，通过它，我们可以在出队和入队的时候加上锁的保护。\npackage mutex import ( \u0026#34;sync\u0026#34; ) type SliceQueue struct { data []any mu sync.Mutex } func NewSlcieQueue(n int) (q *SliceQueue) { return \u0026amp;SliceQueue{data: make([]any, 0, n)} } // Enqueue 把值放在队尾. func (q *SliceQueue) Enqueue(v any) { q.mu.Lock() defer q.mu.Unlock() q.data = append(q.data, v) } // Dequeue 去掉对头并返回. func (q *SliceQueue) Dequeue() any { q.mu.Lock() defer q.mu.Unlock() if len(q.data) == 0 { q.mu.Unlock() return nil } v := q.data[0] q.data = q.data[1:] return v } 因为标准库中没有线程安全的队列数据结构的实现，所以，你可以通过 Mutex 实现一个简单的队列。通过 Mutex 我们就可以为一个非线程安全的 data any 实现线程安全的访问。\n总结 # ","date":"2026年4月27日","externalUrl":null,"permalink":"/posts/go-concurrency/01%E4%B8%A8mutex/","section":"Posts","summary":"","title":"Go Mutex 详解","type":"posts"},{"content":"","date":"2026年4月27日","externalUrl":null,"permalink":"/series/go-%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/","section":"专题系列","summary":"","title":"Go 并发编程","type":"series"},{"content":"","date":"2026年4月27日","externalUrl":null,"permalink":"/series/k8s-api-server/","section":"专题系列","summary":"","title":"K8s API Server 源码剖析","type":"series"},{"content":"API Server 可以分为 API 和 Server 两个部分来看待：\nServer 的主要目标是提供一个以 go-restful 为基础的 Web 服务器，在设计与实现时不会受 API 的结构影响，每个 API 在 Server 看来都是一组端点、一组 Route，Server 提供方法把 API 转化为 WebService 和 Route； API 可以视作信息的容器，是用户向 Server 提交信息的载体。它专注在自己具有的属性、属性默认值、各个版本之间的转换方式等，而不应该受下层 Server 的设计影响。对 API Server 能力的扩展主要是指不断引入新的 API，让 API Server 服务的范畴得到扩展， 如果说每次引入新的 API 都需要对 Server 本身的代码进行改动，则这种设计显然是不太理想的。 二者的独立促成两个方面的灵活性：第一，设计时不互相影响；第二，扩展时各自可变。但这要求深度解耦 API 和 Server，Scheme 是 Kubernetes 达到这一目的的重要工具。\nScheme 就像一个类型注册中心：\nAPI 向它声明\u0026quot;我是谁\u0026quot;（类型、版本、转换规则） Server 向它查询\u0026quot;你是谁\u0026quot;（获取类型元信息生成路由） 两者通过 Scheme 通信，永远不直接依赖对方 1. 注册表的内容 # Scheme 技术上由一个 Go 结构体代表，代码如下所示：\nScheme 的数据结构如下图：\nScheme 数据结构定义的代码示例如下。\ntype Scheme struct { gvkToType map[schema.GroupVersionKind]reflect.Type typeToGVK map[reflect.Type][]schema.GroupVersionKind unversionedTypes map[reflect.Type]schema.GroupVersionKind unversionedKinds map[string]reflect.Type fieldLabelConversionFuncs map[schema.GVK]FieldLabelConversionFunc defaulterFuncs map[reflect.Type]func(interface{}) converter *conversion.Converter versionPriority map[string][]string observedVersions []schema.GroupVersion schemeName string } Scheme 数据结构定义代码中部分字段的说明如下：\ngvkToType：存储 GVK 与 Type 的映射关系。map 类型，key 为 schema.GroupVersionKind 类型，即 GVK 三元组，value 为 reflect.Type 类型。即 GVK 唯一对应的 Go Struct 数据结构。通过该字段中存储的信息，只要知道 GVK 信息，就可以通过反射创建出对应 的结构体以接收数据。\ntypeToGVK：存储 Type 与 GVK 的映射关系，用于根据 Go Struct 反向查询该数据所属的 GVK。map 类型，key 为 reflect.Type 类型，value 为 GVK 列表，一个 Type 会对应一个或多个 GVK。因为即使一个 Kind 在不同的版本甚至不同的组中，但其结构体字段没有改变，所以获取结构体得到的 reflect.Type 就是一样的。例如，__internal 版本的 extensions.Ingress 、 networking.k8s.io.Ingress ，它们的 GVK 对应同一个 Type； CreateOptions 资源对象有 49 个 GVK，在不同组、版本中的 reflect.Type 都是一样的。\nunversionedTypes：存储 UnversionedType 与 GVK 的映射关系。key 为 reflect.Type 类型， value 为单个 GVK，这个字段与 typeToGVK 有所不同，value 不再是 GVK 列表，这也正与 Unversioned 的概念相对应，该资源类型永远只保留一个版本，无须进行版本化管 理。目前对应的只有 5 个 unversionedTypes，Go Struct 分别为 metav1.Status、 metav1.APIVersions、metav1.APIGroupList、metav1.APIGroup、metav1.APIResourceList。\nunversionedKinds：存储 Kind 名称与 UnversionedType 的映射关系。key 为字符串类型， 存储的是 5 个 unversionedType 对应的 Kind，为什么这里不使用 GVK 作为 key 的类型 呢？因为 5 个 unversionedType 的组都是\u0026quot;\u0026quot;，版本都是 v1，所以无须重复指定组和版本， 只需要将 Kind 作为 key，对应的 5 个 Kind 分别是 Status、APIVersions、APIGroupList、 APIGroup、APIResourceList。\n示例：\n// unversionedKinds 的实际内容 scheme.unversionedKinds = map[string]reflect.Type{ // Key Value (Go 类型的反射 Type) // ========================================== \u0026#34;Status\u0026#34;: reflect.TypeOf(metav1.Status{}), \u0026#34;APIVersions\u0026#34;: reflect.TypeOf(metav1.APIVersions{}), \u0026#34;APIGroupList\u0026#34;: reflect.TypeOf(metav1.APIGroupList{}), \u0026#34;APIGroup\u0026#34;: reflect.TypeOf(metav1.APIGroup{}), \u0026#34;APIResourceList\u0026#34;: reflect.TypeOf(metav1.APIResourceList{}), } GVK 与 reflect.Type 是多对一的关系，如下图：\nfieldLabelConversionFuncs：将 Version 和 Resource 映射到对应的函数中，将该版本中的资源字段标签转换为内部版本字段。\ndefaulterFuncs：存储设置对象默认值的函数。\nconverter：转换器，存储所有已注册的转换函数，在初始化时会初始化部分默认转换函 数，是实现资源版本转换的关键。\nversionPriority：API 组到这些组中有序版本列表的映射，指示这些版本在 Scheme 中注册的默认优先级。\nobservedVersions：GV 数组类型，跟踪在资源类型注册期间看到的版本的顺序，不包括内部版本，可用于快速查询某个组或版本在 Scheme 中是否注册过。\nschemeName：Scheme 的名称，没有实质含义，主要用于标识该 Scheme，如在出错时 打印相关信息。\n2. 注册表的构建 # 注册表内容的填入是在 Server 启动过程中完成的，后续运行时不会更改。整个程序设计非常优雅，体现了 Go 语言优良特性。\n2.1 Builder 模式 # 注册表的填充是遵循了构造者模式，经典构造者模式角色关系如图：\n该模式中有 4 个角色，其中产品（Product）在本模式中没有逻辑不必细说，ConcreteBuilder 是 Builder 这个抽象角色的具体实现，所以二者扮演同一角色。\nDirector 和 Builder 各有责任：\nDirector：通过定义产品零部件决定规格。例如同品牌同版本的手机，存储大小不同造就出不同的产品，市场售价会不同。Director 决定最终产品包含哪些零部件以及规格，但它不负责具体加工过程。 Builder 和 ConcreteBuilder：负责把产品零部件组合成产品，掌握组合流程。Director 决定给什么样的零件，Builder 负责把它们按照正确的方式装起来。一般来说，在构建一个 Builder 的时候，需要给足创建一个产品的必备零部件， 不能少掉哪个，否则 Builder 肯定不能工作；而那些可选零部件会通过 Builder 暴露的方法去指定。 2.2 注册表的 Builder # SchemeBuilder 扮演了构造者模式中的 Builder 角色。\n// 代码: staging/k8s.io/apimachinery/pkg/runtime/scheme_builder.go package runtime // SchemeBuilder collects functions that add things to a scheme. It\u0026#39;s to allow // code to compile without explicitly referencing generated types. You should // declare one in each package that will have generated deep copy or conversion // functions. type SchemeBuilder []func(*Scheme) error // AddToScheme applies all the stored functions to the scheme. A non-nil error // indicates that one function failed and the attempt was abandoned. func (sb *SchemeBuilder) AddToScheme(s *Scheme) error { for _, f := range *sb { if err := f(s); err != nil { return err } } return nil } // Register adds a scheme setup function to the list. func (sb *SchemeBuilder) Register(funcs ...func(*Scheme) error) { for _, f := range funcs { *sb = append(*sb, f) } } // NewSchemeBuilder calls Register for you. func NewSchemeBuilder(funcs ...func(*Scheme) error) SchemeBuilder { var sb SchemeBuilder sb.Register(funcs...) return sb } 以上代码定义了 SchemeBuilder，一共就 33 行，做了两件事情：第一定义类型 SchemeBuilder；第二为该类型加方法。\nSchemeBuilder 被定义为一个数组，方法的数组，在 Go 中方法是可以被当作变量使用的。这个数组里的方法的签名有要求：只能有一个输入参数，其类型为 Scheme 的引用；返回值类型为 error，只有在出错时返回值才非 nil。\nSchemeBuilder 定义为 []func(*Scheme) error 的设计是一个延迟注册模式，带来以下核心优势：解决代码生成和手写代码的分离问题。\n在 Go 中， 任何类型都可以作为方法的接收器，这很像为该类型添加方法，而在面向对象语言中，有类 / 接口才能有方法，这也算 Go 特色。上述代码为 SchemeBuilder 数组定义了三个方法：\nNewSchemeBuilder() 方法：Builder 的工厂方法，通过它获取一个 Builder 实例。设计模式中的 Builder 构造函数接收那些必须的零部件，所以这个方法提供了一个输入参数： 可以放入 SchemeBuilder 数组的函数。SchemeBuilder 使用的“零部件”都是以函数形式存在的； Register() 方法：把可选零部件放入 SchemeBuilder 数组中。同样的，这里的“零部件”是函数。 AddToScheme() 方法：用零部件构造出产品——一个 Scheme 实例。它相当于经典模式中 Builder 的 GetProduct 方法：当零部件都给全了，调用这个方法，用这些零部件填充本方法的传入参数得到完整的 Scheme 实例。其内部实现很简洁，是对 SchemeBuilder 数组内保存的“零部件”做一个遍历，如上所述它们是一个个函数，遍历它们能干什么？自然是调用，以本方法获得的实际参数 scheme 为入参去调用这些函数。而这些函数内部会把自己掌握的信息交给该 Scheme 实例，完成 Scheme 实例的构建。 2.3 注册表的 Director-register.go # 有了 Builder，接下来看模式中的 Director 是怎么实现的。模式中的 Director 会决定给什么“零部件”，前面看到 SchemeBuilder 接收的零部件是以 Scheme 指针为形式参数的函数，那么在 Director 中我们就应该能看到这些函数。\nKubernetes Scheme 构建中的 Director 有多个，每个 API Group 的每一个内部版本和全部外部版本各有一个 Director，这和模式中 Director 的定位是吻合的：每个 API 版本中的 API 逻辑上都是不同的，所以不能共用同一个。在 API Server 的实现中，这个角色由不同的 register.go 文件来承担。\n2.3.1 内部版本 # 以 apps 组中内部版本的 register.go 文件为例，一窥究竟。代码如下所示：\n// 代码: pkg\\apis\\apps\\register.go package apps import ( \u0026#34;k8s.io/apimachinery/pkg/runtime\u0026#34; \u0026#34;k8s.io/apimachinery/pkg/runtime/schema\u0026#34; \u0026#34;k8s.io/kubernetes/pkg/apis/autoscaling\u0026#34; ) var ( // SchemeBuilder stores functions to add things to a scheme. // 注册表 Builder SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) //要点① // AddToScheme applies all stored functions t oa scheme. // 在本包上暴露 Builder 的 AddToScheme() 方法 AddToScheme = SchemeBuilder.AddToScheme //要点④ ) // GroupName is the group name use in this package // 本 package 内用的组名 const GroupName = \u0026#34;apps\u0026#34; // SchemeGroupVersion is group version used to register these objects // SchemeGroupVersion 是注册这些对象时使用的组和版本 var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal} // Kind takes an unqualified kind and returns a Group qualified GroupKind // 由 API 种类获取 GroupKind func Kind(kind string) schema.GroupKind { return SchemeGroupVersion.WithKind(kind).GroupKind() } // Resource takes an unqualified resource and returns a Group qualified GroupResource // 由 Resource 获取一个 GroupResource func Resource(resource string) schema.GroupResource { return SchemeGroupVersion.WithResource(resource).GroupResource() } // Adds the list of known types to the given scheme. // 将一列类型添加到给定的注册表内 func addKnownTypes(scheme *runtime.Scheme) error { //要点② // TODO this will get cleaned up with the scheme types are fixed scheme.AddKnownTypes(SchemeGroupVersion, \u0026amp;DaemonSet{}, //要点③ \u0026amp;DaemonSetList{}, \u0026amp;Deployment{}, \u0026amp;DeploymentList{}, \u0026amp;DeploymentRollback{}, \u0026amp;autoscaling.Scale{}, \u0026amp;StatefulSet{}, \u0026amp;StatefulSetList{}, \u0026amp;ControllerRevision{}, \u0026amp;ControllerRevisionList{}, \u0026amp;ReplicaSet{}, \u0026amp;ReplicaSetList{}, ) return nil } 关注上述代码要点①处 SchemeBuilder 类型实例的构造和要点②处 addKnownTypes() 方法。\nBuilder 实例的获取借助前面介绍的 NewSchemeBuilder() 方法，它的唯一形参类型是 Scheme 指针，在 register.go 中调用该方法时使用的实际参数是 addKnownTypes() 方法，这个方法把当前这个 API Group 中的所有 API 都交给目标 Scheme 实例：这里目标 Scheme 实例是通过参数传进来的，Group 中的 API 则是以它们的 Go 结构体实例表示的，见要点③及其下方代码。\nScheme 类型的 AddKnownTypes() 方法会填充注册表中 GVK 和 API 基座结构体的双向映射表。上面已经看到 register.go 中的 addKnownTypes() 方法被交给了 Builder，当 Builder 的 AddToScheme 方法被调用时，它就会被调用。Scheme 类型的 AddKnowTypes() 方法源代码如下：\n// 代码: staging/k8s.io/apimachinery/pkg/runtime/scheme.go // AddKnownTypes registers all types passed in \u0026#39;types\u0026#39; as being members of version \u0026#39;version\u0026#39;. // All objects passed to types should be pointers to structs. The name that go reports for // the struct becomes the \u0026#34;kind\u0026#34; field when encoding. Version may not be empty - use the // APIVersionInternal constant if you have a type that does not have a formal version. func (s *Scheme) AddKnownTypes(gv schema.GroupVersion, types ...Object) { s.addObservedVersion(gv) for _, obj := range types { t := reflect.TypeOf(obj) if t.Kind() != reflect.Pointer { panic(\u0026#34;All types must be pointers to structs.\u0026#34;) } t = t.Elem() s.AddKnownTypeWithName(gv.WithKind(t.Name()), obj) } } 总结一下：只要 Builder 的 AddToScheme 方法被调用，当前 API Group 的内部版本 API 与 GVK 映射关系会被填充到目标 Scheme 实例中。\n其次，register.go 代码中的要点④，这一行把刚刚获得的 Builder 实例的 AddToScheme() 方法暴露为当前包（在本例中就是包 apps）上的同名方法 AddToScheme()，这样工程内其它代码就可以通过调用 apps 包下的 AddToScheme() 方法间接触发 Builder 填充一个给定的 Scheme 实例。这很重要，因为到目前为止，前面的所有工作只是为某 API 版本制作一个 Builder 的实例，并没有真正去填充某个 Scheme，甚至连目标 Scheme 实例是谁都不知道，需要后期通过 apps.AddToScheme() 方法的调用触发对某个 Scheme 的填充。\n上例示例代码的 register.go 对应的是内部版本，对于 apps 这个 group 来说源码位于 pkg/apis/apps/register.go；而外部版本 register.go 极为类似，不同的是它位于 staging/src/k8s.io/api/apps/v1/register.go 中。\n2.3.2 外部版本 # package v1 import ( metav1 \u0026#34;k8s.io/apimachinery/pkg/apis/meta/v1\u0026#34; \u0026#34;k8s.io/apimachinery/pkg/runtime\u0026#34; \u0026#34;k8s.io/apimachinery/pkg/runtime/schema\u0026#34; ) // GroupName is the group name use in this package const GroupName = \u0026#34;apps\u0026#34; // SchemeGroupVersion is group version used to register these objects var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: \u0026#34;v1\u0026#34;} // 要点① // 要点② // Resource takes an unqualified resource and returns a Group qualified GroupResource func Resource(resource string) schema.GroupResource { return SchemeGroupVersion.WithResource(resource).GroupResource() } var ( // TODO: move SchemeBuilder with zz_generated.deepcopy.go to k8s.io/api. // localSchemeBuilder and AddToScheme will stay in k8s.io/kubernetes. SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) localSchemeBuilder = \u0026amp;SchemeBuilder // 要点③ AddToScheme = localSchemeBuilder.AddToScheme ) // Adds the list of known types to the given scheme. func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, \u0026amp;Deployment{}, \u0026amp;DeploymentList{}, \u0026amp;StatefulSet{}, \u0026amp;StatefulSetList{}, \u0026amp;DaemonSet{}, \u0026amp;DaemonSetList{}, \u0026amp;ReplicaSet{}, \u0026amp;ReplicaSetList{}, \u0026amp;ControllerRevision{}, \u0026amp;ControllerRevisionList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) // 要点④ return nil } 虽然外部版本与内部版本的 register 是极其类似的，主要有如下区别：\n版本标识符：\n// internal version var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal} // v1 version var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: \u0026#34;v1\u0026#34;} internal: 使用 runtime.APIVersionInternal（内部常量，通常为 \u0026ldquo;\u0026quot;），Internal version 是内部版本，不对外暴露。 v1: 使用具体的版本号字符串 \u0026ldquo;v1\u0026rdquo;，v1 是外部版本，有具体的版本号。 辅助函数：\nKind 函数：把未带 group 的类型名补成 apps 组的 schema.GroupKind，供 apiserver 内部生成 REST 映射或策略时直接使用 apps.Kind(\u0026quot;Deployment\u0026quot;) 得到完整 GroupKind。 Resource 函数：把资源名补成 apps 组的 schema.GroupResource，用于内部存储/策略等对 “deployments、statefulsets…” 的统一处理。 SchemeBuilder 定义：\n// internal version var ( SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) AddToScheme = SchemeBuilder.AddToScheme ) // v1alpha1 version var ( SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) localSchemeBuilder = \u0026amp;SchemeBuilder AddToScheme = localSchemeBuilder.AddToScheme ) Internal version 直接创建：所有 defaulting、conversion、rest storage 等代码都在同一仓库里维护。该包只需要把自身的类型注册进 scheme，因此用值类型的 SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) 即可，AddToScheme 会在 apiserver 启动过程中调用，把内部类型加入 scheme；其它 defaulting/conversion 函数（同包或同仓库中）直接在这个 builder 上注册即可，没必要暴露指针 v1 version 采用延迟注册自定义逻辑【自己手写的 defaulting / conversion 函数】，而外部版本只定义通用的注册逻辑，而自定义的手写函数位于主仓，并且 Go 的 runtime.SchemeBuilder 是一个切片类型（值语义）。若直接暴露 SchemeBuilder（值），其它文件 import 后拿到的只是副本，调用 Register 只会修改副本，AddToScheme 仍使用原始那份，导致额外注册失效。因此外部包定义 localSchemeBuilder = \u0026amp;SchemeBuilder 并把 AddToScheme = localSchemeBuilder.AddToScheme，这样主仓中的任何文件都能通过指针调用 localSchemeBuilder.Register(...) 把函数追加到同一个 builder 上，最终 AddToScheme 会顺序执行 addKnownTypes 和这些额外函数。 metav1.AddToGroupVersion 的差异\n// v1 版本多了一行 metav1.AddToGroupVersion(scheme, SchemeGroupVersion) v1 需要调用这个来注册 metav1 相关的元数据到该 GroupVersion Internal version 不需要，因为内部版本不关心这些元数据格式 2.4 注册表注册默认值设置函数 # 以外部版本 apps/v1 来讲解，外部版本在 pkg/apis/apps/v1 下有一个 register.go，部分代码如下所示：\n// 代码: pkg/apis/apps/v1 var ( localSchemeBuilder = \u0026amp;appsv1.SchemeBuilder AddToScheme = localSchemeBuilder.AddToScheme ) func init() { // We only register manually written functions here. The registration of the // generated functions takes place in the generated files. The separation // makes the code compile even when the generated files are missing. //这里只注册人工撰写的默认值函数，自动生成的已经有被生成的代码注册 // 如此分开的好处是即使代码生成没有进行，这部分编译还是会成功 localSchemeBuilder.Register(addDefaultingFuncs) } 在 v1 这个包的初始化过程中，调用了 Builder 方法的 Register() 方法，向 Builder 添加了 addDefaultingFuncs() 方法，我们也应该能猜到这个方法功用，它将向注册表中添加默认值设置函数。当 v1 版本对应的 Builder 被触发时，v1 版本的默认值设置方法将被注册进目标 Scheme 实例。\n2.5 注册表注册版本转换函数 # Converter 又是在哪里被注册进注册表的呢？实际上和 addDefaultingFuncs 一样，在包 v1 的初始化过程中，同样通过 Register() 方法交给 Builder，只不过这一部分源码不在 v1/register.go 中而是在 v1/zz_generated.conversion.go 中，代码如下所示：\n// 代码 pkg/apis/apps/v1/zz_generated.conversion.go func init() { localSchemeBuilder.Register(RegisterConversions) //要点① } 默认值设置函数和版本转换函数的代码，虽然不在同一源文件，但它们都隶属于包 v1。v1 在被引用时所有的包初始化——init() 函数都会被调用，包括 v1/register.go 中的 init() 和 v1/zz_generated.conversion.go 的 init() 函数。而这次，把 Conversion 的注册函数交给了 Builder， 见要点①处。\n注册表有四个主要信息，到此已经找到了其中三个的注入地，分别是：GVK 与 API 基座结构体映射关系、默认值设置方法和内外部版本转换函数。至于最后一个主要信息“API Group 内各版本的重要级别”，马上展开讲解其注册地。\n2.6 触发注册表的构建 # 经过上述代码准备，每个 API Group 的每个内外部版本都有了一个被 Director 设置好的 Builder，并且该 Builder 的产品构造触发方法 AddToScheme() 被暴露到该版本对应的 Go 包上，万事俱备，就等触发构造。但从解耦角度看还有待解决问题：Builder 是暴露在各个版本的包上的，要调用它们必须导入这些版本的包，外部触发程序岂不是要与这些包硬绑定？\n为了解决这个问题，在每个 API Group 下，都建立了一个 install/install.go 文件，工程里其它代码只要导入了一个 API Group 包的 install 子包，就会触发该 API Group 下各个版本对应的 Scheme Builder，把信息填入一个全部 API Group 共享的 Scheme 实例，以 apps 这个 Group 举例，代码如下：\nfunc init() { Install(legacyscheme.Scheme) } // Install registers the API group and adds types to a scheme func Install(scheme *runtime.Scheme) { utilruntime.Must(apps.AddToScheme(scheme)) utilruntime.Must(v1beta1.AddToScheme(scheme)) utilruntime.Must(v1beta2.AddToScheme(scheme)) utilruntime.Must(v1.AddToScheme(scheme)) utilruntime.Must(scheme.SetVersionPriority(v1.SchemeGroupVersion, v1beta2.SchemeGroupVersion, v1beta1.SchemeGroupVersion)) // 优先级 } 可以从：\nimport ( \u0026#34;k8s.io/apimachinery/pkg/runtime\u0026#34; // 必须导入所有版本 - 硬绑定！ \u0026#34;k8s.io/kubernetes/pkg/apis/apps\u0026#34; \u0026#34;k8s.io/kubernetes/pkg/apis/apps/v1\u0026#34; \u0026#34;k8s.io/kubernetes/pkg/apis/apps/v1beta1\u0026#34; \u0026#34;k8s.io/kubernetes/pkg/apis/apps/v1beta2\u0026#34; ) 转为：\nimport ( \u0026#34;testing\u0026#34; // 仅需一个空白导入！ _ \u0026#34;k8s.io/kubernetes/pkg/apis/apps/install\u0026#34; ) install 包的初始化方法 init() 会调用 Install 方法，该方法逐个调用每个版本所暴露出的 AddToScheme 方法，把各个版本下的 API 信息填写入指定的注册表：pkg/api/legacyscheme.go 中定义的名为 Scheme、类型为 runtime.Scheme 的变量。除此以外，Install 方法还设置了这个 Group 下所有版本的优先级，这也是注册表中的一个信息。\n现在已找到了注册表全部四个主要信息的注入点。\n现在，每个内置 API Group 都提供了一种方便触发 Builder 工作的方式：导入该 Group 的 Install 子包即可。注册表构建的实际触发是在 API Server 启动时完成的，一经构建，运行时不再对其更改，过程如下图所示。\nAPI Server 的应用程序从 main 包内代码开始运行，第一件事情就是导入依赖的包，这最终触发了注册表的填充。就如它的名字所揭示的一样，图中提及的 pkg/controlplane/import_known_versions.go 文件唯一的目的就是触发内建 API 向注册表的注册。\npackage controlplane import ( // These imports are the API groups the API server will support. _ \u0026#34;k8s.io/kubernetes/pkg/apis/admission/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/admissionregistration/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/apiserverinternal/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/apps/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/authentication/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/authorization/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/autoscaling/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/batch/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/certificates/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/coordination/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/core/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/discovery/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/events/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/extensions/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/flowcontrol/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/imagepolicy/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/networking/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/node/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/policy/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/rbac/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/resource/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/scheduling/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/storage/install\u0026#34; _ \u0026#34;k8s.io/kubernetes/pkg/apis/storagemigration/install\u0026#34; ) ","date":"2026年4月27日","externalUrl":null,"permalink":"/posts/cloud-native/01%E4%B8%A8k8s-generic-server-options-to-configs/","section":"Posts","summary":"","title":"K8s Generic Server Options To Configs","type":"posts"},{"content":"","date":"2026年4月27日","externalUrl":null,"permalink":"/tags/%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/","section":"Tags","summary":"","title":"并发编程","type":"tags"},{"content":"","date":"2026年4月27日","externalUrl":null,"permalink":"/tags/%E5%BA%95%E5%B1%82%E5%8E%9F%E7%90%86/","section":"Tags","summary":"","title":"底层原理","type":"tags"},{"content":"","date":"2026年4月27日","externalUrl":null,"permalink":"/categories/%E5%90%8E%E7%AB%AF%E5%BC%80%E5%8F%91/","section":"技术专栏","summary":"","title":"后端开发","type":"categories"},{"content":"","date":"2026年4月27日","externalUrl":null,"permalink":"/categories/cloud-native/","section":"技术专栏","summary":"","title":"云原生","type":"categories"},{"content":"","date":"2026年4月27日","externalUrl":null,"permalink":"/tags/%E4%BA%91%E5%8E%9F%E7%94%9F/","section":"Tags","summary":"","title":"云原生","type":"tags"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"}]