news 2026/9/10 3:01:36

Spring Boot 3.x中Caffeine缓存大小策略失效排查与解决

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 3.x中Caffeine缓存大小策略失效排查与解决

做Spring Boot 3.x的缓存改造时,我遇到过不止一次这样的场面:配置里明明写了maximumSize=500,测试时往缓存里塞了两千条数据,estimatedSize()却一路涨到两千多,就好像根本没设上限。后来排查才知道,问题不在Caffeine本身,而在Spring Boot对CaffeineCacheManager的初始化时机,以及缓存大小策略到底有没有被正确注入。这篇文章会从这类“配置不生效”的场景出发,把Caffeine的大小策略、淘汰机制、正确配置方式、常见坑和监控手段完整拆一遍。无论你是刚接触Spring Cache,还是在3.x上被缓存容量搞到内存吃紧,都可以对照着用。

1. 项目概述:遇到“缓存最大容量不生效”怎么查

1.1 场景还原:看起来不起作用的maximumSize

先说一个我实际见过的配置。项目用的是Spring Boot 3.1,pom里加了spring-boot-starter-cachecom.github.ben-manes.caffeine:caffeine,然后在application.yml里写了:

spring: cache: type: caffeine caffeine: spec: maximumSize=100,expireAfterWrite=10m

业务代码里用@Cacheable(cacheNames = "userCache")缓存用户信息。启动之后做压力测试,往缓存里写入大量不同用户的数据。结果通过cacheManager.getCache("userCache").getNativeCache().estimatedSize()去观察,发现条数早就超过100了,甚至在两百、三百的级别继续增长。第一反应当然是怀疑maximumSize写错了,或者是Caffeine版本的问题。

但实际去翻CaffeineCacheManager的源码就会发现,真正的罪魁祸首是“缓存创建时机”。Spring Boot通过spring.cache.caffeine.spec解析出的CaffeineSpec,本质上是需要注入到CaffeineCacheManager里的,而CaffeineCacheManager并不是在Bean初始化阶段就把所有缓存建好,它是在第一次调用getCache(name)的时候,才真正创建对应名称的CaffeineCache。也就是说,谁先触发getCache("userCache"),谁就决定了这个缓存是用什么配置创建的。如果此时spec还没被设置进去,系统就会用默认的空配置建出一个“无大小限制”的缓存,之后你就算再改spec,已经创建的缓存也不会重新生成。

1.2 为什么“配置不生效”:CaffeineCacheManager的延迟创建机制

这里值得多说一句核心机制。CaffeineCacheManager继承自AbstractCacheManager,内部维护了一个ConcurrentHashMap来存放已经创建的缓存。调用getCache(String name)时,如果缓存不存在并且dynamic模式是开启的,它就会用当前持有的Caffeine构建器或者CaffeineSpec去创建一个全新的CaffeineCache

这个设计的本意是让开发者可以“随用随建”,不用把所有缓存名提前声明。但副作用就是:配置的注入时机必须严格早于第一次访问。你可以把这几个组件串起来理解:

  • Caffeine<Object, Object>构建器:由代码编程式定义,可以包含maximumSize、maximumWeight、expireAfterWrite、recordStats等全部参数。
  • CaffeineSpec:由spring.cache.caffeine.spec配置解析而来,支持一部分常用参数,适合简单场景。
  • CaffeineCacheManager:持有以上配置,并在getCache时创建CaffeineCache

我在给团队讲这个问题的时候喜欢打一个比方:办健身卡的时候要录入身高体重和体测数据,录入之后卡片就印出来了。如果这时候你想改卡上的数据,得重新办卡,而不是把卡片拿回来让前台用笔改一下。getCache("userCache")就是“印卡”的瞬间,之后无论你是改CaffeineSpec还是setCaffeine,对这张已经存在的卡都没有任何影响。

