Hexo 博客评论系统深度迁移:从 LeanCloud 到 TiDB Cloud

前言

ByteFisher 博客的评论系统使用 Waline(部署于 Vercel) + LeanCloud(国内版)的组合已运行一年多,积累了 19 条评论和 137 篇文章阅读计数。一切看似稳定,直到某天发现了一个 ETIMEDOUT 报错……

这篇文章记录了从发现问题、方案选型、数据库迁移到最终落地的完整过程,特别整理了迁移过程中遇到的 7 个坑,希望能给同样使用 Waline + LeanCloud 组合的博主一些参考。


一、问题的源头

1.1 突如其来的连接超时

某天更新博客后,发现控制台持续报错:

1
2
3
WalineComment.vue:194 Get comment data failed with 500:
connect ETIMEDOUT 106.75.100.17:443
[N/A GET https://8sghvqns.lc-cn-n1-shared.com/1.1/classes/Comment]

表象是 Vercel(海外服务器)向 LeanCloud 国内节点(上海)发起的 TCP 连接全部超时。从国内网络实测却能正常连接:

1
2
3
# 从国内网络测试
$ curl https://8sghvqns.lc-cn-n1-shared.com/1.1/classes/Comment -I
HTTP_CODE:200 TIME:2s # ✅ 国内网络正常

Vercel 的服务器部署在 AWS/GCP 美西区域,而 LeanCloud 上海节点的跨境网络不稳定是常见现象。之前还能用是因为旧的出口 IP 还能连通,但这终究是随时会爆的雷。

1.2 更大的定时炸弹

排查过程中,发现 LeanCloud 官方公告:

LeanCloud 将于 2027 年 1 月 12 日停止对外提供服务。

距离停服只剩不到 8 个月。即使这次勉强修好连接,2027 年 1 月还是要迁移——不如一步到位。

影响范围

组件 用途 影响程度
Waline 服务端(Vercel) 评论读写、阅读计数 🔴 核心功能全部中断
scripts/generators/hot-articles.js Hexo 构建时获取热门文章 🔴 侧边栏热门文章空
api/hot-articles.js Vercel Serverless 热门文章 API 🟡 降级可用

二、方案选型

2.1 核心条件

条件 原因
国内无需代理访问 博主和多数读者在国内
Waline 原生支持 不改 Waline 代码
免费额度够用 个人博客,数据量小
2027 年后不会停服 这次迁移要管很多年

2.2 候选方案对比

方案 国内访问 免费额度 Waline 支持 公司背景 结论
LeanCloud 国际版 开发版 ✅ 原生 2027 年停服
Turso (SQLite) ⚠️ 9GB ❌ 不支持 Turso (美国)
MongoDB Atlas ⚠️ 慢 512MB ✅ 原生 MongoDB Inc 🟡
TiDB Cloud 流畅 5GB 原生 PingCAP (中国)

2.3 选择 TiDB Cloud 的理由

因素 评价
PingCAP 是北京公司 控制台国内访问流畅,不担心被墙
5GB 免费额度 19 条评论 + 137 条计数十年也用不完
Waline 原生支持 直接设置 TIDB_* 环境变量即可
MySQL 兼容 迁移数据时 SQL 语法完全兼容,工具链丰富
长期稳定 PingCAP 是 TiDB 的创始公司,不会突然停服

最终决策:Waline + TiDB Cloud(Serverless)


三、LeanCloud 数据导出

3.1 控制台导出

在 LeanCloud 国内版控制台 → 数据存储 → 分别导出两张表:

表名 用途 记录数
Comment 评论内容 19 条
Counter 阅读计数 137 条

3.2 导出格式

LeanCloud 的导出格式是 tar.gz 压缩包,内含 JSONL(JSON Lines) 文件。格式特殊:

1
2
3
4
5
// 第 1 行是元数据
#filetype:JSON-streaming {"type":"Class","class":"Comment"}
// 每行是一个 JSON 对象
{"nick":"Anonymous","comment":"测试","url":"/guestbook/",...}
{"nick":"用户","comment":"文章内容","url":"/2026/05/04/...",...}

每个 JSON 行的字段:

字段 类型 说明
objectId String LeanCloud 唯一 ID(24 位)
comment Text 评论正文
nick / mail / link String 用户信息
url String 文章路径(原始中文
pid / rid String/null 父子评论引用 ID
createdAt / updatedAt ISO 时间 时间戳

坑 1:JSONL 不是标准 JSON 数组,不能直接用 JSON.parse() 读取。需要用 JSON.parse(line) 逐行解析。


四、TiDB Cloud 部署

4.1 注册与建库

  1. 打开 tidbcloud.com → GitHub 账号登录
  2. 选择 Serverless Tier(免费 5GB)
  3. 配置参数:
参数
Cluster Name bytefisher-waline
区域 AWS Singapore (ap-southeast-1)
数据库 waline

选 Singapore 区域的原因:离国内和 Vercel(美东)的延迟都较低。

4.2 获取连接信息

创建后拿到:

1
2
3
HOST: gateway01.ap-southeast-1.prod.aws.tidbcloud.com
PORT: 4000
USER: xxx.root

在 TiDB Cloud 的 SQL Editor 中执行建库:

1
CREATE DATABASE waline;

五、数据导入 TiDB

5.1 表结构

Waline 的 TiDB 适配器使用 MySQL 兼容模式,需要创建以下表:

表名 用途
wl_Comment 评论数据
wl_Counter 阅读计数
wl_Users 用户信息(含第三方 OAuth 字段)

坑 2wl_Users 表创建时如果缺少 githubtwitter 等 OAuth 字段,管理后台 GitHub 登录会报 Unknown column 'github' 错误。建表时一定要包含所有社交登录字段。

5.2 数据迁移

从 LeanCloud JSONL → TiDB SQL 的转换注意:

Comment 表字段映射:

LeanCloud TiDB 转换逻辑
objectId id String → 按时间排序后分配自增整数
comment comment 直接映射
pid / rid pid / rid 坑 3:LeanCloud 存的是 objectId 字符串,TiDB 用自增整数,需要用 Map 重映射
createdAt createdAt ISO 时间戳 → TIMESTAMP
insertedAt 对象 insertedAt {"__type":"Date","iso":"..."} 需要特殊解析

Counter 表字段映射:

LeanCloud TiDB 说明
url url 文章路径
time time 阅读计数

5.3 导入脚本

编写 Node.js 脚本,连接 TiDB 后执行批量插入。核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 读取 JSONL → 解析每行为 JSON 对象
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
if (line.startsWith('#')) continue;
const obj = JSON.parse(line);
results.push(obj);
}

// 2. 按时间排序,分配自增 ID
rawComments.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
const idMap = {};
rawComments.forEach((r, i) => { idMap[r.objectId] = i + 1; });

// 3. 重映射 pid/rid
const pid = r.pid ? (idMap[r.pid] || null) : null;
const rid = r.rid ? (idMap[r.rid] || null) : null;

六、Vercel 环境变量配置

这是最关键的一步——Waline 通过环境变量确定使用哪个数据库。

6.1 删除旧环境变量

1
2
3
4
LEAN_ID          # 已不需要
LEAN_KEY # 已不需要
LEAN_MASTER_KEY # 已不需要
LEAN_SERVER # 已不需要

6.2 新增 TiDB 环境变量

变量名
TIDB_HOST TiDB 连接地址
TIDB_PORT 4000
TIDB_USER 用户名
TIDB_PASSWORD 密码
TIDB_DB waline
TIDB_PREFIX wl_
JWT_TOKEN 随机字符串(管理后台登录密钥)

坑 4:环境变量名不能写错。之前误将 TIDB_USER 写成了 IDB_USER(少了开头的 T),Waline 连接不上数据库,但也不报错——仅 Function Logs 中有一条 No valid storage found 警告。排查了很长时间才发现少了个字母。

6.3 配置验证

配置完成后,访问 https://waline.bytefisher.top/ui,登录管理后台确认:

  • Comment 表显示 19+ 条旧评论
  • Counter 表显示 137+ 条计数
  • 新发评论能正常入库

七、迁移后的修复(踩坑合集)

数据迁移完成只是第一步,后面还有几个坑需要填平。

7.1 坑 5:计数器 URL 编码不匹配(四次反复)

这是整个迁移过程中最折磨人的问题,来回改了四次才最终解决。

问题现象:文章页阅读计数显示为 0,但 Counter 表中确实有数据。

根因:URL 编码格式不一致。

Waline 的数据流是:

1
博客页面 (data-path 属性) → Waline API 请求 → Waline 服务端解码 → TiDB 查询

每个环节都可能引入编码差异:

编码格式 示例
原始中文 /2026/05/14/Hexo-博客图床迁移实战-从iili.io到Cloudflare Workers+工具链改造全记录/
URL 编码 /2026/05/14/Hexo-%E5%8D%9A%E5%AE%A2%E5%9B%BE%E5%BA%8A.../
双重编码 %2F2026%2F05%2F14%2FHexo-%25E5%258D%259A%25E5%25AE%25A2...

每次格式转换都可能改变路径字符,导致数据库查不到记录。

四次修复过程:

版本 数据库格式 Waline 查询格式 结果
原始导入 原始中文 原始中文 ❌ Waline查询时编码不匹配
第一次修复 URL 编码 URL 编码 ❌ Waline解码后匹配不上
第二次修复 去尾斜杠 带尾斜杠 ❌ Waline用带斜杠的查
最终 原始中文 + 尾斜杠 原始中文 + 尾斜杠 ✅ 匹配

最终结论:Counter 表和 Comment 表的 URL 格式必须统一为原始中文 + 尾斜杠(与 Waline 服务端 decodeURI() 后的格式一致)。

另外,前端 JS 中获取计数时,需要正确的编码链:

1
2
3
4
// 正确的编码链
var p = el.getAttribute('data-path'); // 原始中文
fetch(baseURL + '/api/article?path=' + encodeURIComponent(p) + '&type=time')
// Waline 服务端 decodeURIComponent → 原始中文 → 匹配数据库

7.2 坑 6:评论刷新后消失

问题:新评论发成功了(管理后台能看到),但刷新页面后不显示。

排查了一通才发现——Waline Vercel 函数实例缓存了旧查询结果,新评论写入数据库但 API 返回的是旧缓存。

修复方法

  1. 直接调 API 确认正常:/api/comment?path=/guestbook/&pageSize=20
  2. 强制 Vercel 冷启动重新部署(Deployments → Redeploy)
  3. 清除浏览器缓存/Service Worker 缓存

之后再发新评论,刷新即可正常加载。

7.3 坑 7:Waline.init 选项不被支持

问题:NexT 主题的 waline.swig 中调用了 Waline.init(),传入了几个不被 @waline/client@2 支持的选项:

1
2
3
4
5
6
7
8
9
// ❌ 会有问题的版本
Waline.init({
el: '#waline-comments',
serverURL: 'https://waline.bytefisher.top/',
imageUploader: false, // 不存在此选项
pageview: false, // 不存在
comment: true, // 不存在
highlight: true // 不存在
});

Waline v2 遇到不认识的选项会直接抛出错误,导致整个初始化中断——评论不显示、计数不更新。

修复方法:精简 init 配置,只保留被支持的选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ✅ 正常的版本
Waline.init({
el: '#waline-comments',
serverURL: 'https://waline.bytefisher.top/',
path: location.pathname,
lang: 'zh-CN',
meta: ['nick', 'mail', 'link'],
requiredMeta: ['nick'],
placeholder: '',
avatar: 'retro',
pageSize: 10,
emoji: ['//unpkg.com/@waline/emojis@1.2.0/weibo']
});

7.4 计数显示修复(前端 JS)

计数器 API 调用成功后,还需要确保前端 DOM 更新。在 waline.swig 中加了独立的计数获取脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(function() {
var baseURL = 'https://waline.bytefisher.top/'.replace(/\/+$/, '');

function fetchAll() {
document.querySelectorAll('.waline-visitor-count').forEach(function(el) {
var p = el.getAttribute('data-path');
if (!p) return;
fetch(baseURL + '/api/article?path=' + encodeURIComponent(p) + '&type=time')
.then(function(r) { return r.json(); })
.then(function(d) {
var t = d && d.data && d.data[0] && d.data[0].time;
if (t > 0) el.textContent = t;
});
});
}

// 多层保障
if (document.readyState === 'complete') { fetchAll(); }
else { window.addEventListener('load', fetchAll); }
setTimeout(fetchAll, 2000);
setTimeout(fetchAll, 4000);
// MutationObserver 监控 DOM 变化
document.querySelectorAll('.waline-visitor-count').forEach(function(el) {
var obs = new MutationObserver(function() { fetchAll(); });
obs.observe(el, { characterData: true, childList: true, subtree: true });
});
})();

八、热门文章生成改造

原有的 hot-articles.js 生成器直连 LeanCloud API 获取阅读计数:

1
2
3
4
5
6
// ❌ 旧的实现(依赖 LeanCloud)
var opts = {
hostname: '8sghvqns.lc-cn-n1-shared.com',
path: '/1.1/classes/Counter?order=-count&limit=300',
headers: { 'X-LC-Id': appId, 'X-LC-Key': appKey + ',master' }
};

迁移后改为在 Hexo 构建时直接查询 TiDB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ✅ 新的实现(直连 TiDB)
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: 'gateway01.ap-southeast-1.prod.aws.tidbcloud.com',
port: 4000,
user: 'xxx.root',
password: 'xxx',
database: 'waline',
ssl: { rejectUnauthorized: true }
});

