前言
最近在使用 Halo 博客时遇到了一个棘手的问题:回收站里的旧文章既不能恢复,也不能彻底删除,界面一直显示"恢复中"或"删除中",刷新无数次也无济于事,这可真是让有强迫型思维的我难以接受。
经过一番排查,我发现问题的根源在于 Halo 2.x 的数据模型与"终结器"机制。本文完整记录我从定位问题到最终解决的整个过程,希望能帮助遇到同样问题的朋友。
问题背景
- Halo 版本:2.23.0
- MySQL 版本:8.4.8
- 现象 :
- 近期发布的文章在回收站中可以正常删除或恢复。
- 很久以前(从旧版本升级而来)的文章卡在回收站,操作后一直转圈,无法完成删除或恢复。
- 相关报错 :系统输出日志中出现
SearchEngineUnavailableException: 400 BAD_REQUEST "Search Engine is unavailable."
java
# Halo内置搜索引擎报错
run.halo.app.search.SearchEngineUnavailableException: 400 BAD_REQUEST "Search Engine is unavailable."
at reactor.core.publisher.MonoErrorSupplied.subscribe(MonoErrorSupplied.java:56) ~[reactor-core-3.8.3.jar:3.8.3]
at reactor.core.publisher.Mono.subscribe(Mono.java:4569) ~[reactor-core-3.8.3.jar:3.8.3]
at reactor.core.publisher.FluxSwitchIfEmpty$SwitchIfEmptySubscriber.onComplete(FluxSwitchIfEmpty.java:83) ~[reactor-core-3.8.3.jar:3.8.3]
at reactor.core.publisher.FluxFilter$FilterSubscriber.onComplete(FluxFilter.java:167) ~[reactor-core-3.8.3.jar:3.8.3]
at reactor.core.publisher.MonoNext$NextSubscriber.onComplete(MonoNext.java:103) ~[reactor-core-3.8.3.jar:3.8.3]
at reactor.core.publisher.Operators.complete(Operators.java:137) ~[reactor-core-3.8.3.jar:3.8.3]
at reactor.core.publisher.FluxFlatMap.trySubscribeScalarMap(FluxFlatMap.java:146) ~[reactor-core-3.8.3.jar:3.8.3]
at reactor.core.publisher.MonoFlatMapMany.subscribeOrReturn(MonoFlatMapMany.java:49) ~[reactor-core-3.8.3.jar:3.8.3]
at reactor.core.publisher.Mono.subscribe(Mono.java:4553) ~[reactor-core-3.8.3.jar:3.8.3]
at reactor.core.publisher.Mono.blockOptional(Mono.java:1853) ~[reactor-core-3.8.3.jar:3.8.3]
at run.halo.app.search.HaloDocumentEventsListener.onApplicationEvent(HaloDocumentEventsListener.java:62) ~[classes/:2.23.0]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Unknown Source) ~[na:na]
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:359) ~[spring-aop-7.0.5.jar:7.0.5]
at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:190) ~[spring-aop-7.0.5.jar:7.0.5]
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:158) ~[spring-aop-7.0.5.jar:7.0.5]
at org.springframework.aop.interceptor.AsyncExecutionInterceptor.lambda$invoke$0(AsyncExecutionInterceptor.java:112) ~[spring-aop-7.0.5.jar:7.0.5]
at java.base/java.util.concurrent.FutureTask.run(Unknown Source) ~[na:na]
at java.base/java.lang.VirtualThread.run(Unknown Source) ~[na:na]
Suppressed: java.lang.Exception: #block terminated with an error
at reactor.core.publisher.BlockingOptionalMonoSubscriber.blockingGet(BlockingOptionalMonoSubscriber.java:172) ~[reactor-core-3.8.3.jar:3.8.3]
at reactor.core.publisher.Mono.blockOptional(Mono.java:1854) ~[reactor-core-3.8.3.jar:3.8.3]
... 9 common frames omitted
原因分析
1. Halo 2.x 的数据存储方式
Halo 2.x 采用了全新的 Extension(自定义模型) 设计。与传统的多张关系表不同,所有数据(文章、页面、评论、配置等)都以 JSON 格式统一存储在 extensions 这一张核心表中。
这就意味着,我们无法像在 Halo 1.x 中那样直接操作 posts 表,而是需要学会解析 extensions 表中的 JSON 数据。
2. 终结器(Finalizers)机制
Kubernetes 用户对 Finalizers 一定不陌生,Halo 2.x 借鉴了这一设计。当一个资源(比如文章)被标记为删除时,系统会检查其 metadata.finalizers 字段。该字段是一个列表,里面可以包含多个"终结器",每个终结器代表一项必须在资源被真正删除前执行的清理任务(例如删除关联文件、清理搜索引擎索引、触发插件的钩子等)。
只有当 所有终结器都被执行并从列表中移除后,系统才会真正删除该资源。
3. 我的问题具体原因
通过直接查询数据库,我发现卡住的文章,其 JSON 数据中的 metadata.finalizers 字段长这样:
json
"finalizers": ["ai.halo.run/summary-cleanup"]
这个 ai.halo.run/summary-cleanup 终结器由某个 AI 摘要插件添加。很可能是因为该插件已被卸载,或者其清理任务执行失败,导致终结器一直未能被移除,从而让文章一直卡在了"删除中"或"恢复中"的状态。
解决步骤
步骤一:备份数据库(极其重要)
直接操作数据库存在风险,请务必提前备份!你可以使用 Halo 后台自带的"系统 → 备份"功能,或者直接通过数据库管理工具导出完整备份。
步骤二:确定数据库类型并连接
Halo 2.x 默认使用 H2 嵌入式数据库,但很多生产环境会改用 MySQL 或 PostgreSQL。你需要先确认自己的数据库类型,并使用相应的客户端工具连接。
步骤三:定位问题文章
由于文章数据存储在 extensions 表中,保存文章信息的name字段值的格式是/registry/content.halo.run/posts/+文章ID,我们需要先找到需要删除的文章ID,我这里是在控制台页面打开浏览器开发人员工具获取到的,如下图所示。 
获取到文章ID后,可以用以下的SQL语句查询出对应的记录(实际查询时记得替换为你的文章ID):
js
SELECT * FROM `extensions` WHERE `name` LIKE '%/registry/content.halo.run/posts/你的文章ID%'

步骤四:手动清空终结器列表
定位到文章后,接下来就需要将问题文章数据中的 metadata.finalizers 的值改为空数组 []。我这里使用的工具是phpmyadmin,可以方便下载和上传数据,把对应记录的数据下载下来后,找到"finalizers"数组并请空[]中内容,如下图所示。 
清空终结器列表后保存数据并重新上传到对应文章覆盖,可以发现数据中只有文章的信息,没有文章的正文内容,所以可以肯定文章的正文内容保存在了其他地方,为防止文章关联数据残留,我这里清空终结器列表后,还是在控制台页面尝试删除问题文章的,此时就可以发现能成功删除了,重启Halo服务后,原来系统日志报的错:SearchEngineUnavailableException: 400 BAD_REQUEST "Search Engine is unavailable."也消失了。
结语
Halo 2.x 的 Extension 架构赋予了系统极高的灵活性,但也带来了排查问题的新挑战。希望本文记录的经验能够帮助你在遇到类似情况时少走弯路。
如果你有更好的解决方案,或者遇到了其他棘手的问题,欢迎在评论区留言交流!
