news 2026/10/16 1:14:32

Redis为什么快?从单线程、IO多路复用到内存管理的深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis为什么快?从单线程、IO多路复用到内存管理的深度解析

1. 面试官问“Redis为什么快”,到底在考察什么

“Redis为什么快?”这道题在技术面试里出现的频率,大概和“你最大的缺点是什么”差不多。很多人一听就条件反射地背出两条:单线程、基于内存。然后面试官面无表情地“嗯”一声,接着问下一个问题,你心里还觉得自己答得挺稳。但结果往往是——回去等通知,然后就没有然后了。

我后来复盘过很多次这类场景,也跟一些做过面试官的朋友聊过,发现一个很扎心的事实:“单线程”和“基于内存”这两个答案,面试官一天能听八十遍,它们只是入场券,不是加分项。真正拉开差距的,是你能不能讲清楚这两个特性背后,Redis 到底做了哪些工程上的取舍和设计,以及这些设计在什么场景下会失效。

这篇文章就是想把这道题彻底拆开讲透。不管你是正在准备面试,还是单纯想搞明白 Redis 的性能到底从哪来,我都会从底层机制、IO 模型、数据结构、内存管理、持久化影响这几个维度,把“快”这件事讲清楚。文章会涉及不少原理,但我会尽量用生活化的类比和实际场景来解释,保证你看完能自己讲出来,而不是背答案。

先给一个总览:Redis 的快,是内存存储、高效数据结构、IO 多路复用、单线程无锁、精细的内存管理、合理的持久化策略这几件事叠加的结果。单线程和基于内存只是其中最表层的两个点,真正体现功力的是后面几个。下面我们一个一个拆。

2. 单线程这个答案,为什么只对了一半

2.1 单线程到底指的是哪部分单线程

很多人说“Redis 是单线程的”,这句话其实不严谨。准确地说,Redis 处理客户端命令的核心流程是单线程的,也就是从网络 IO 读取命令、解析、执行、写回结果这一整条链路,是由一个主线程串行完成的。但 Redis 并不是只有一个线程。

从 Redis 4.0 开始,就有后台线程处理一些异步任务,比如UNLINK删除大 key、FLUSHALL ASYNC异步清空数据库。到了 Redis 6.0,又引入了多线程 IO,用来处理网络数据的读写和协议解析,但命令的实际执行仍然是单线程。所以如果你在面试里说“Redis 是单线程”,面试官可能会追问一句“你确定吗”,这时候你要能补充说明。

为什么命令执行要坚持单线程?核心原因是避免锁竞争和上下文切换。多线程执行命令,就必然要对共享数据结构加锁,加锁就有竞争,有竞争就有等待,有等待就有上下文切换开销。Redis 的操作本身是内存级的,执行时间极短,如果为了并行而引入锁,反而可能因为锁的开销把性能拖垮。这就像一个小卖部,顾客买瓶水的过程就几秒钟,你非要安排三个收银员,结果三个人还要抢同一个钱箱,反而更慢。

2.2 单线程为什么没有成为瓶颈

单线程能扛住高并发,关键在于每个命令的执行时间足够短。Redis 官方给出的数据是,单机 QPS 可以到十万级别。这个数字的前提是命令本身是 O(1) 或 O(log N) 的,比如GET、SET、LPUSH、ZADD。如果执行一个KEYS *或者SMEMBERS一个百万级的大集合,单线程就会被这一个命令卡住,后面的请求全部排队。

所以单线程的“快”是有条件的:你不能让单个命令执行太久。这也是为什么 Redis 反复强调不要在生产环境用KEYS、不要对大 key 做全量操作。一旦某个命令耗时过长,单线程的优势立刻变成劣势,因为所有请求都被阻塞了。

我在实际项目里踩过一次坑:有个业务用HGETALL去拿一个存了几十万字段的 hash,平时量小没感觉,某天数据涨上来之后,整个 Redis 实例间歇性卡顿,其他业务的请求超时率飙升。排查了半天才发现是这个大 key 在作祟。后来改成HSCAN分批取,问题才解决。这个经历让我深刻理解到,单线程模型下,命令的时间复杂度就是整个系统的生命线。

