一、一次慢日志为什么会拖住本来很快的请求业务处理只花十几毫秒接口却偶尔卡到数百毫秒。监控显示数据库查询和计算都正常最后追到一条看似无关的日志请求线程把一段内容写到网络日志接收器网络等待使处理函数没法及时返回。开发者常把日志视为“顺手记一下”实际上普通日志处理器是在调用日志的线程里执行输出操作磁盘、网络和格式化开销都可能直接计入请求延迟。在并发服务中这个问题还会放大。多个线程共享同一个处理器处理器内部可能需要串行化访问即使单条输出只慢几十毫秒集中爆发也会把等待排成队列。把日志级别调高可能暂时减轻压力但无法解决错误级别的告警恰好最慢的情况。尤其发生异常时堆栈格式化和远程转发往往更复杂不能仅凭平均响应时间判断日志没有影响。Python 标准库提供 QueueHandler 和 QueueListener 作为一对协作组件。业务线程把日志记录放进内存队列专门的监听线程从队列消费再交给真正的文件、控制台或网络处理器。改变的是耗时操作所在的线程而不是日志本身的业务语义。它不会自动保证日志绝不丢失也不能把很慢的接收方变快。本文从可运行的对照试验开始逐步分析排队、容量、丢弃、异常信息和退出清理。示例只使用 Python 标准库适用于 Python 3.11 到 3.13 的核心接口另行指出 Python 3.14 新增的上下文管理器写法。所有性能数字都以代码运行结果为准不将示例的固定延迟当作生产环境的基准。二、弄清生产端与消费端各自承担什么QueueHandler 本身是 logging.Handler 的子类接收 LogRecord 后调用 prepare再把记录交给 enqueue。默认 enqueue 使用 put_nowait因此把日志放入有容量上限的队列时如果队列已满就有触发 queue.Full 的可能。这里“写日志成功返回”最多说明记录完成了生产端处理不代表它已经到达磁盘或远程服务。应用需要把生产成功和最终写出作为两个不同事件。QueueListener 接收同一个队列和一个或多个真正输出日志的 handler。调用 start 后它使用后台线程等待记录将每条记录依次交给配置的处理器。在正常结束时调用 stop监听器会发出退出哨兵并等待线程收尾。这个动作帮助已在队列里的记录被处理但不能替代进程崩溃后的持久化恢复也不适合把它误称为事务提交。这套结构主要适合请求处理线程不能忍受慢输出的场景。日志输出被移走后队列本身成为需要观察的缓冲区。如果生产速度持续高于消费速度内存队列就会积压选择无上限队列只是推迟瓶颈选择有上限队列则必须制定满载时行为。团队应先决定最重要的是延迟、完整性还是资源边界再决定队列大小与溢出策略。一个常见配置错误是把原有的慢 FileHandler 保留在业务 logger 上同时又加上 QueueHandler。日志记录会先后触发这两个处理器慢路径仍在业务线程执行。正确配置应让关键请求线程只挂队列处理器真正输出处理器由监听器独占必要时再用独立的根日志策略处理其他模块避免重复输出和意外传播。三、最小实测输出慢四十毫秒生产十条日志要多久下面这段程序构造一个故意睡眠四十毫秒的日志输出处理器模拟远程写入或慢磁盘。生产端只把十条记录交给无界队列监听器随后完成全部输出。程序打印生产阶段毫秒数以及最终记录条数并断言十条记录都得到处理。为了隔离其他库的根日志配置示例创建专用 logger关闭 propagate运行完再移除处理器。请注意这里的四十毫秒属于人为设定的慢处理器而不是标准库 QueueListener 的内在延迟。生产阶段只计算发出十条 logger.info 的时间不包含 stop 等待监听器清空队列的时间这两个计时口径不能混为一谈。等待 listener.stop 返回后再检查数量才能验证当前进程正常结束时队列中的内容已经送到输出处理器。import logging import queue import time from logging.handlers import QueueHandler, QueueListener class SlowSink(logging.Handler): def __init__(self): super().__init__() self.items [] def emit(self, record): time.sleep(0.04) self.items.append(self.format(record)) events queue.Queue() sink SlowSink() listener QueueListener(events, sink) log logging.getLogger(queue_demo_fourth) log.handlers.clear() log.setLevel(logging.INFO) log.propagate False log.addHandler(QueueHandler(events)) listener.start() start time.perf_counter() for number in range(10): log.info(event-%02d, number) produce_ms (time.perf_counter() - start) * 1000 listener.stop() assert len(sink.items) 10 print(produce_ms:, round(produce_ms, 2)) print(written:, len(sink.items)) log.handlers.clear()四、运行结果应该怎么读不能怎么读执行示例后生产阶段一般会明显短于十次四十毫秒睡眠的总和随后 stop 等待监听器处理完剩余记录。这里展示的是消除生产线程里的慢 handler 等待不是系统总耗时凭空消失。消费端仍然需要处理十条记录处理速度上限没有改变。若把监听器也纳入请求路径并在每次请求结束时 stop那么反而可能重新引入等待违背队列分离的初衷。另一个重要边界是 LogRecord 在进入队列之前的成本。判断日志级别、构造消息参数、生成时间和调用位置信息依然可能发生在业务线程。默认 QueueHandler.prepare 会对记录作适配通常先将消息格式化成适合队列传递的内容。因此不能把“用了队列”理解成所有日志 CPU 工作都移到了消费线程。非常大的对象转字符串、复杂自定义过滤器仍可能影响请求时延。要做准确基准应把无队列与有队列两种结构使用同样的消息数量、相同处理器、相同级别和相同并发负载分别测试。记录请求端 p50、p95、p99 延迟和监听器完成全部记录的总时间既观察延迟改善也确认成本没有被不合理地转移到内存。单次本机输出的漂亮数字不能外推到磁盘阵列或远程日志平台。建议将实验中的 sleep 看作慢下游的可控故障注入。生产服务里还需要观察 handler 内部锁等待、网络建立连接、接收方限流以及异常处理策略这些时间来源与人为休眠不完全相同。决定上线前先用真实业务的日志大小和峰值速率验证队列尺寸并明确负载持续增长时的退化行为。五、队列满了怎么办先验证默认路径是否会丢有上限的队列能够保护内存但默认 QueueHandler.enqueue 使用非阻塞 put_nowait。当队列装满时处理器的 handleError 路径可能报错或静默忽略具体可见性还受 logging.raiseExceptions 等配置影响。误以为调用 logger.error 没抛异常就证明日志已经保存会导致故障时最需要的记录反而消失。日志完整性必须通过接收量与输出量的差值判断。下面的示例不启动消费者而是给容量为二的队列连续提交五条日志。它刻意实现一个计数型队列处理器成功入队最多两条其余被明确计为丢弃。断言只是验证本地满载机制不意味着任何项目都应该静默丢日志。可运行代码比口头说“队列大概会满”更有帮助因为它把容量、发送量和最终结果变成一组可重复检查的数字。示例中的丢弃计数发生在内存对象里进程退出后不会自动保留。在真实服务中这个计数应接入持久化或外部指标系统并且不要在队列已满时再向同一个满队列写一条“丢日志”告警否则告警自身也会陷入相同故障。可以用独立轻量指标通道记录计数或按周期输出被聚合的异常状态。import logging import queue from logging.handlers import QueueHandler class CountDrops(QueueHandler): def __init__(self, target): super().__init__(target) self.dropped 0 def enqueue(self, record): try: self.queue.put_nowait(record) except queue.Full: self.dropped 1 buffer queue.Queue(maxsize2) handler CountDrops(buffer) log logging.getLogger(bounded_log_demo_fourth) log.handlers.clear() log.propagate False log.setLevel(logging.INFO) log.addHandler(handler) for number in range(5): log.info(event-%s, number) print(queued:, buffer.qsize(), dropped:, handler.dropped) assert buffer.qsize() 2 assert handler.dropped 3 log.handlers.clear()六、最容易写错的退出顺序先停还是先产生日志调用 QueueListener.stop 时监听器通过队列中的哨兵退出并等待后台线程结束。这要求业务线程已经停止向同一队列持续提交新记录。如果在 stop 之后仍有任务写日志新的记录可能留在队列里无人处理。正确的退出顺序应当是先停止接收新请求等待正在处理的业务完成或进入明确的取消路径然后阻止新的生产者写入最后停止监听器并关闭它使用的真正输出处理器。若队列有容量上限还要特别注意退出哨兵本身如何入队。标准实现默认使用 put_nowait 放入哨兵满队列情况下可能发生 queue.Full。把它改成永久阻塞的 put 也有代价监听线程若被慢网络卡住退出过程就可能一直无法完成。对于必须完整保留审计事件的场景应该考虑持久化队列、独立进程或可靠日志代理而不是仅靠内存队列停止动作实现强保证。在服务管理器给出固定停机宽限期时应把等待业务完成与冲刷日志分别配置时间预算。假如容器收到终止通知后仅剩很短的时间应用应优先确保核心业务事务状态与必要审计记录已保存。普通调试日志可以采用可观测的有界退化策略但不能未经审查就把结算、权限变更等关键审计事实放到允许丢失的普通日志通道。Python 3.14 为 QueueListener 增加了 with 上下文管理器使用方式进入时 start、退出时 stop。较旧版本仍可以显式调用 start 和 stop本文示例因此保持兼容。with 可以减少忘记停止监听器的机会但它并不解决下游长期阻塞、队列满载或者进程被强制终止这些更高层级的问题。七、异常堆栈能否完整传过去prepare 做了什么日志通常包含消息模板、参数、异常堆栈和自定义上下文字段。标准 QueueHandler.prepare 默认会格式化消息并调整传递给队列的 LogRecord使其更适合被序列化或交给其他处理器。这个过程可能清空 args、exc_info 等原始字段。因此若下游处理器需要重新按结构化字段渲染异常必须先检查 prepare 的真实行为而不是相信队列两端拿到的是完全相同的对象。一种常见需求是在接收端输出 JSON把请求标识、错误类别、堆栈文本分成独立字段。如果生产端已经把异常格式化并清空了原始元信息消费端可能无法再次访问完整的 exc_info 元组。可以按文档扩展 QueueHandler.prepare在投递前提取明确需要的字符串字段或者设计专用的可序列化事件对象。不过扩展实现应避免把无法序列化的连接句柄、请求对象乃至敏感数据一起传入。不要在消费者里假定任何日志内容都可信。日志消息可能含换行、控制字符或由外部输入构成的字段。把它们写入 JSON 时要使用正确的编码器让格式化由可信代码完成若日志会进入终端、搜索索引或告警系统还需要限制异常长的值并做敏感字段脱敏。这些工作与队列并无直接关系但异步输出往往把原先分散在多个线程的格式化责任集中到单一监听器值得趁迁移一并厘清。测试时可以抛出一个固定异常比较直接输出与队列输出的错误类型、堆栈首尾以及业务上下文字段。真正重要的是团队查询故障时所需的信息有没有保留下来而不是逐字节比较两种渲染方式。只要下游对字段语义有依赖就应当把这份依赖写进测试合同并在更新 Python 版本或日志处理器时回归。八、handler级别和logger级别不是同一层门禁配置多个下游 handler 时例如把 INFO 写入本地文件、把 ERROR 发往告警服务必须同时理解 logger 级别与 handler 级别。logger 首先决定一条记录是否参与处理handler 再决定自己是否接收这条记录。QueueListener 的 respect_handler_level 参数默认是 False这意味着监听器在默认模式下不会替各个 handler 按自身 level 再做一遍相同的筛选。如果希望队列监听器真正尊重每个消费处理器的 level应显式设置 respect_handler_levelTrue并给每个输出处理器配置相应级别。否则看起来只应该发送 ERROR 的远程处理器也可能收到 INFO。更严谨的方式是添加一个包含 INFO、WARNING、ERROR 的小型测试分别记录每个处理器看到的事件再确认与策略一致。即便启用了 respect_handler_level日志仍可能因为传播而重复。logging 的 logger 具有层级结构子 logger 默认可能把记录向父 logger 传播。如果子 logger 和根 logger 都挂载了独立输出处理器或者一个挂了队列、另一个又直接写文件就会产生重复与不必要的阻塞。处理这种问题时先画出 logger、handler 和 propagate 的关系再检查队列数量。最后要避免把 handler 级别当成隐私控制。日志采集前就不应拼装密钥、会话令牌或其他敏感字段即使某个 handler 最终因为级别没有输出记录可能仍被其他处理器接收。安全的日志规则需要在生成和过滤记录的阶段就约束敏感信息而不是把希望寄托在消费端某个级别开关上。九、不要把线程方案直接复制到多进程QueueHandler 和 QueueListener 可以服务于线程化应用但多进程场景涉及队列对象是否能跨进程共享、处理器是否安全以及子进程退出回收等额外约束。Python 文档提示使用 multiprocessing 时应考虑 multiprocessing.Queue不应把 queue.SimpleQueue 直接当作多进程替代物。复制单进程测试能运行的代码并不能证明多进程日志链路在不同启动方法下也可用。多进程还有一条容易踩中的递归路径multiprocessing 自身可能产生 DEBUG 级内部日志如果这些内部日志又通过同一个 multiprocessing.Queue 交给 QueueHandler 发送就可能形成死锁或无限递归。官方处理器文档专门提醒过这种情况。因此不要把进程间通信用的队列和记录该队列内部行为的日志链路混为一谈。如果工作者进程很多可以让它们通过专门的日志队列向一个拥有文件处理器的集中监听进程发送事件但要明确启动次序、进程退出次序和接收者故障策略。一个更简单的部署选择是每个进程向标准输出写结构化日志让外部日志采集器负责汇聚这样至少可以把文件并发写入和进程内服务生命周期分开处理具体方案仍取决于平台提供的可靠性保证。选择方案时不要只问写文件是否有线程锁还要问跨进程是否有统一序列、文件轮转时谁持有句柄、采集进程异常时业务是否继续以及日志丢失如何报警。单进程 QueueListener 只对自己的队列和监听线程负责不提供一个覆盖所有进程的自动顺序保证。十、慢消费者产生的延迟如何用指标说清楚系统上线后至少记录四项指标生产速率、消费速率、队列当前积压和丢弃计数。前两项能够识别短时突发与持续超载积压显示尚未写出的记录数量丢弃计数说明是否已经触发容量策略。只有处理时长或日志总量而没有队列积压往往看不到服务内部已经存在的债务。估算容量时不要只看平稳时的平均值。假设正常状态每秒产生一百条、监听器每秒处理一百二十条看起来余量足够如果故障后业务瞬间产生五倍日志而且故障持续数十秒原来宽裕的队列也可能填满。更糟糕的是网络日志接收端在故障时响应更慢消费速度与生产速度会朝相反方向变化。应给日志体积设上限并定期核查最大记录大小。队列容量以条数为单位不意味着占用内存固定一个包含巨大堆栈和请求体的日志记录可能远比普通 INFO 消息占内存。对于无界队列必须把进程常驻内存与延迟一起监控对于有界队列则要把积压率和丢弃率与用户请求延迟放在同一图表上观察。告警规则也要区分“下游偶发慢”和“系统已无法稳定记录故障”。少量瞬时积压可以留给队列吸收持续积压上升、连续丢弃或监听线程异常才是需要介入的信号。不要通过反复调大队列阈值掩盖消费端吞吐不足队列只能缓冲不能替代对处理器阻塞原因的调查。十一、迁移旧系统时先做四项回归第一项是内容一致性。用固定请求标识生成包含不同等级、中文、异常堆栈和可选字段的日志比较旧处理器与新监听器最终写出的语义。第二项是不会重复。确认每条记录只由一条生产路径入队父 logger 不再多挂一份同类输出不能因为换了组件就让业务日志数量翻倍。第三项是故障行为。人为让消费处理器延迟并让有界队列达到上限检查告警指标是否能明确告诉运维哪些日志已经丢弃、哪些仍在等待。不要依赖 logging.raiseExceptions 的某个开发默认值来充当生产告警机制显式埋点比隐式报错更容易核验。第四项是退出完整性。停止新的业务写入后正常调用 listener.stop观察队列里的已有事件能否在规定时间内完成处理。再通过受控测试模拟突然终止确定哪些记录确实会丢失并把不可接受的关键审计事件迁移到独立可靠存储。两类测试不能混为一谈正常停止成功并不等于断电恢复成功。迁移步骤最好做到可回滚保留旧配置文本先只替换一个服务或一个专用 logger验证完整性和延迟后再扩大范围。不要在一次修改中同时更换消息格式、日志级别、网络采集器和队列实现否则任何异常都很难归因。运行中若出现积压只回滚明确影响的配置保留故障期间的指标与被丢弃计数。十二、结论日志离开请求线程不等于日志已经可靠落盘QueueHandler 加 QueueListener 是 Python 标准库提供的清晰隔离手段生产者快速提交记录消费者承担慢输出。对网络处理器和可能阻塞事件循环的日志路径它通常比让业务线程直接等待远端输出更容易控制请求延迟。但系统仍要为缓冲容量、消费异常、停机时序、等级过滤和多进程边界负责。落地时最重要的三条规则是请求线程不再挂慢处理器队列满载有可观测的明确策略服务停机先停止生产再回收监听器。对于需要持久化证明的审计记录不能把进程内存队列视为强可靠消息系统。对于普通应用日志则应该用真实流量与故障注入确定延迟收益是否值得增加这些维护成本。核心接口依据 Python 官方 logging.handlers 文档与 Logging Cookbook相关链接https://docs.python.org/3/library/logging.handlers.html 以及 https://docs.python.org/3/howto/logging-cookbook.html 。Python 3.14 的 QueueListener 上下文管理器属于额外版本功能本文实际代码采用较早版本也支持的显式 start、stop。读者可在自己的 Python 环境中运行两段脚本用可复现断言检验异步写日志的边界。
企业数字化 ERP 产品动态
相关推荐
意大利商超收银软件合规全解:电子发票、电子小票与税控绑定 意大利商超收银软件的核心不是“能不能结账”,而是“合不合规”。本文从电子发票、电子小票、税控设备绑定三个硬要求出发,逐条拆解意大利财税合规要点,并结合面向华人商户的收银方案,给出可落地的选型清单。1.三大合规硬要求电子… · 2026/11/5 3:53:29
毕设选题分享|高校科研信息管理系统,源码+论文资料免费领取! 还在为毕业设计发愁的同学,可以看看今天分享的这个项目!
尤其是计算机、软件工程相关专业的同学,如果不想做太简单的学生管理系统,又担心选题太复杂、最后做不出来,高校科研信息管理系统是一个值得研究的方向。
它有比… · 2026/11/5 3:53:28
Pandas vs Excel vs SQL:同一个需求,三种写法 写在前面
同一个需求,工具选错会浪费很多时间:
几百行数据做一次临时统计,用 Pandas 写脚本,可能比 Excel 拖两下还慢;三千万行数据在 Excel 里筛,卡到怀疑人生,而这正是 SQL 的主场;… · 2026/11/5 3:52:28
量子神经混合计算:72维超智能系统架构解析 1. 项目概述:量子-神经混合超智能生态系统的诞生背景在当代科技发展的最前沿,量子计算与神经科学的交叉融合正在催生新一代智能系统架构。7FLOOR Seventy-Two-Pointed Star(72星)超维工程系统正是这一趋势下的代表性产物。这个系统… · 2026/11/5 4:41:54
X平台开源推荐算法解析与工程实践 1. 开源推荐算法的行业影响解析当全球最具影响力的社交平台之一决定公开其核心算法时,这不仅仅是一次技术透明化的尝试,更是对内容分发行业的一次颠覆性冲击。作为从业十余年的推荐系统工程师,我亲历过多个主流平台的算法迭代周期,… · 2026/11/5 4:41:54
AI语音交互革新:大模型如何提升智能客服体验 1. 项目概述:AI语音交互的范式革新去年夏天,我参与了一个智能客服系统的改造项目。当时客户抱怨最多的问题是:"为什么机器人总是答非所问?"这个痛点在传统语音交互领域普遍存在——系统能识别语音转文字,却无… · 2026/11/5 4:41:54
专科生必备的9款AI工具:降低门槛提升效率 1. 项目概述作为一名在AI工具领域摸爬滚打多年的从业者,我深知专科生在学习和工作中使用AI工具时面临的独特挑战。专科教育更注重实践技能培养,但往往缺乏系统的AI理论学习,这就导致很多同学在使用AI工具时容易陷入"会用但不懂原理"… · 2026/11/5 4:41:54
轻量级Raw Socket流量监控系统:WinXP兼容、无驱动、单文件抓包 简介:这是一套基于Visual C开发的Windows平台网络流量监控系统源码,面向C中级开发者、网络编程学习者及网络安全实践人员,用于理解数据包捕获、协议解析、实时流量统计与MFC界面集成等核心能力。资源共24个文件,含8个头文件&#… · 2026/11/5 4:41:54
零基础3个月速成网络安全工程师实战指南 1. 项目概述作为一名在网络安全领域摸爬滚打多年的从业者,我经常被问到同一个问题:"零基础如何快速入门网络安全?"今天我就把自己当年用3个月时间从完全不懂到能够独立完成基础渗透测试的学习路径完整分享出来。这份清单之所以被称… · 2026/11/5 4:40:54
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38
多智能体协作系统架构设计与实践优化 1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39
大模型系统性入门实战指南:从原理到部署微调的全链路路线 提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39
鸿蒙文件系统沙箱机制解析与开发实践 1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38