重写社区平台栈,但不重写数据库
lakebbs 从 Vue 2 + Express + Sequelize 迁到 Next.js 16 + React 19 + Drizzle ORM —— 但 MySQL 5.7 schema 没动。为什么栈重写时锁住数据库是无聊但正确的决定。
- #nextjs
- #drizzle
- #mysql
- #migration
- #engineering-discipline
- #rewrite
lakebbs 是北安省小城市的中文社区平台。2024 年起步用的是当时的实用栈:Vue 2 + Vite + Pinia + Express + Sequelize + MySQL 5.7。两年后这套栈变成累赘 —— 类型松散、没有 SSR、AI 重构工具在 Vue 2 上远弱于现代 TypeScript / React。
2026 年 5 月落地的重写把整个 app 搬到了 Next.js 16 (App Router) + React 19 + TypeScript + Drizzle ORM + NextAuth v5 + Tailwind 4。有意思的地方:MySQL schema 没动。没加列、没删列、没改类型。同一个数据库、同一份数据、同一个 nginx 反代后的 lakebbs MySQL 5.7 实例。
重写时如果连 schema 也改,你会同时面对:
- 代码项目 —— 写新组件、新 ORM 模型、新路由
- 数据项目 —— 写迁移脚本变换已有行
- 部署项目 —— 协调代码切换和数据切换,两边都要 rollback 路径
- 正确性项目 —— 验证新 app 在新 schema 下产出跟旧 app 在旧 schema 下相同
每个项目都有自己的失败模式。合起来的失败模式不是相加,是相乘。
锁住 schema 后:(1) 变成纯代码项目;(2) 空;(3) 一次切换 nginx;(4) 检查新 app 跟旧 app 输出一致。总工作量被 (1) 上限。而 (1) 就是我本来想做的事。
实际看 Drizzle 怎么对接现有 schema:
export const posts = mysqlTable('posts', {
id: int('id').primaryKey().autoincrement(),
cityId: int('city_id').notNull(),
authorId: int('user_id').notNull(), // 保留遗留列名
title: varchar('title', { length: 255 }).notNull(),
body: text('body').notNull(),
isPinned: tinyint('is_pinned').default(0).notNull(), // Sequelize 遗产
createdAt: datetime('created_at').notNull(),
updatedAt: datetime('updated_at').notNull(),
});
数据库的遗留留在数据库,新代码允许干净。
线上切换是一次 nginx reload。旧 Express 停、新 Next.js 起,都指向同一个 MySQL。零用户感知 downtime。重写在几周而不是几个月内完成。Rollback 路径就是「停新 app,重启旧 app」—— 同 schema,同数据。
完整论证 + 哪些场景不适用这个 pattern(迁移数据库引擎、合规需求、schema 本身就是要解决的问题等)见 英文版。
相关
— 相关阅读