404E Blog

AI 全栈 · ToB 开发 · HomeLab

从 XJSX 到 Mermaid:我为什么不再让 LLM 在对话里生成复杂 UI

AI 工程

最开始做对话里的可视化,是因为看到了 CodePilot 这个项目。

它的方案是在对话中直接生成 HTML / SVG,看起来非常唬人。模型一边输出,页面一边出现各种组件、图形、卡片和布局,第一眼看上去很像未来的交互方式。

但是实际用了一段时间后,问题很快暴露出来:LLM 会把大量 token 浪费在内嵌样式和绝对定位上。

它会不停写 style="left: 32px; top: 18px" 之类的东西,还会手搓一堆颜色、阴影、圆角、布局宽高。结果是上下文被样式污染,生成成本变高,最后画出来的东西还很粗糙。尤其是复杂一点的页面,实际看的时候总是一眼能看出有地方做的没对上。

这件事让我产生了一个想法:既然 LLM 不擅长设计细节,那就不要让它设计细节。让它只写结构。

第一版:让模型写 JSX,而不是 HTML

我的第一版方案是走 JSX 组件。

系统预先提供一部分组件,比如文本、卡片、表格、图表、Mermaid、分栏、区块之类的东西。LLM 不需要写完整 HTML,也不需要写一堆内嵌样式,只需要写类似这样的结构:

jsx
return (
  <Section title="风险概览">
    <Text md={refs.summary} />
    <Chart config={refs.trend} />
  </Section>
);

这样做的好处很明显:

  • 样式由系统统一控制,不让模型随便发挥。
  • 组件能力可以被限制,减少安全风险。
  • token 主要花在内容和结构上,而不是花在 CSS 上。
  • UI 的整体风格比较稳定,不会每次回答都像换了一个设计师。

这个方向一开始效果还不错。至少比直接生成 HTML / SVG 稳定得多,也更符合对话产品的需求。

但很快又遇到了新的问题:流式输出。

如果模型要先写完完整 JSX,前端才能渲染,那用户在等待过程中只能看源码。这个体验不好。尤其是图表、流程图、长文本这些内容,本来可以先展示出来,却因为布局代码还没生成完,只能等到最后。

于是我把内容和布局拆开了。

第二版:把图表、Mermaid 和文本块拆成最小引用单元

当时比较常见的内容有三类:

  • ECharts 图表配置。
  • Mermaid 流程图、时序图、关系图。
  • Markdown 文本块。

这些东西不一定非要等 JSX 布局完成之后才能看。只要某个内容块生成完整,前端就可以先预览它。

所以我设计了一套最小单元引用语法。模型先输出内容块:

纯文本
```xjsx
---ref:markdown:summary---
这里是一段分析摘要

---ref:mermaid:flow---
flowchart TD
  A[开始] --> B[处理]
```

然后最后再输出 JSX,把这些内容组装起来:

纯文本
```jsx
return (
  <Section title="处理流程">
    <Text md={refs.summary} />
    <Flowchart def={refs.flow} />
  </Section>
);
```

这样流式体验就好很多。文本块、图表、Mermaid 都可以逐步出现,最后 JSX 只负责布局。

但继续跑下去,又发现另一个问题:并不是所有内容都应该进入这套协议。

有些回答就是普通 Markdown。比如解释一个问题、列几个步骤、写一个结论,根本不需要组件系统。如果强行让模型都走 JSX,就会把简单问题复杂化。

所以我把这套语法放进了 xjsx 代码块。只有模型明确输出:

纯文本
```xjsx
...
```

前端才会按 XJSX 解析。其他内容仍然按普通 Markdown 渲染。

这就是 XJSX 的第一阶段:它不是为了替代 Markdown,而是给“确实需要结构化展示”的回答提供一个更强的表达方式。

问题开始变多

XJSX 跑起来后,确实能做出一些不错的效果。但随着使用次数变多,错误率也开始变得明显。

最容易理解的问题是语法错误。模型会少写一个括号,少闭合一个标签,或者生成一个不存在的组件。比如系统里只有 Flowchart,它可能写成 MermaidChart;系统里要求 ---ref:charts:userdata---,它可能写成 ---ref:data:userdata---

这类问题看起来可以靠提示词解决,但实际只能缓解,不能根治。

更麻烦的是代码块边界问题。

XJSX 本身在 Markdown 代码块里,但 XJSX 内部又可能包含 Markdown 内容。只要内部 Markdown 里也出现同样数量的反引号,外层代码块就会被提前结束。这个问题非常烦,因为它不是业务逻辑错,而是 Markdown 本身没写对。

我调了很久提示词,要求模型内部不要输出同级别反引号,也在前端做了兜底修复,才勉强把“代码块提前结束”的问题压下去。但这类修复很脆弱,因为模型只要换一种写法,问题又会回来。