大家看到这里应该明白了:如果你在@PostConstructApplicationRunnerCommandLineRunner,甚至某个@Bean方法里调用了cacheManager.getCache("xxx"),那你已经把缓存“提前印”出来了。后续再登录系统看到“配置没生效”,其实只是配置没赶上创建时机。

1.3 结论先行:配置大小策略的三个原则

结合上面这个案例,我在实操中总结出三条原则,建议你先把这三条刻在脑子里,再去写代码:

  1. 配置注入必须先于缓存创建。如果是用配置文件方式,要确保CaffeineCacheManager是从Spring容器里那一个,而不是自己new出来的;如果是编程式配置,setCaffeinesetCacheNames要放在任何可能触发getCache的调用之前。

  2. 所有缓存名尽量提前声明。把项目里的@Cacheable(cacheNames=...)收敛一遍,在CaffeineCacheManager里用setCacheNames一次性列出来,这样启动阶段就会预建缓存,运行期不会因为动态创建而抓到错误配置。

  3. 不同缓存需要不同容量时,不要图省事用统一spec。热门数据可以给大容量,冷数据给个小容量,用registerCustomCache分别注册,否则一个maximumSize=100会让所有缓存都受限,或让某些缓存过大。

2. 核心细节解析:Caffeine大小策略到底在管什么

2.1 maximumSize和maximumWeight,两种“大小”语义

很多同学以为Caffeine的大小策略就是“最多放多少条”,其实Caffeine给了两种不同的“大小”语义。

maximumSize是最常用的一种,它限制的是缓存中条目的数量。比如maximumSize(10_000)表示最多保留1万个键值对,当超过这个数量时,会按照W-TinyLFU淘汰算法把一些条目逐出。它适合“每条数据大小差不多”的场景,比如用户信息对象、商品对象这类结构相对统一的缓存。

maximumWeight则是基于权重的限制。你需要先定义一个weigher,告诉Caffeine每个键值对“重”多少。例如你想把缓存的总大小控制在1MB以内,可以按字符串字节数计算权重:

Caffeine.newBuilder() .maximumWeight(1_000_000) .weigher((key, value) -> value.toString().getBytes(StandardCharsets.UTF_8).length) .build();

关于这两者的区别和限制,我用一个表格来对照:

参数语义适用场景注意事项
maximumSize条目数量上限缓存条目大小相对均匀时不能与maximumWeight同时使用
maximumWeight总权重上限,需要配合weigher缓存条目大小差异很大时设置weigher后必须同时设置maximumWeight,否则缓存会无限增长
initialCapacity初始化容量预估缓存规模时减少自动扩容的开销,不是大小限制

Caffeine的校验逻辑很严格,maximumSizemaximumWeight同时设置会直接抛异常,因为这是两种互斥的淘汰触发方式。源码里requireWeightWithMaximumWeight这类强校验会保证你配置的一致性,所以如果你用了weigher却忘了maximumWeight,启动阶段就可能直接报错,这不算坏事,至少问题暴露得早。

2.2 W-TinyLFU淘汰算法:为什么不是普通LRU

Caffeine默认使用W-TinyLFU算法来做缓存淘汰,理解它能帮你少走很多弯路。

传统的LRU(Least Recently Used)简单粗暴,最近没被访问的数据会被淘汰。但它的弱点是:如果一个冷门数据在某个时刻突然被批量扫描了一次,它就会进入“热区”,把真正高频访问的数据挤出去。而LFU(Least Frequently Used)虽然能记住长期频率,但对于刚进来的突发热点很不友好,新数据还没来得及积累频率就被淘汰了。

W-TinyLFU在两者之间做了一个折中。它把缓存空间拆成两部分:一个很小的Window区域和一个Main区域。Main区域内部又分为Probation和Protected两个区域。简单理解:

  • Window区域用来接纳新写入的条目,让突发流量有一个保护空间。
  • Main区域里真正长期高频的数据会进入Protected区,不太活跃的放在Probation区。
  • 当缓存满的时候,候选条目会和Probation区的驱逐候选者“比频率”,谁的访问频率低,谁就走人。