2.3 单线程模型下的隐性成本

单线程还有一个容易被忽略的点:它无法利用多核 CPU。如果你的服务器是 8 核,Redis 主线程只能吃满一个核,剩下 7 个核基本闲着。所以通常的部署建议是在一台机器上跑多个 Redis 实例,每个实例绑一个核,通过分片来利用多核。这也是集群方案存在的原因之一。

另外,单线程意味着一个慢查询会拖垮所有请求。MySQL 里一个慢查询最多影响那一个连接,但 Redis 里一个慢命令会影响所有客户端。所以 Redis 提供了慢查询日志(SLOWLOG),可以记录执行超过指定时间的命令,这个工具在排查性能问题时非常有用。我一般会在上线前用SLOWLOG GET检查一遍,看看有没有意外的慢命令。

3. 基于内存只是起点,内存管理才是真功夫

3.1 内存访问和磁盘访问的差距有多大

“基于内存所以快”,这句话本身没错,但太笼统了。到底快多少?我们可以量化一下:内存的随机访问延迟大约在 100 纳秒级别,而机械硬盘的随机读写延迟在 10 毫秒级别,两者差了五个数量级。即使是 SSD,随机读延迟也在 100 微秒左右,比内存慢三个数量级。这个差距意味着,同样一次数据读取,内存是“眨个眼的功夫”,磁盘是“等一壶水烧开”。

但光把数据放内存里还不够。如果数据结构设计得不好,内存操作一样可以很慢。比如在内存里做一次全表扫描,照样要遍历所有元素。所以 Redis 的快,不只是“数据在内存”,更是“用对的方式在内存里组织数据”。

3.2 Redis 的内存分配器选择

Redis 默认使用jemalloc作为内存分配器,而不是 Linux 默认的 glibc malloc。这个选择是有讲究的。jemalloc 在多线程场景下的碎片率更低,内存分配效率更高。虽然 Redis 命令执行是单线程,但后台线程和内存分配本身可能涉及多线程,jemalloc 能更好地控制内存碎片。

你可以通过INFO memory查看mem_fragmentation_ratio这个指标,它等于used_memory_rss / used_memory。如果这个值大于 1.5,说明碎片比较严重,可能需要重启实例或者做内存整理。我见过一个实例这个值到了 2.0 以上,明明数据只有 4G,实际占了 8G 多内存,后来通过MEMORY PURGE和重启才降下来。

3.3 过期键删除策略对性能的影响

Redis 的过期键删除用了惰性删除 + 定期删除的组合策略。惰性删除是指访问一个 key 时才检查它是否过期,过期就删掉。定期删除是指每隔一段时间随机抽查一部分设置了过期时间的 key,删掉其中过期的。

为什么不用定时删除?因为定时删除需要为每个 key 维护一个定时器,CPU 开销太大。惰性删除的问题是,如果一个 key 过期了但一直没人访问,它就会一直占着内存。定期删除就是来兜底的,但它是随机抽查,不是全量扫描,所以仍然可能有漏网的过期 key。

这个策略对性能的影响在于:如果大量 key 同时过期,定期删除的抽查可能删不完,导致内存上涨。更严重的是,如果这些 key 恰好是大 key,删除操作本身就很耗时,会阻塞单线程。所以设置过期时间时,最好加一个随机偏移量,避免同一时刻大量 key 集中过期。比如原本要设 3600 秒,可以改成3600 + random(0, 300)。

4. IO 多路复用:单线程扛住万级连接的关键

4.1 为什么不用一个连接一个线程

传统的阻塞 IO 模型里,一个线程处理一个连接。如果有 1 万个连接,就要 1 万个线程。线程的创建、销毁、上下文切换都是成本,而且大部分线程大部分时间都在等待数据,白白占着资源。这就像一家餐厅,每来一个客人就专门配一个服务员站在旁边等他点菜,客人看菜单看十分钟,服务员就干站十分钟,人力浪费极大。