还有 ECharts 配置错误。模型会生成不存在的图表配置字段,或者在 dataset、encode、series 之间写出互相对不上的结构。前端可以做兼容,但兼容太多之后,协议就会越来越混乱。

这类错误还有一个麻烦点:它不是后端拿到文本时就一定能判断出来。XJSX 的设计里,图表配置最终要交给前端组件和 ECharts 实例执行,很多问题只有真正渲染时才会暴露。也就是说,系统必须等前端完成一次渲染,再由前端把错误上报回来,后面才有机会进入修复流程。

于是后面加了 review + fix。

review + fix 也救不了所有问题

当时的思路是:既然模型第一次生成容易错,那就让 reviewer 针对特定方向并行检查,最后由 fixer 统一用行级补丁修复。

检查方向大概有:

  • XJSX 语法是否合法。
  • ref 有没有被 JSX 引用。
  • 图表配置是否能渲染。
  • 组件是否存在。
  • 布局和证据链是否合理。

其中“图表配置是否能渲染”不是一个纯静态检查。它依赖前端真实渲染后的错误上报:前端渲染 XJSX,ECharts 抛错或组件异常,再把错误信息带回到 review + fix 链路里。

这个方案能修一部分问题,但并没有想象中稳定。

比如一个内容块没有被 JSX 引用,正确修复方式应该是修改 JSX,把这个内容块接入 UI。可是 reviewer 可能会得出结论:这个内容块没有引用,所以应该删除。这样看起来“问题消失了”,实际上证据链被删掉了。

再比如 fixer 修完以后,新的代码依然可能有语法错误。它可能修好了一个标签,又引入了另一个括号问题。或者它为了修复图表,把配置改成一个前端仍然不能识别的结构。

还有一些幻觉问题更难处理。模型会生成不存在的代码块类型、组件名、组件属性。前端能做白名单校验,但校验失败后怎么修,仍然要靠模型。模型不一定知道系统里真实存在什么。

这时候我意识到,XJSX 最大的问题不是“错误太多”,而是它很难强校验。

它本质上仍然是一段自由生成的代码。可以做解析,可以做运行时限制,可以做前端兜底,可以做 reviewer 和 fixer,但很难像一个严格协议一样在生成时就把错误挡住。

最后就会变成:协议看起来很强,实际很多问题只能手动修。

这也引出了后来的 a2ui。

a2ui 看起来更正确,但实际更不稳定

后来同事 review 这块代码时提到 Google 有 a2ui 协议,并问为什么不用这个方案。

从人的角度看,a2ui 的方向确实更完善。它不是让模型直接生成一段 JSX,而是用工具调用逐步组装 UI。数据和 UI 分开,协议可校验,模型还可以在失败后自己检查和修复。

这听起来非常合理。

因为 XJSX 暴露出来的问题,很大一部分就是无法强校验。那如果换成结构化 JSON,加上严格 schema,是不是就能解决?

我调研并尝试之后,发现事情没有这么简单。

a2ui 把数据和 UI 分成两块,理论上更干净,实际会出现很多新的错误:

  • UI 没有引用已经生成的数据。
  • UI 引用了不存在的数据。
  • 数据结构改了,UI 没有同步改。
  • 某一步失败后模型反复重试,把前面已经生成好的部分又改坏。
  • 刚看到页面生成了一部分,下一段又被模型删掉或重组。
  • 最后提交出来的结果像是多次中间状态叠在一起,变成四不像。

这类问题和 XJSX 的语法错误不一样。它不是“某一行写错了”,而是模型没有稳定维护 UI 状态。

我当时用 Gemini 的时候发现 Gemini 自己的应用中UI生成也有类似的问题。页面经常闪烁、变化、损坏,刚生成出来的 UI 一会儿又消失。这个现象基本说明了一件事:协议本身再完善,如果模型没有针对这种表达形式训练过,它就很难稳定使用。

也就是说,a2ui 在工程师眼里是更正确的协议,但在当前模型眼里不一定是更容易的协议。

模型更擅长一次性写一段 Markdown,或者写一段相对完整的代码。它不擅长在多轮工具调用中维护一个复杂 UI 树的中间状态。尤其是数据和 UI 分开之后,引用关系变多,错误传播也更隐蔽。

这时我对这件事的判断变了。

之前我以为问题是“需要一个更强的 UI 协议”。后来发现,真正的问题可能是:对话里根本不应该承载这么复杂的 UI 生成。

对话里真的需要复杂 UI 吗

后来我不再参与原来的项目,自己写了一个 Agent 项目 RunForge,把这些反思带了进去。

在 RunForge 里,我最终选择了更克制的方案:

  • 默认使用 Markdown。
  • 需要流程、结构、关系、简单图表时使用 Mermaid。
  • 需要公式时使用 LaTeX。
  • 需要复杂报表、交互页面、可分享结果时,生成 HTML artifact 文件。

这套规则看起来没有 XJSX 那么酷,但更稳定。