const [rows] = await pool.execute(
'SELECT url, time FROM wl_Counter WHERE url LIKE "/20%/%" AND time > 0 ORDER BY time DESC LIMIT 300'
);

这样 hexo g 时直接从 TiDB 拉取最新阅读计数,生成 api/hot-articles.json,不依赖外部 API 的稳定性。


九、最终架构

系统拓扑

1
2
3
4
5
用户浏览器 (www.bytefisher.top)
├── 发表评论 ──→ Waline 服务端 (Vercel) ──→ TiDB Cloud (Singapore)
├── 加载评论 ──→ Waline 服务端 (Vercel) ──→ TiDB Cloud (Singapore)
├── 阅读计数 ──→ Waline 服务端 (Vercel) ──→ TiDB Cloud (Singapore)
└── 热门文章 ──→ 读取 hexo 生成的静态 JSON (构建时从 TiDB 拉取)

数据库表

表名 用途 数据量
wl_Comment 评论内容、用户信息 21 条
wl_Counter 文章阅读计数 133 条
wl_Users 管理后台用户 1 条

Vercel 环境变量(最终版)

变量名 用途
TIDB_HOST / TIDB_PORT / TIDB_USER / TIDB_PASSWORD TiDB 连接
TIDB_DB / TIDB_PREFIX 数据库和表前缀
JWT_TOKEN 管理后台登录密钥
SITE_NAME / SITE_URL 站点信息