Redis 用的是IO 多路复用,一个线程可以同时监听多个连接。哪个连接有数据来了,就去处理哪个。这样单线程就能管理成千上万的连接,不需要为每个连接创建线程。常见的多路复用实现有select、poll、epoll、kqueue,Redis 会根据操作系统自动选择,Linux 上默认用epoll。

4.2 epoll 的工作机制

epoll的核心是三个函数:epoll_create创建一个 epoll 实例,epoll_ctl注册或修改要监听的文件描述符,epoll_wait等待事件发生。相比select,epoll的优势在于:它不需要每次调用都把整个文件描述符集合从用户态拷贝到内核态,也不需要遍历所有描述符来检查哪个就绪,而是由内核维护一个就绪列表,直接返回就绪的描述符。

这个机制让 Redis 在处理大量连接时,epoll_wait的返回效率几乎不受连接总数影响。连接数从 1000 涨到 10000,epoll_wait的开销并不会线性增长。这是 Redis 能扛住高并发连接的根本原因之一。

4.3 事件循环的完整流程

Redis 的事件循环(aeEventLoop)是整个单线程模型的心脏。它的大致流程是:

  1. 调用epoll_wait等待事件,设置一个超时时间(通常是最近一个定时任务的时间)。
  2. 如果有文件事件(客户端连接、读、写)就绪,依次处理。
  3. 处理时间事件,比如serverCron定时任务,负责更新统计信息、检查过期键、触发持久化等。
  4. 回到第 1 步,循环往复。

这个循环里,文件事件的处理是主体。当客户端发来命令,epoll_wait返回可读事件,Redis 读取数据、解析协议、执行命令、把结果写入输出缓冲区,然后注册可写事件,等epoll_wait下次返回可写时再把数据发出去。整个过程没有阻塞,没有线程切换,一气呵成。

注意:Redis 6.0 的多线程 IO 只是把“读取数据”和“写回数据”这两步交给多个线程并行做,命令执行还是单线程。这样在网络带宽成为瓶颈时,可以提升吞吐量,但不会改变单线程执行命令的本质。

5. 数据结构:快在“用对了工具”

5.1 底层编码的自动转换

Redis 对外暴露的是 String、List、Hash、Set、ZSet 这五种基本类型,但每种类型在底层都有多种编码方式,会根据数据量自动切换。比如 Hash 类型,元素少的时候用ziplist(Redis 7.0 之后是listpack),元素多了或者单个元素大了就转成hashtable。ZSet 在元素少时用 ziplist,多了就转成skiplist + dict。

这种自动转换的设计,是为了在不同数据规模下都能保持最优的性能和内存占用。ziplist 是一块连续内存,遍历快、内存紧凑,但插入删除可能涉及内存搬移,适合小数据量。hashtable 插入删除是 O(1),但内存开销大,适合大数据量。Redis 通过配置项hash-max-ziplist-entries、zset-max-ziplist-entries等来控制转换阈值。

我一般会关注这些阈值,因为默认值不一定适合所有场景。比如默认hash-max-ziplist-entries是 128,如果你的 hash 经常存 200 个字段,那每次都会用 hashtable,内存占用会比 ziplist 高不少。如果内存紧张,可以适当调大这个阈值,但要注意 ziplist 变大后操作会变慢,需要权衡。

5.2 跳表和压缩列表的取舍

ZSet 的底层实现特别能体现 Redis 的设计功力。当元素少时用 ziplist,元素多时用skiplist + dict。skiplist 是一种多层链表,查找、插入、删除的平均复杂度都是 O(log N),而且实现比平衡树简单,范围查询也很方便。dict 则用来存储 member 到 score 的映射,保证 O(1) 查询某个 member 的分数。