这个算法带来的直观效果就是:缓存命中率通常比单纯的LRU更高,尤其在热点数据突发的场景下更加明显。

为什么要在这里讲这个?因为“大小策略生效”并不等于“缓存永远保持在一个固定数量”。Caffeine的淘汰是异步执行的,默认使用ForkJoinPool.commonPool(),当条目数超过上限后,不会马上同步地把数量压回去,会有一个短暂的时间窗口。如果你像刚才那个场景一样刚塞了一堆数据就立刻estimatedSize(),看到的往往是“超限”的中间状态,而不是最终稳定值。我在排查问题时见过不少人被这个现象误导,以为maximumSize完全失效,其实等一两秒再观察,数量就慢慢回落了。

2.3 大小策略与过期策略如何配合

大小策略解决的是“缓存能占多少空间”,过期策略解决的是“数据在什么时间内有效”。两者经常一起用,但很多人容易混淆。

Caffeine中常见的过期策略有:

  • expireAfterWrite(Duration):写入后经过指定时间过期。
  • expireAfterAccess(Duration):最后一次访问后经过指定时间过期。
  • expireAfter(Expiry):自定义过期时间,可以根据key和value决定。

如果只设置maximumSize而不设置过期策略,请求峰值高的时候确实能控制总量,但缓存里会出现大量“很久没更新、业务上已经不需要”的脏数据。它们会一直占着名额,直到因为容量压力被淘汰。如果数据本身是会变化的,那最好加上expireAfterWrite作为兜底,保证即使没被淘汰,也会在固定时间后失效,重新从数据库加载。

这里还有一个隐藏细节:Caffeine的过期清理不是有单独线程定时扫描的,它是在缓存读写操作时触发的,属于“惰性删除”加“定期清理”的结合。所以你可能会发现,某条数据明明到了过期时间,但在下一次访问之前它仍然占着内存,这并不代表过期配置失效。定期清理线程会分批处理过期条目,只是这个动作不是每毫秒都在跑。

3. 实操配置:Spring Boot 3.x中正确的缓存大小策略写法

3.1 方式一:spring.cache.caffeine.spec简单配置

如果项目里缓存场景比较单一,所有缓存可以使用同样的大小和过期时间,那么最简单的方式就是用配置文件:

spring: cache: type: caffeine caffeine: spec: maximumSize=500,expireAfterWrite=10m

这种方式背后,Spring Boot会自动创建一个CaffeineCacheManager,并把CaffeineSpec设置进去。CaffeineSpec支持不少参数,比如initialCapacitymaximumSizemaximumWeightexpireAfterAccessexpireAfterWriterefreshAfterWriteweakKeysweakValuessoftValuesrecordStats

但它有几个天然限制:

一是weigher无法在CaffeineSpec中声明,所以如果你要按字节数做权重限制,就不能只靠配置文件。

二是所有缓存共用同一个spec。假如hotCache你想给1万条,configCache你只想给500条,用配置文件就没有办法分别定制,除非你拆成多个CacheManager,那又太绕了。

所以我的建议是:配置文件的方式适合“项目很小、缓存规则统一、先跑起来再说”的阶段。一旦缓存变多、业务规则差异化,尽早切换到代码配置。

3.2 方式二:自定义CacheManager Bean,把配置权拿回来

更推荐的做法是自定义一个CacheManager@Bean,显式声明Caffeine构建器,并提前把缓存名固定住。这样配置可见、可控,排查问题时一眼就能看出每个缓存的容量是多少。

@Configuration public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .initialCapacity(100) .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats()); cacheManager.setCacheNames(Arrays.asList("userCache", "orderCache")); return cacheManager; } }

这里最关键的一点是:setCaffeinesetCacheNames必须发生在这个Bean返回之前,而且不要在Bean方法内部调用cacheManager.getCache("userCache")。如果你手贱在Bean方法里先调了一次getCache,就又把“提前创建”的问题引回来了。