十、总结

踩坑清单

编号 耗时 教训
1 JSONL 格式解析 0.5h LeanCloud 导出的不是标准 JSON
2 wl_Users 缺 OAuth 字段 0.5h 建表需包含全部字段
3 pid/rid 引用重映射 1h 自增 ID 需要重新关联
4 TIDB_USER 写错变量名 1h 少了 T 字母排查很久
5 URL 编码不匹配(改了4次) 4h 记录中文 URL 要统一格式
6 Vercel 实例缓存 0.5h 冷启动重新部署即可
7 Waline.init 不被支持的选项 1h 精简配置,只留有效选项

迁移前后对比

项目 迁移前 迁移后
数据库 LeanCloud 国内版 TiDB Cloud(新加坡)
免费额度 依赖 LeanCloud 开发版 5GB Serverless
连接稳定性 ❌ Vercel 跨境超时 ✅ 海外→海外低延迟
停服风险 2027-01-12 无(PingCAP 持续运营)
构建时的热门文章 直连 LeanCloud API 直连 TiDB SQL
评论前端 JS 含不被支持的选项 精简配置 + 独立计数脚本

最终效果

  • 评论功能正常(19 条旧评论 + 新评论持久化)
  • 阅读计数实时更新(133 条记录)
  • 管理后台可用(GitHub 登录 + 评论管理)
  • 首页热门文章正常显示(构建时自动获取)
  • 管理后台:https://waline.bytefisher.top/ui

这次迁移虽然踩了不少坑,但结果是可靠的:TiDB Cloud 作为 PingCAP 的核心产品,配合 Waline 的原生支持,可以保证博客评论系统长期稳定运行。

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
分享: