起因

我的博客用了好几年的 Qexo(一款 Hexo 博客管理面板),一直跑在 Vercel + MongoDB Atlas 免费版上。说说、图片、配置都在 MongoDB 里,17 条说说,20 张图片,63 项配置。

前几天看到 Qexo 4.0 发布了,支持 Django 5.2,新增 passkey 登录,改进了 MongoDB 支持。想着都 2026 年了该升一波,fork 仓库,Vercel 上点个 Redeploy,按理说几分钟的事。

结果第一轮部署直接炸了。

第一劫:migration 炸了

**Vercel 部署日志里报了一长串 Django migration 错误,核心是 **auth.0013_alter_group_id_alter_permission_id_alter_user_id 这条 migration 想修改 _id 字段,但 MongoDB 不允许修改已有文档的主键。

报错信息大概长这样:

1
2
3
pymongo.errors.BulkWriteError: batch op errors occurred
E11000 duplicate key error collection: django.auth_permission
index: __primary_key__ dup key: { id: null }

第一反应是 Migration 历史记录不完整。新版 Qexo 的 migration 列表里有些记录我的 MongoDB 没有,手动一条条补进去,重新部署。

第二劫到第四劫:补不完的坑

补完 hexoweb 的 migration 记录,下一轮又炸在 auth.0013。补完 auth.0013,又炸 contenttypes.0003。补完 contenttypes.0003,又炸 passkeys.0002。

**到第四轮,报错变成 **InconsistentMigrationHistory: Migration passkeys.0002 is applied before its dependency passkeys.0001,我补的顺序错了。删掉重来,按正确顺序补 0001_initial0002_alter_userpasskey_id

每修好一个问题,下一张表马上冒出来新的。

第五劫:唯一索引的暗坑

**第五轮部署日志显示 **No migrations to apply,以为稳了,结果 post_migrate 信号触发时又炸。Django 自动创建权限和 content_type 时,每条新记录 id=null,但 MongoDB 里有个从老版本继承下来的 __primary_key__ 唯一索引在 id 字段上。第一条 null 能插入,第二条 null 就触发唯一键冲突。

**解决办法是把 **auth_permissionauth_groupauth_userdjango_content_typedjango_session 这些 Django 内置表全部清空,__primary_key__ 索引也一个个删掉。这些表不存用户数据,清空没问题。

代价是管理员账号丢了,需要重新建。

第六劫:部署成功了,页面 500

第六轮部署日志全绿,Vercel 显示 Ready。打开博客,”错误! Error 500”。

**排查后定位到根因:Qexo 4.x 的 hexoweb 模型把主键改成了 **id = models.UUIDField(primary_key=True),代码里到处用 i.id.hex 调用。但我的 MongoDB 里 hexoweb 表的 _id 是老版本的 ObjectId,根本没有 id 字段。

i.id 是 None,None.hex 报 AttributeError,整个页面 500。

第七劫:UUID 格式的反复折腾

**先尝试把 hexoweb 表的 **_id 从 ObjectId 转成 UUID Binary(subtype 4),不行,Django MongoDB backend 把 UUIDField 映射为 db_type="string",期望的是字符串而不是 Binary。

再尝试转成字符串 UUID,还是不行,i.id 依然是 None,因为 Django 读的是 id 字段而不是 _id 字段。

**又尝试给每条文档加一个 Binary UUID 的 **id 字段,报 TypeError: a bytes-like object is required, not 'str',加错格式了。

最后在 Django MongoDB backend 源码里翻到一行:

1
"UUIDField": "string",

正确的文档结构应该是:

1
2
3
4
5
6
{
 _id: ObjectId(...),      // MongoDB 自动主键
 id: "c687f7ed3ab84bd5847b7f478022944c",  // 字符串 UUID,32字符无连字符 hex
 content: "...",
 // ...其他字段
}

_id 是 MongoDB 自己的 ObjectId,id 字段是字符串 UUID(Qexo 代码用 i.id.hex 读,所以是无连字符的 32 字符格式)。

**按这个结构把所有 hexoweb 表重新整理了一遍,17 条说说、20 张图片、63 项配置、7 条通知,每条记录都重新生成 ObjectId 作 **_id,UUID 字符串作 id

**执行完,访问 **/pub/talks/

1
{"msg": "获取成功!", "status": true, "count": 17, "data": [...]}

17 条说说全回来了。

小插曲:管理员账号

**之前清空 Django 内置表的时候把 **auth_user 也删了,得手动在 MongoDB 里插入一条管理员记录。Django 5.2 的密码用 pbkdf2_sha256 格式:

1
pbkdf2_sha256$600000$<salt>$<base64-hash>

**手动生成 hash,插入文档,登录,还是 500。排查了半天,发现 **date_joined 字段我写成了字符串 "2026-07-25T02:00:00Z",Django 期望的是 datetime 对象。改了,再登录,进去了。

控制台一切正常,7 条历史通知都还在,从 2.8.1 到 3.6.3 的更新通知一字排开。

长期方案:注释掉 migrate

**部署是成功了,但 **vercel.sh 里的 makemigrationsmigrate 早晚还会再炸。只要作者新增一条 migration 又尝试改表结构,又会触发 MongoDB 的 _id 写入限制。

**最终把 **vercel.sh 里这两行注释掉了:

1
2
# python3 manage.py makemigrations
# python3 manage.py migrate --fake --noinput

表结构已经是新版的最终态,不需要再跑 migration。以后 fork sync upstream 也不会有冲突,通过 Vercel Build Command 用 sed 动态注入跳过逻辑,源码保持原版。

文档里那句话

事后翻 Qexo 官方文档,部署页面有一句:

“目前 Qexo 已支持使用 MongoDB 作为数据库进行部署,请注意新版本修改了 MongoDB 连接方式,旧版本用户可能需要重新初始化数据库”

“重新初始化数据库”,实际操作起来就是删库重来,说说、图片、配置全部清空,没有任何迁移路径,没有备份提示。

我翻了 GitHub Issues,找到 Issue #681,一位用户报了和我一样的错误,作者是用 Copilot 提了两个 PR(#683、#685)修的。但那个 issue 里没人讨论数据保留,大概率那位用户也是清空数据库重来的。

MongoDB 老用户本来就少,4.x 又是 2026 年 2 月才发布的,数据量大的老用户更少。我刚好是用了好几年 MongoDB 又不想丢数据的那种。

如果你也遇到

MongoDB 老用户想升级 Qexo 4.x 的话,有几个选择:

  1. 数据量小、不在意丢失,直接删 MongoDB 数据库,4.x 用新格式从头初始化,最快最省事。
  2. **数据想保留,参考这篇的过程,核心就两步:清空 Django 内置表并删 **__primary_key__ 索引;hexoweb 表的文档结构改成 {_id: ObjectId, id: "uuid-hex-string"}
  3. 彻底换数据库,迁移到 PostgreSQL(SupaBase 免费版)。Qexo 原生支持 PostgreSQL,_id 格式问题不存在了,以后 migration 直接跑。这个最干净,迁移脚本也不难写。

最后

**Django + MongoDB 本身就是 Django ORM 的边缘场景,4.x 改用官方 **django-mongodb-backend 是对的方向,但老 MongoDB 用户的迁移路径确实没考虑到位。

文档里”可能需要重新初始化数据库”这句,如果能补上”(此操作会清空所有数据,请提前备份)”,能省掉不少人不少麻烦。