为什么不用红黑树?因为 skiplist 的实现更简单,而且做范围查询时,skiplist 只需要在底层链表上顺序遍历即可,红黑树则需要中序遍历。另外,skiplist 的并发控制(虽然 Redis 单线程用不上)也比红黑树容易。这些取舍都体现了 Redis “够用就好,简单优先”的哲学。

5.3 不同结构的性能对比

数据结构底层编码查找插入删除适用场景
Stringint/embstr/rawO(1)O(1)O(1)缓存、计数器
Listlistpack/quicklistO(N)O(1)O(1)消息队列、最新列表
Hashlistpack/hashtableO(1)O(1)O(1)对象存储
Setintset/listpack/hashtableO(1)O(1)O(1)去重、交集并集
ZSetlistpack/skiplistO(log N)O(log N)O(log N)排行榜、延时队列

这张表看起来简单,但实际使用时有很多细节。比如 List 的LINDEX是 O(N),如果你频繁按索引访问,性能会很差。再比如 Set 的SINTER复杂度是 O(N*M),N 是最小集合的大小,M 是集合数量,大集合做交集运算会阻塞很久。这些都是在选型时要考虑的。

6. 持久化与内存管理的暗坑

6.1 RDB 和 AOF 对性能的影响

Redis 的持久化有两种方式:RDB 和 AOF。RDB 是快照,把某一时刻的内存数据全量写到磁盘。AOF 是追加日志,把每个写命令追加到文件里。两者对性能的影响完全不同。

RDB 的bgsave会 fork 一个子进程来做快照,主进程继续处理请求。fork 的瞬间会有短暂的阻塞,阻塞时间和内存页表大小有关。内存越大,页表越大,fork 越慢。我见过一个 32G 的实例,fork 一次阻塞了将近 200 毫秒,对于高并发场景来说这是不可接受的。所以大内存实例要谨慎配置 RDB 的触发频率。

AOF 的appendfsync有三个选项:always、everysec、no。always每条命令都刷盘,性能最差但最安全;everysec每秒刷一次,是默认值,兼顾性能和安全;no交给操作系统决定,性能最好但可能丢数据。生产环境一般用everysec,但如果对数据安全性要求极高,可以考虑always,前提是能接受性能下降。

6.2 内存淘汰策略的选择

当内存达到maxmemory限制时,Redis 会根据淘汰策略删除一些 key。常见的策略有:

  • noeviction:不淘汰,写入直接报错。
  • allkeys-lru:从所有 key 中淘汰最近最少使用的。
  • allkeys-lfu:从所有 key 中淘汰使用频率最低的。
  • volatile-lru:从设置了过期时间的 key 中淘汰最近最少使用的。
  • volatile-ttl:从设置了过期时间的 key 中淘汰剩余生存时间最短的。

选哪个策略取决于业务场景。如果是纯缓存,用allkeys-lru或allkeys-lfu比较合适。如果有些 key 不能丢,那就用volatile-*系列,只淘汰设置了过期时间的 key。LFU 比 LRU 更适合热点数据稳定的场景,因为它考虑的是访问频率而不是最近访问时间。

我踩过一个坑:有个实例配了volatile-lru,但业务写入的 key 大部分没设过期时间,结果内存满了之后淘汰不了任何 key,写入全部报错。后来改成allkeys-lru才解决。所以配置淘汰策略前,一定要确认业务 key 的过期时间设置情况。

6.3 大 key 和热 key 的治理

大 key 是指单个 key 的 value 特别大,比如一个 String 存了几 MB,或者一个 Hash 有几十万字段。大 key 的危害在于:操作它耗时久,会阻塞单线程;删除它更耗时,如果是DEL同步删除,可能卡住好几秒。Redis 4.0 引入了UNLINK,把删除操作放到后台线程,可以缓解这个问题。

热 key 是指访问频率极高的 key,比如秒杀场景下的库存 key。热 key 的问题在于,所有请求都打到同一个 Redis 实例的同一个 key 上,单线程处理不过来。解决办法有:本地缓存、key 拆分(比如把库存拆成 10 个 key,请求分散到不同 key 上)、读写分离等。