因为大部分对话中的图表,并不是为了产出一份精美报告。很多时候只是为了“看一下数据大概是什么样”。用户需要的是快速理解趋势、结构、流程和关系,不需要一张经过精细设计的 ECharts 图。

这里更关键的判断是:LLM 不应该在对话里过多关注样式和展示。

它应该专注于回答用户问题,负责把数据、结构、关系和结论表达清楚。至于图表怎么渲染、颜色怎么选、间距怎么排、不同图表之间的视觉风格怎么统一,应该交给系统来做。这样图表的稳定性、准确性和样式一致性都更可控,也不会让模型把上下文浪费在展示细节上。

如果真的要给别人看,或者需要一个美观的 dashboard,那它就不应该塞在聊天气泡里。它应该是一个 artifact,一个独立的 HTML 或 Markdown 文档。这样用户可以打开、分享、保存,也可以让模型用更完整的上下文去生成页面,而不是在流式对话里一点点拼 UI。

Mermaid 的好处是,它不太给模型发挥样式的空间。

模型只需要表达节点、边、关系、时序、状态。它输出的是更接近“数据和结构”的东西,而不是一份带着大量样式决策的图表实现。样式交给前端统一处理,渲染交给系统完成。这样 token 不会浪费在颜色和绝对位置上,整个对话里的图表风格也更统一。

这和最开始做 XJSX 的动机其实是一致的:不要让 LLM 做它不擅长的事情。

只不过第一阶段的答案是“让它写 JSX 组件,不要写 HTML 样式”;后来的答案变成了“能用 Markdown/Mermaid 表达,就不要让它写 UI 组件”。

现在的边界

我现在对这类能力的边界大概是这样看的。

对话里的展示应该优先满足三个目标:

  1. 快速生成
  2. 稳定渲染
  3. 帮助理解

不应该优先追求:

  1. 每次都定制布局
  2. 每次都生成复杂组件树
  3. 在聊天气泡里做完整 dashboard

如果只是解释流程,用 Mermaid。

如果只是展示结构,用 Mermaid。

如果只是说明结论和证据,用 Markdown 表格和列表。

如果只是写公式,用 LaTeX。

如果真的需要复杂交互、筛选、联动、精美样式和可分享页面,再生成 HTML artifact。

这不是能力倒退,而是边界变清楚了。

LLM 不是不能生成 UI,但让它在对话中持续维护复杂 UI 协议,成本很高,稳定性也差。尤其当协议需要跨数据、布局、引用、状态、多次修复时,模型很容易在中间状态里迷路。

总结

XJSX 对我来说不是一个失败的方案。它验证了一个方向:直接让 LLM 生成 HTML/SVG 确实不靠谱,组件化和内容引用能明显降低样式浪费,也能改善流式预览。

但它也暴露了另一个问题:只要协议仍然依赖自由代码生成,就很难做到强校验。review + fix 可以缓解,但不能从根上解决语法、引用、组件幻觉和错误修复的问题。

a2ui 看起来解决了强校验问题,但又把问题转移到了状态维护和数据/UI 引用一致性上。当前模型对这种多次工具调用组装 UI 的形式并不擅长,甚至不如直接生成 Markdown/Mermaid 稳定。

所以最后的结论反而很简单:

对话里的可视化应该克制。能用 Markdown/Mermaid/LaTeX 表达的,就不要上复杂 UI 协议。需要交付和分享的,再生成 artifact。

这件事看起来是在选渲染方案,实际是在给 LLM 划工作边界。

不要把所有看起来炫的能力都塞进对话框里。很多时候,让模型少做一点,系统反而更可靠。

我为什么从 WordPress 换到了 Astro:从家宽非标端口到 Cloudflare Pages

工程实践

非标端口不得上桌

之前的博客部署在自己家里的自建服务器上,从 9999 的 http 到 3443 的 https,家宽总是与标准端口无缘,毕竟现在没有哪个运营商还开放 80 和 443 给家宽用。

看到前公司同事创业,发在公众号的文章,注意到自己做的东西应该让别人看到,这些也是个人竞争力的一部分,然而自己之前一直闭门造车,希望用做的好的产品(软件)而不是个人来吸引到注意力。看到同事的经历发现和他的想法和我的非常类似,虽然现在不想把个人推到台前做什么自媒体博主,但是提前做准备肯定是没错的,看起来中年危机离得很远,实际上不早早准备,机会来了也未必能抓住。

同事的经历给了我很好的借鉴方向,提前建立一些个人的技术资产,平时就持续积累,比等到要用的时候再到处找强太多。但是一个跑在家宽非标端口上的自建 WordPress 实在是上不了台面,所以开始找更好的方案

技术选型

搞一个有标准端口的博客方案很多,找个 VPS 扔上去,或者放 GitHub Pages 之类的,或者干脆直接套 Cloudflare 的代理,转发一下,但是我希望博客在国内也能正常快速访问,延迟低,也不用单独管一台服务器。

本来想要一个功能完整的博客,带一套后端可以有评论/访问量统计之类的功能,但是这基本上就意味着要管一个专门的 VPS 托管网站,这就有点太麻烦了,之前家里云的服务器跑了太多服务,虱子多了不痒,债多了不愁,多一个博客不多,少一个博客不少,就直接扔上面了,但是要单独搞一个 VPS ,专门上去维护,就很麻烦了。

但是如果只是想要评论和统计的话,用 GitHub Pages 之类的静态博客托管,也是有办法支持的。调研了一下发现有Giscus,或者可以直接用 Cloudflare Pages Functions 做轻量级后端也可以实现。

这个时候正好在和一些朋友聊天,于是讨论了一下,发现评论之类的功能实际上并不重要,评论之类的动态功能对博客来说只是锦上添花,博客最核心的点就是看博客,对于真正希望看到博客内容的人来说,有没有评论都不重要,之前开着评论也没有人发表什么很有价值的评论,于是决定干脆就先不做这些,先把标准端口的博客弄出来再说。

既然不要动态功能,那就太简单了,直接用 Hexo 快快的搭一个出来部署到 Cloudflare Pages 上,搭完之后配置的时候发现 Hexo 很麻烦,之前博客用的主题有一个 Hexo 版本 的也没维护了,而且如果要修改会更麻烦,很多想改的地方都要通过主题和插件,改起来很绕。

反正现在 vibe coding 这么方便,我直接 vibe 一个得了。

于是按照 Astro + Cloudflare Pages 的方案快速vibe了一个出来,从 WordPress 把博客导了出来做成了现在的博客。

最终效果

Cloudflare Pages 的 GitHub 仓库对接体验还是很好的,不需要配 CI 也可以直接在推送时自动构建。而且延迟其实并不高,首次加载在 2s 左右就加载完了,对于一个博客来说完全可以接受。

未来如果需要评论,大概会考虑 Pages Functions + D1/KV,看起来免费额度拿来做这些功能是足够的。

docker+mc的运维管理方案

Minecraft

之前在 一篇文章 中提到过docker部署minecraft服务器,后来发现并不好用,因为portainer免费版并没有很好的控制台管理方式,没有用户组之类的精确权限控制,同时也没有好用的文件管理方案,最终选择使用code-server来管理,在容器中安装tmux用于后台运行并随时打开控制台,同时还有vscode的好用文件管理

Dockerfile

bash
# ubuntu 作为基础镜像。
FROM ubuntu:24.04

# HTTP 代理
ENV HTTP_PROXY="http://172.17.0.1:7890"
# HTTPS 代理
ENV HTTPS_PROXY="http://172.17.0.1:7890"
# 针对 code-server/npm/git 等的全局代理(通常是小写的)
ENV http_proxy="http://172.17.0.1:7890"
ENV https_proxy="http://172.17.0.1:7890"
ENV NO_PROXY="localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
# 时区
ENV TZ="Asia/Shanghai"

# 配置 APT 清华源
COPY aliyun-ubuntu.sources /etc/apt/sources.list.d/
RUN apt-get update