使用setCacheNames的另一个好处是缓存名被固定了。这样如果你在代码里敲错了一个缓存名,比如把orderCache写成ordrCache,启动时就会因为找不到声明而直接报错,而不是运行期静默创建一个用默认配置的缓存,让问题潜伏很久。

3.3 方式三:不同缓存名使用不同大小策略

当项目里缓存的数据差别很大时,统一配置一个maximumSize往往不够用。比如热搜商品数据量大、访问频繁,可以给大容量;系统配置项少但重要,给小容量、长过期时间即可。

CaffeineCacheManager提供了registerCustomCache方法,可以为指定缓存名注册独立的Caffeine实例:

@Configuration public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.registerCustomCache("hotProductCache", Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(Duration.ofMinutes(30)) .recordStats() .build()); cacheManager.registerCustomCache("configCache", Caffeine.newBuilder() .maximumSize(1_000) .expireAfterWrite(Duration.ofHours(1)) .recordStats() .build()); return cacheManager; } }

注意,用registerCustomCache注册的时候,要保证这个方法在getCache之前执行。通常放在Bean方法里是安全的,但如果你后续有其他代码在启动阶段提前触发了缓存创建,那注册时机还是会乱掉。所以我习惯在配置类里同时给setCacheNames列出一个完整清单,既不担心缓存被提前创建,又能让所有缓存名在启动阶段就被验证一遍。

3.4 补充:用CacheManagerCustomizer做统一兜底

Spring Boot提供了一种比较优雅的扩展入口:CacheManagerCustomizer。如果你不想全部推翻Spring Boot的自动配置,只想在自动配置的CaffeineCacheManager基础上做额外定制,可以定义一个CacheManagerCustomizer<CaffeineCacheManager>的Bean。

@Component public class CaffeineCacheManagerCustomizer implements CacheManagerCustomizer<CaffeineCacheManager> { @Override public void customize(CaffeineCacheManager cacheManager) { cacheManager.setAllowNullValues(false); cacheManager.setCacheNames(Arrays.asList("userCache", "orderCache", "configCache")); } }

这个方式的优点是不会影响Spring Boot自动配置的spring.cache.caffeine.spec解析,你仍然可以在配置文件中维护spec,同时在代码里补充一些spec无法表达的东西,比如固定缓存名列表、禁止缓存null值等。如果你的项目里有很多组员同时开发,这种定制入口比每个人各写一套CacheManager更不容易冲突。

4. 常见问题与排查技巧实录

4.1 配置没生效:先检查有没有人提前碰过缓存

这个问题出现的概率非常高,典型的排查路径是这样的:

先在CaffeineCacheManager.getCache(String name)方法入口打一个断点,应用启动后看第一次执行getCache的调用栈是从哪里进来的。如果调用栈里出现了@PostConstructApplicationRunnerCommandLineRunner@EventListener(ApplicationReadyEvent.class)等方法,那就是有代码在启动阶段提前创建了缓存,导致后续的spec设置根本来不及生效。

还有一类情况是项目里同时存在多个CacheManager。比如有人为了集成Redis,定义了一个RedisCacheManager,又定义了CaffeineCacheManager,结果某些代码拿到了另一个实例,配置自然对不上。排查时建议直接输出所有CacheManager的类型和实例,确认你操作的确实是Spring Boot为Caffeine创建的那一个。

解决这个问题的根治办法就是:把缓存名全部提前声明,并在配置类里完成所有Caffeine构建器的注入,不依赖运行期的动态创建。

4.2 maximumSize和maximumWeight同时设置:启动直接报错

这是Caffeine比较刚硬的一面。如果你在配置里既写了maximumSize又写了maximumWeight,启动时会抛出类似IllegalStateException的异常,提示不能同时设置两种淘汰条件。

如果是从配置文件用CaffeineSpec写的,也可能会在解析阶段就失败。遇到这个报错,先确定你到底想按“条数”还是按“权重”限制缓存。我遇到过一些同学用maximumWeight但忘了写weigher,结果缓存不淘汰,内存持续增长,这也是非常危险的。记住Caffeine的规则:使用weigher就必须同时指定maximumWeight;使用maximumWeight就必须准备一个weigher