排查大 key 可以用redis-cli --bigkeys,它会扫描所有 key 并统计每种类型最大的 key。排查热 key 可以用MONITOR命令(但生产环境慎用,会影响性能),或者用一些监控工具做采样统计。

7. 面试里怎么把这道题答出层次感

7.1 从“两点”扩展到“六点”

如果你在面试里只答“单线程和基于内存”,面试官会觉得你只是背了八股文。更好的答法是先给一个总述,然后逐层展开。比如可以这样说:

“Redis 快的原因可以从几个层面来看。最基础的是数据存在内存里,内存访问比磁盘快几个数量级。但光有内存不够,还要有高效的数据结构,Redis 针对不同数据量和场景选择了 ziplist、skiplist、hashtable 等不同的底层编码。然后是 IO 模型,Redis 用 epoll 做多路复用,单线程就能管理大量连接。命令执行单线程避免了锁竞争和上下文切换。再加上精细的内存管理和合理的持久化策略,共同保证了高性能。”

这样答,面试官会觉得你不仅知道结论,还理解背后的设计逻辑。

7.2 主动说出单线程的局限性

面试里主动提局限性,往往比只讲优点更能加分。你可以说:“单线程也不是没有代价的,它无法利用多核,一个慢命令会阻塞所有请求,所以生产环境要避免KEYS、大 key 全量操作这些。Redis 6.0 引入多线程 IO 也是为了缓解网络瓶颈,但命令执行还是单线程。”

这种回答展示了你对技术的全面理解,而不是盲目吹捧。

7.3 结合实际场景谈取舍

如果面试官继续追问,你可以结合具体场景谈取舍。比如:“如果业务是读多写少,可以用读写分离,从节点分担读请求。如果写并发很高,可以考虑集群分片。如果对数据安全性要求高,AOF 用always,但性能会下降,需要权衡。”

这种回答说明你有实际经验,不是只会理论。

8. 几个容易被忽略的性能细节

8.1 网络带宽和延迟的影响

Redis 的快是相对于磁盘数据库而言的,但在网络层面,带宽和延迟仍然会影响实际性能。如果客户端和 Redis 跨机房部署,网络延迟可能到几毫秒,这时候 Redis 本身再快也没用。所以生产环境一般要求客户端和 Redis 同机房部署,延迟控制在 1 毫秒以内。

另外,如果返回的数据量很大,比如一次取几 MB 的 value,网络传输时间会占主导。这时候可以考虑压缩数据,或者拆分 key,减少单次传输量。

8.2 客户端连接池的配置

客户端连接池的配置也会影响性能。连接池太小,请求排队等连接;连接池太大,Redis 端要维护更多连接,虽然 epoll 能处理,但每个连接都有内存开销。一般建议根据并发量来设置,比如 QPS 是 1 万,每个请求平均耗时 1 毫秒,那理论上需要 10 个连接就够,但考虑到突发流量,可以设 50 到 100 个。

连接池还有一个坑是连接泄漏。如果代码里拿了连接忘记归还,连接池很快就会被耗尽。所以用连接池一定要确保在finally块里归还连接。

8.3 慢查询日志和监控

前面提到过SLOWLOG,这里再强调一下它的用法。SLOWLOG GET 10可以查看最近 10 条慢查询,SLOWLOG RESET清空。慢查询的阈值由slowlog-log-slower-than配置,单位是微秒,默认 10000 微秒(10 毫秒)。生产环境可以设成 5000 甚至 1000,更早发现问题。

除了慢查询,还要监控INFO里的关键指标:instantaneous_ops_per_sec(实时 QPS)、used_memory(内存使用)、connected_clients(连接数)、blocked_clients(阻塞客户端数)、keyspace_hits和keyspace_misses(命中率)。这些指标能帮你快速判断实例的健康状况。

9. 我在实际项目里踩过的坑

说几个真实踩过的坑,都是跟“Redis 为什么快”这个主题相关的。

第一个坑是用KEYS做模糊查询。早期项目里有个功能要按前缀查 key,开发直接用了KEYS prefix:*。测试环境数据少没感觉,上线后数据量上来,一次KEYS卡了 3 秒,整个实例不可用。后来改成用SCAN游标遍历,虽然不能保证一次返回所有结果,但不会阻塞。

第二个坑是大 key 的DEL。有个业务存了一个几十万元素的 Set,某天要删除它,直接DEL导致 Redis 卡了 5 秒。后来改用UNLINK,删除操作放到后台,主线程立刻返回。再后来我们加了规范,超过一定大小的 key 必须用UNLINK删除。

第三个坑是持久化配置不当。有个实例开了 RDB,save配置是900 1(900 秒内至少 1 次修改就触发),结果业务写入频繁,几乎每分钟都在 fork,fork 的阻塞叠加起来导致周期性卡顿。后来改成3600 10000,降低触发频率,问题缓解。

第四个坑是内存淘汰策略选错。前面提过,配了volatile-lru但 key 没设过期时间,导致内存满了写入报错。这个坑的教训是,配置淘汰策略前一定要确认业务 key 的过期设置。

这些坑的共同点是:它们都不是 Redis 本身的问题,而是使用方式的问题。Redis 的快是有前提的,前提就是你要按它的设计意图来用。违背了这些前提,再快的系统也会被拖慢。

10. 从源码角度看几个关键设计

如果你对源码感兴趣,可以看看 Redis 的几个核心文件。ae.c是事件循环的实现,epoll的封装就在里面。t_zset.c是 ZSet 的实现,skiplist 的代码写得非常清晰。zmalloc.c是内存分配的封装,可以看到 jemalloc 是怎么被集成的。

看源码最大的收获是,你能看到作者在每一个细节上的取舍。比如ae.c里对epoll_wait的超时计算,既要保证定时任务按时执行,又不能频繁唤醒浪费 CPU。再比如t_zset.c里 skiplist 的层数生成用了随机算法,平衡了查找效率和内存开销。这些细节在文档里是看不到的,但恰恰是理解 Redis 设计哲学的关键。

我建议如果你想深入,可以从ae.c开始看,因为它相对独立,而且能帮你理解整个事件驱动模型。看完之后再去看server.c里的main函数,就能把整个启动流程串起来。

11. 关于性能优化的一点个人体会

最后说点个人体会。Redis 的性能优化,很多时候不是去调什么参数,而是先搞清楚业务到底怎么用 Redis。我见过太多团队,一上来就问“怎么让 Redis 更快”,结果一看用法,全是KEYS、大 key、没设过期时间。这种情况下,调参数没用,得先改用法。

我的习惯是,新业务接入 Redis 之前,先过一遍 checklist:key 的命名规范是什么、有没有设过期时间、value 大概多大、有没有可能变成大 key、读写比例是多少、能不能接受丢数据。这些问题想清楚了,再决定用什么数据结构、什么持久化策略、什么淘汰策略。这比事后调优有效得多。

Redis 的快,本质上是一种“设计出来的快”,而不是“堆硬件堆出来的快”。理解这一点,你就能明白为什么面试官不满足于“单线程和基于内存”这个答案。他们想听到的,是你对这套设计逻辑的理解,以及你在实际场景中怎么用好它。希望这篇文章能帮你把这道题答出层次感,也帮你在实际项目里少踩几个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 1:12:45

Spring Boot项目集成SonarQube:打造GitLab CI/CD代码质量门禁

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:12:27

双节锂电主动均衡方案:MP2642GR-Z与MSP432P401R协同设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:11:46

高斯混合模型(GMM)异常检测原理与Python实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:11:08

Python批量上传图片到OSS:目录结构保真与并发优化实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:11:08

手机杂音背后的电磁三幕剧:静电屏蔽、涡流与趋肤效应

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:10:49

三维相机交互原理:鼠标操控背后的坐标变换与状态机设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华