# 安装依赖
RUN DEBIAN_FRONTEND=noninteractive \
    apt-get install -y --no-install-recommends \
    curl \
    gosu \
    tmux \
    ca-certificates \
    iputils-ping \
    wget \
    zip \
    unzip \
    locales \
    && rm -rf /var/lib/apt/lists/*

# 设置编码
RUN locale-gen en_US.UTF-8 && update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8

# code-server
RUN curl -fsSL https://code-server.dev/install.sh | sh

COPY start.sh /
RUN chmod +x /start.sh

RUN useradd --shell /bin/bash -u 1001 -m mc
WORKDIR /home/mc

# 暴露端口
EXPOSE 8080
EXPOSE 25565

CMD ["/start.sh"]

start.sh

bash
#!/bin/bash
set -e

# 检查环境变量是否已设置
if [ -z "$SERVER_NAME" ]; then
    echo "Error: SERVER_NAME environment variable is not set correctly."
    exit 1
fi

# 修正文件归属
chown -R mc /home/mc

# ===============================================================
# 信号捕获函数:用于优雅关闭 MC Server
# ===============================================================
graceful_shutdown() {
    echo "Caught signal. Performing graceful shutdown..."
    
    # 检查 tmux session 是否存在
    if tmux has-session -t mc 2>/dev/null; then
        echo "Sending 'stop' command to Minecraft server via tmux..."
        
        # 使用 tmux send-keys 向 'mc' 会话发送 'stop' 命令和 Enter 键
        # -t mc: 指定目标会话
        # C-m: 相当于按下 Enter 键
        tmux send-keys -t mc 'stop' C-m
        
        # 等待 MC Server 进程退出。mc-server-runner 会处理 Java 的关闭
        # 我们可以等待 tmux session 消失,表示 mc-server-runner 已退出
        TIMEOUT=60
        COUNT=0
        while tmux has-session -t mc 2>/dev/null && [ $COUNT -lt $TIMEOUT ]; do
            echo "Waiting for Minecraft server to stop... (Max $TIMEOUT seconds)"
            sleep 1
            COUNT=$((COUNT + 1))
        done
        
        if [ $COUNT -eq $TIMEOUT ]; then
            echo "WARNING: Minecraft server did not stop gracefully within $TIMEOUT seconds. Killing tmux session."
            tmux kill-session -t mc 2>/dev/null
        else
            echo "Minecraft server stopped successfully."
        fi
    else
        echo "Minecraft server tmux session not found or already stopped."
    fi

    # 停止 code-server (exec 后的 code-server 已经是主进程,收到信号后会自动退出)
    # 我们这里不需要手动杀死 code-server,因为 Tini 会转发信号给它。
    # 退出脚本,允许 Tini 干净地清理进程
    exit 0
}

# 捕获 SIGINT (Ctrl+C) 和 SIGTERM (Docker Stop) 信号
trap 'graceful_shutdown' SIGINT SIGTERM

echo Starting Server...
cd /home/mc/$SERVER_NAME
gosu mc tmux new -d -s $SERVER_NAME
gosu mc tmux send-keys -t $SERVER_NAME:0 "$START_CMD" C-m

echo Starting Code...
gosu mc sed -i "s#^password: .*#password: $CODE_PASSWORD#" /home/mc/.config/code-server/config.yaml
gosu mc code-server --bind-addr 0.0.0.0:8080 /home/mc/$SERVER_NAME &
CODE_SERVER_PID=$!
echo "code-server started with PID $CODE_SERVER_PID."

# 等待 code-server 进程或收到的信号。
# 这一行是保持 start.sh 脚本存活的关键,以监听信号。
wait $CODE_SERVER_PID

# 如果 code-server 意外退出,则执行优雅关闭流程
graceful_shutdown

aliyun-ubuntu.sources

yaml
Types: deb
URIs: http://mirrors.aliyun.com/ubuntu
Suites: noble noble-updates noble-backports
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg

docker-compose.yml 使用示例(此处的镜像是本地构建的)

yaml
services:
  velocity:
    image: minecraft-universal:1.5
    container_name: velocity
    hostname: velocity
    environment:
      SERVER_NAME: "velocity"
      START_CMD: "./start.sh"
      CODE_PASSWORD: "<your-password>" 
    ports:
      - "25565:25565"
    volumes:
      - /opt/java:/java:ro
      - /opt/mc-velocity:/home/mc
    restart: unless-stopped
    networks:
      - mc
      - web

使用时需要先在映射的目录下创建目录,目录名字和环境变量中的SERVER_NAME一致,并在目录下添加 start.sh 以及其他服务器文件,对应compose中定义的启动指令

同事误删mysql库补救办法

排障复盘

发现问题

今天下午同事突然跑来找我说不小心把库删了,问我会不会恢复,我从来没有这种删库恢复的经验,自然是要学习一下的,直接开始帮他恢复

查找解决办法

被删库的机器,这儿就叫他243,243的应用和数据库在同一个服务器上

  1. 首先先停止了应用防止继续写入或者丢失服务数据
  2. 检查mysql的binlog是否开启 SHOW VARIABLES LIKE 'log_bin%';,发现是开着的
  3. 检查binlog是否完整 ls /opt/mysql/data/ 发现缺少了几乎一半的binlog,推测是开启了过期清理
  4. 这个时候了解服务器运维的同事提出可以问机房的人恢复硬盘备份,并联系了相关同事
  5. 等待了一段时间,运维同事恢复了前一天的一个备份到一台新机器上,这儿叫他229

解决过程

在等待的时间里,确定了解决方案

  1. 检查两个服务器的binlog id差异
  2. 在243上找到最新的删库的binlog id
  3. 在229上找到最新的binlog id
  4. 在243生成两个binlog id中间差异的sql文件
  5. 将sql文件scp到229服务器上
  6. 229服务器的mysql执行sql补全缺失的数据

其中第二步可以在等待的时候完成

检查binlog的脚本 - 243

纯文本
mysqlbinlog --no-defaults -v --base64-output=DECODE-ROWS  --stop-position=287225397  binlog.000075 | tail -n 500 > ~/tail.log
bash
# at 287225397
#260227 15:46:29 server id 1  end_log_pos 287225556 CRC32 0x97e6fb47 	Query	thread_id=663510	exec_time=1	error_code=0	Xid = 583720608
use `laboratory`/*!*/;
SET TIMESTAMP=1772178389/*!*/;
SET @@session.pseudo_thread_id=663510/*!*/;
SET @@session.foreign_key_checks=0/*!*/;
DROP TABLE IF EXISTS `assay_report` /* generated by server */

检查binlog的脚本 - 229

纯文本
mysqlbinlog --no-defaults --database=laboratory ./binlog.000074 > ~/74.sql
bash
# at 1074009846
#260227 10:39:55 server id 1  end_log_pos 1074009890 CRC32 0x5b7eb4ae   Rotate to binlog.000075  pos: 4
SET @@SESSION.GTID_NEXT= 'AUTOMATIC' /* added by mysqlbinlog */ /*!*/;
DELIMITER ;
# End of log file
/*!50003 SET COMPLETION_TYPE=@OLD_COMPLETION_TYPE*/;
/*!50530 SET @@SESSION.PSEUDO_SLAVE_MODE=0*/;

在243生成差异sql

纯文本
mysqlbinlog --no-defaults --database=laboratory  --skip-gtids  --start-position=256983036 binlog.000074 --stop-position=287225397 binlog.000075 > all.sql

恢复sql

bash
# 登录sql
mysql -u root -p
# 执行文件 (1.5G的all.sql最终执行了十几分钟,服务器是固态硬盘)
source /home/all.sql

最终效果

在下班前恢复完成数据库并准时下班🎉🎉🎉

ps:本次行动由gemini提供技术支持😂

优化超大单体Spring Boot项目开发环境启动速度

工程实践

之前维护的一个项目,项目启动需要12分钟,开发环境需要频繁启停,极大影响开发效率

分析

使用IDEA profiler收集启动阶段的数据,并用idea打开火焰图分析

发现启动时间大部分都消耗在注入Autowired资源以及创建Aop切面

同时整理了项目的模块发现开发环境下有一部分是不常用的

优化方案

首先是修改pom使开发环境不加载不常用的模块,启动速度快了60秒左右

然后编写了脚本静态分析源码,收集了所有没有使用的@Autowired和@Resource,并手动处理(项目代码规范不是很好, 不确定是否有特殊的引用, 虽然最后没发现这样的引用),启动速度快了30秒左右

java
import java.io.File

val dir = File("/path/to/a/project")
val unused = mutableListOf<Pair<String, String>>()
val autowiredPattern = Regex("(private|public|protected)\\s+\\w+\\s+(?<varName>\\w+)\\s*;")

fun collect(file: File) {
    if (file.isDirectory) {
        // 忽略编译产物
        file.name == "target" && file.parentFile.resolve("pom.xml").exists() && return
        file.listFiles()?.forEach { collect(it) }
        return
    }
    if (!file.name.endsWith(".java")) {
        return
    }
    // println("scan ${file.name}")
    // 找到所有autowired
    val autowiredList = mutableListOf<String>()
    var nextIsAutowired = false
    val lines = file.readLines().filter { it.startsWith("//") }
    for (line in lines) {
        if (line.trim().run{ startsWith("@Autowired") || startsWith("@Resource") }) {
            nextIsAutowired = true
        }
        if (nextIsAutowired) {
            val result = autowiredPattern.find(line)
            if (result != null) autowiredList.add(result.groups["varName"]!!.value)
            nextIsAutowired = false
            continue
        }
    }
    if (autowiredList.isEmpty()) return
    // 查询没有使用的autowired
    val filePath = file.absolutePath
        .replace("\\", "/")
        .substringAfter("src/main/java/")
        .replace("/", ".")
        .removeSuffix(".java")
    autowiredList.filter { varName ->
        lines.all { line -> varName !in line || line.trim().matches(autowiredPattern) }
    }.let {
        unused += it.map { v -> filePath to v }
    }
}

collect(dir)
File("unused-autowired-${dir.name}.txt").writeText(unused.joinToString("\n") { (k, v) -> "$k: $v" })

此时启动速度依然很慢,于是使用 lazy-initialization 的方案,环境变量中添加 spring.main.lazy-initialization=true

但是所有bean都懒加载会导致一部分模块出错最终导致启动失败,于是添加filter

纯文本
@Bean
public LazyInitializationExcludeFilter filter() {
    return (beanName, beanDefinition, beanType) -> {
        String className = beanType.getName();
        return className.startsWith("com.example.module.plugin.important")
                || className.startsWith("com.example.module.common")
                ;
    };
}

最终启动速度从12分钟优化到最快4分钟(不启用可选的模块)

总结

  • 模块拆分解耦是好文明,在这种场景下直接禁用不用的模块也不影响项目启动
  • 在bean初始化里写逻辑是坏文明,遇到一个上游依赖的组件,自己封装了一层XxlJobSpringExecutor,还把class设置成package-private,导致不加载该模块就无法正常初始化xxljob

Arthas