4.3 动态缓存名泛滥:缓存数量无限增长

有些同学写@Cacheable的时候,喜欢把动态参数拼进cacheNames里,比如:

@Cacheable(cacheNames = "order_" + orderId) public Order getOrder(Long orderId) { ... }

这不是不行,但要明白:CaffeineCacheManager会为每一个不存在的缓存名创建一个新的缓存实例。如果订单总量有100万个,那就会创建上百万个独立的Caffeine缓存,每个缓存都有自己的W-TinyLFU结构和内部HashMap。这种情况下,你就算给每个缓存设置maximumSize=100,总的内存占用也会因为缓存实例数量太多而爆炸。

正确的做法是缓存名固定为orderCache,把orderId作为缓存的key参数:

@Cacheable(cacheNames = "orderCache", key = "#orderId") public Order getOrder(Long orderId) { ... }

这类问题比单个缓存超过大小限制可怕得多,因为它不仔细看监控很难发现。我的经验是:在项目初期就约定好所有缓存名必须收敛成固定集合,并且用setCacheNames固定下来,从根上杜绝动态缓存名泛滥。

4.4 同时存在Redis等其它缓存实现导致Caffeine压根没生效

Spring Boot的缓存自动探测机制有个特点:当classpath下同时存在Redis和Caffeine时,如果spring.cache.type没有显式指定,Boot会选择一个优先级更高的缓存实现来创建CacheManager。这个优先级往往会落在Redis上。

表现就是你配置了spring.cache.caffeine.spec,接口一直正常,但缓存的淘汰行为完全不对,甚至内存中根本没有Caffeine实例。查看CacheManager类型就会发现你拿到的其实是RedisCacheManager

解决办法很简单,在配置里强制指定类型:

spring: cache: type: caffeine

这个坑不算Caffeine的特殊问题,但一旦踩到,很容易让人怀疑自己的大小策略写错了,浪费好几个小时排查。建议一上来就显式声明。

4.5 问题速查表

现象可能原因处理方法
maximumSize看起来没生效spec/构建器在缓存创建后才设置提前声明缓存名,或在Bean方法中完成配置
缓存数量持续增长,内存告警weigher设置了但没设maximumWeight必须成对设置
启动报错maximumSize和maximumWeight冲突二选一
动态缓存名非常多cacheNames拼接了动态参数改为固定缓存名 + key参数
Caffeine缓存完全没创建和Redis冲突,自动探测选中了Redis显式配置spring.cache.type: caffeine
缓存满了但数量没马上下降淘汰是异步执行稍等片刻再观察,或减少entry超限测试幅度

5. 监控与调优:如何判断缓存大小配置是否合理

5.1 开启recordStats获得Caffeine真实统计数据

光配置完还不行,你要能证明它有效。Caffeine默认不统计访问数据,cache.stats()返回的都是空值。想拿到命中率、淘汰数等指标,必须在构建Caffeine时开启recordStats()

如果是编程式配置,直接加一个方法调用即可:

Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build();

如果你用的是spring.cache.caffeine.spec,也可以直接在spec里加上recordStats

spring: cache: caffeine: spec: maximumSize=500,expireAfterWrite=10m,recordStats

拿到CacheManager之后,可以这样获取缓存统计信息:

CaffeineCache caffeineCache = (CaffeineCache) cacheManager.getCache("userCache"); com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache = caffeineCache.getNativeCache(); CacheStats stats = nativeCache.stats(); System.out.println("命中率: " + stats.hitRate()); System.out.println("被淘汰条数: " + stats.evictionCount()); System.out.println("请求总数: " + stats.requestCount()); System.out.println("加载成功次数: " + stats.loadSuccessCount());

CacheStats返回的是一个快照对象,多次读取结果一致,不会实时变化,所以适合定时采样或者对比两个时间点之间的差值。

5.2 用Actuator暴露缓存指标并在请求中观察

Spring Boot Actuator默认会暴露一部分Cache相关的指标,比如cache.getscache.putscache.evictions等。如果你配置了management.endpoints.web.exposure.include=metrics,就可以直接访问/actuator/metrics/cache.gets查看数据。

但要注意,这些指标走的是Spring Cache抽象层,粒度是缓存名和CacheManager,并不能完整替代Caffeine自身的CacheStats。如果你想知道Caffeine内部的命中率、加载时间、淘汰原因,还是要靠recordStats()

我一般建议的监控方案是:写一个定时任务,每隔几十秒采集一次所有缓存实例的CacheStats快照,把命中率、淘汰数、估算大小输出到日志或者上报到监控平台。这样当缓存容量设置不合理时,你能在出现OOM之前看到淘汰数异常飙升。

5.3 调整容量时的经验公式与实操心得

很多同学会问:“缓存容量到底设置多少合适?”这个问题没有标准答案,但有个经验公式可以帮你在初期给一个合理起点:

预估容量 = 高峰期每秒请求数 × 单次请求对应的缓存数据平均活跃时长 × 重复访问比例系数

举个例子,假设某个接口高峰期QPS是2000,缓存数据在30秒内会有较高的重复访问概率,那么这30秒内最多可能出现60000次不同的数据请求命中,考虑重复访问比例系数0.3,缓存容量可以起步设在60000 × 0.3 = 18000条左右。再结合单条数据大小,估算出内存占用,留出30%以上余量,然后观察实际命中率和淘汰率,逐步调整。

调整的时候,我习惯关注两个关键信号:

  • 如果命中率长期在95%以上,说明容量比较充裕,可以考虑适当降低容量省内存。
  • 如果命中率经常低于80%,且淘汰数很高,说明容量偏小,或者过期时间太短,导致数据被过早逐出。
  • 如果命中率不低、淘汰数也不高,但内存增长明显,那就要检查是不是缓存名泛滥、数据对象本身太大、或者有没有未设置过期策略。

另外,initialCapacity这个参数很好用,但很多人会忽略。它不限制缓存大小,只是给内部的哈希表一个预分配空间。如果你预估容量在1万条左右,设置initialCapacity(1000)initialCapacity(10000)可以减少扩容带来的性能损耗。注意不要设得太夸张,否则启动时就会占用较多内存。

根据我个人的经验,Caffeine的大小策略出问题,绝大多数不是Caffeine本身的问题,而是使用方把它的生命周期和Spring管理对象的生命周期搞混了。所以我现在接手一个新项目,第一件事就是先看CacheManager是怎么创建的、缓存名是否收敛,这比调maximumSize本身更重要。建议你也把缓存配置收敛到一个配置类里,列出所有缓存名和容量规划,再谈监控和调优。这步做完,后面会省心很多。

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

配电网可靠性指标的线性规划快速求解方法

简介&#xff1a;本资源是一份面向电力系统专业研究生、科研人员及配电网优化方向工程师的学术复现资料&#xff0c;聚焦于基于线性规划的非仿真类配电网可靠性评估方法。它完整复现了2018年发表于IEEE TRANSACTIONS ON SMART GRID的开创性论文《Reliability Assessment for Di…

作者头像 李华
网站建设 2026/9/10 2:59:16

低代码列表引擎字段样式配置:从数据展示到动态渲染的实践指南

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

作者头像 李华
网站建设 2026/9/10 2:57:49

CANN/GE动态输入索引获取API

GetDynamicInputIndexesByName 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTor…

作者头像 李华
网站建设 2026/9/10 2:57:47

CANN/GE自定义算子融合Pass样例

样例使用指导 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、TensorFlow 前…

作者头像 李华
网站建设 2026/9/10 2:56:10

Kahn算法详解:拓扑排序原理、C语言实现与工程场景应用

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

作者头像 李华