开发工具

Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。
通常,本地开发环境无法访问生产环境。如果在生产环境中遇到问题,则无法使用 IDE 远程调试。更糟糕的是,在生产环境中调试是不可接受的,因为它会暂停所有线程,导致服务暂停。
开发人员可以尝试在测试环境或者预发环境中复现生产环境中的问题。但是,某些问题无法在不同的环境中轻松复现,甚至在重新启动后就消失了。
如果您正在考虑在代码中添加一些日志以帮助解决问题,您将必须经历以下阶段:测试、预发,然后生产。这种方法效率低下,更糟糕的是,该问题可能无法解决,因为一旦 JVM 重新启动,它可能无法复现,如上文所述。
Arthas 旨在解决这些问题。开发人员可以在线解决生产问题。无需 JVM 重启,无需代码更改。 Arthas 作为观察者永远不会暂停正在运行的线程。

Arthas官方文档

查cpu高占用

  • dashboard 查看占用最高的线程
  • thread -n 3 查看占用高的线程及其堆栈

查内存高占用

  • oom崩溃的情况无法处理,需要-XX:+HeapDumpOnOutOfMemoryError
    • -XX:HeapDumpPath=/tmp/heapdump.hprof 指定dump文件位置
    • 进程还活着才可以分析
  • vmtool --action forceGc 先fullGc一次
  • dashboard 查看Memory
  • jmap -histo:live <pid> | head -n 20 查看是否有大量自定义对象 一般排名靠前的都是java的基本类型
  • heapdump --live /tmp/dump.hprof 导出文件后拖到idea或者mat软件分析
    • 在合并的路径tab中可以按照实例类型和引用关系找到引用最多对象的实例
    • 在最大对象tab中可以看到对象及其引用的对象的占用并层层展开查看引用关系
    • 保留(Retained)是这个对象及其引用的对象层层递归加起来的总内存占用,主要看这个
      • 计算的是对象的独占对象的内存占用,如果一个object还被其他object引用,则不纳入计算
      • 找到保留大小最大的几个对象,通过引用树找到持有这些对象的实例
    • 浅层(Shallow)是这个对象本身占用的内存,一般只有数组的浅层大小会很大

修改方法逻辑

  • jad --source-only com.example.demo.UserController > /tmp/UserController.java 添加 --lineNumber=false 不显示行号 -d dump文件到目录
  • 编辑补全逻辑
  • mc /tmp/UserController.java -d /tmp 编译成class
  • retransform /tmp/com/example/demo/UserController.class 热重载

查慢方法调用

  • trace com.example.demo.OrderController createOrder -n 1 添加监听
    • -n 1 指定触发次数,方法触发指定次数后自动退出,不添加则会连续监听直到ctrl c
    • 若入参或返回值过长可以输出到文件 文件路径不能使用~ 否则会创建名为~的文件
  • 输出包含方法调用栈以及行号的执行耗时
    • 输出的列表和Map不会展开内容,只会显示size

查方法入参返回值

  • watch com.example.demo.UserService login "{params, returnObj}" -n 1 添加监听
    • -n 1 指定触发次数,方法触发指定次数后自动退出,不添加则会连续监听直到ctrl c
    • 添加 -x 2 展开对象, 展开2刚好够展开Map和List
    • watch com.example.demo.UserService login "{ @com.alibaba.fastjson.JSON@toJSONString(params), @com.alibaba.fastjson.JSON@toJSONString(returnObj) }" -n 1 使用OGNL编写表达式可以输出json格式, 此时不需要-x展开
    • watch com.example.demo.UserService login "{ params, returnObj }" "params[0].name == 'test'" -n 1 使用OGNL编写条件表达式,可以在递归调用等地方过滤不想要收集的方法调用 如果在win等无法输入中文的情况下可以用unicode编码 "'\u6d4b\u8bd5\u540d\u5b57' == params[0]"

远程连接内网路由器

HomeLab

看标题会感觉是一个很简单的操作,但是不是。路由器后台会自动重定向到ip访问,导致常规端口转发不生效。

我尝试过zerotier,但是和公司的局域网网段重叠了,遂放弃。

今天发现ssh可以做socks代理,于是解决了。

ssh指令ssh -D 1080 -N -q user@host -p port打开一个socks代理

然后下一个火狐(可以下便携版)

进入设置拉到最下面找到网络设置,配置socks代理(别写http的)

然后就可以直接访问了

回退vscode版本 & 多版本共存

开发工具

最近更新了vscode,有一天需要连接远程服务器的时候发现报错说服务器glibc什么的版本过旧不支持了,但是服务器又不是我的,没法更新系统,所以需要回退旧版本。同时我有一个新版本的vscode,因为claude code插件不支持旧版本vscode,所以需要两个vscode互相隔离,于是就有了这个博客。

下载旧版本vscode

因为我需要多版本共存,所以需要一个免安装的版本,避免他和现有版本产生冲突

首先在faq中找到下载地址的格式,然后填入对应的版本

https://update.code.visualstudio.com/{version}/win32-x64-archive/stable

运行

如果下载之后直接运行,会直接使用当前用户目录下的缓存,比如插件啥的,可能会产生冲突,所以需要指定各种目录,我直接给出启动脚本

bat
@echo off

set VSCODE_DIR=C:\Users\Administrator\Downloads\VSCode-win32-x64-1.96.4
set "VSCODE_EXECUTABLE=%VSCODE_DIR%\Code.exe"
set "USER_DATA_DIRECTORY=%VSCODE_DIR%\user-profile"
set "EXTENSIONS_DIR=%VSCODE_DIR%\user-extensions"

start "VSCode Portable" "%VSCODE_EXECUTABLE%" --user-data-dir "%USER_DATA_DIRECTORY%" --extensions-dir "%EXTENSIONS_DIR%"

用这个脚本启动则vscode会有独立的用户数据目录和插件目录,避免和安装版本的产生冲突

minecraft docker运行

Minecraft

今天研究了一下docker中运行minecraft,原因是希望在不给ssh的情况下允许别人进入服务器后台,因为已经部署了portainer,所以希望可以直接通过portainer操作后台

构建镜像

首先需要一个镜像来运行服务端

一开始我选择了Alpine作为底包,然后发现这个包实在是太干净了,甚至用的都不是glibc,下好的预编译的jdk没法跑,于是换了ubuntu

然后希望通过不同的目录来区分各个不同的子服,所以用环境变量+启动脚本动态选择工作目录

Dockerfile

dockerfile
# ubuntu 作为基础镜像。
FROM ubuntu:24.04

ENV TINI_VERSION=v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini

# 设置环境变量的默认值,这些值会被 docker-compose.yml 中的配置覆盖。
ENV SERVER_NAME=minecraft_server
ENV START_CMD="/java/bin/java -Xms1G -Xmx1G -jar server.jar --nogui"

# 设置容器内的通用工作目录。
WORKDIR /${SERVER_NAME}

# 暴露 Minecraft 服务器默认的 TCP 和 UDP 端口。
EXPOSE 25565

# 将启动脚本复制到镜像中。
COPY start.sh /start.sh

# 授权启动脚本可执行权限。
RUN chmod +x /start.sh

# 设置入口点。tini 确保当容器收到停止信号时,能优雅地关闭 Java 进程。
ENTRYPOINT ["/tini", "--"]

# 定义默认的启动命令。
CMD ["/start.sh"]

start.sh

bash
#!/bin/bash

# 检查环境变量是否已设置
if [ -z "$SERVER_NAME" ]; then
    echo "Error: SERVER_NAME environment variable is not set correctly."
    exit 1
fi

cd /$SERVER_NAME
echo "starting..."
exec $START_CMD
bash
# 构建指令
docker build -t minecraft-universal:1.0 .

docker compose

yaml
services:
  sc:
    image: minecraft-universal:1.2
    container_name: sc # 容器名字
    # 允许attach
    stdin_open: true
    tty: true
    ports:
      - "35565:25565" # 游戏端口映射,可以修改为其他端口
    environment:
      # 服务器名
      SERVER_NAME: "sc"
      START_CMD: "/java/bin/java -jar fabric-server-mc.1.21.8-loader.0.17.2-launcher.1.1.0.jar nogui"
    volumes:
      # 挂载宿主机的 Java 目录到容器中的 /java
      - /usr/local/jdk/21:/java
      # 挂载宿主机的服务器目录到容器中
      - ./sc:/sc
    restart: unless-stopped

使用如上配置之后可以在attach后正常和服务端控制台交互

重点是 stdin_open: truetty: true

wordpress 非标准端口 https

排障复盘

今天折腾了一天的wordpress,想从http切换到https

首先用acme申请了证书

在wordpress容器到公网之间添加了一个nginx做反代顺便添加https支持

首先遇到的问题是修改站点地址为https后argon的js和css都请求失败,一看发现还都是http

折腾半天找不到配错的地方,最后没写过php也只能硬改代码了,在argon的functions.php里添加了以下代码

javascript
function fix_output_urls($buffer) {
    return str_replace('http://e404.top', 'https://e404.top', $buffer);
}

function start_output_buffer() {
    ob_start("fix_output_urls");
}

add_action('wp_loaded', 'start_output_buffer');

然后就返回了正常的https地址

但是打开管理界面的时候一直重定向到自己

又调试半天,找不到问题原因

只能直接改代码看日志debug

半夜把is_ssl函数改了一下发现能跑了

javascript
function is_ssl() {
	if ( isset( $_SERVER['HTTPS'] ) ) {
		if ( 'on' === strtolower( $_SERVER['HTTPS'] ) ) {
			return true;
		}

		if ( '1' === (string) $_SERVER['HTTPS'] ) {
			return true;
		}
	} elseif ( isset( $_SERVER['SERVER_PORT'] ) && ( '443' === (string) $_SERVER['SERVER_PORT'] ) ) {
		return true;
	}

	return false;
}

第二天发现是wp-config-docker.php原来是需要替换config.php的