Skip to content

记一次小程序的生产事故和数据库迁移

观前提示:本文饱含各种草台班子技术方案,最开始都是为了写起来快上线快 爽,导致我搞出了一整套又隔路又激进的技术方案。技术大佬可能越看越会生理不适,如果对你的身体健康产生了影响,还请您使用返回按钮紧急避险。能忍住不适看下去的话,祝你看得开心。

欢迎来玩我们的小程序。

alt text

这是一个完全由本人设计、开发、运维、炸库、修好的小程序。掏出你的大手机扫码,即可获得原汁原味的体验。

也欢迎来找我做小程序(求求了,孩子想开单……)

前因

由于这个小程序是从 2024 年 11 月开始写的。最开始的用户也就几百个,日活更是拿不出手。

alt text

开发之初,一切为了快。我掏出了比当时 PHP + Taro.js 更快的 Node.js + 原生小程序方案,加上甲方的需求并没有很多,第一版只用了一个晚上就搓出来了。

后来到了 2025 年 3 月,加了一点功能,我意识到这个东西可能要逐渐发展,于是我一拍脑袋干了两个不知道算牛逼还是傻逼的事。

  1. 做了一个所有路由都要走的中间件,把所有的请求的参数和响应都存到了数据库里。

  2. 做了一个定时任务,每天 0 6 12 18 点,对指定几张重要的表进行 select *,写到本地的一个目录下。

在数据量还不是很大的时候,这就是一个简易的请求日志 + 数据库备份。

但在今年年初开始,事情开始不对劲了。

日志表在 2026 年 1 月的时候,数据量破了百万。这就导致了在后台查看访问日志的时候,即使加了分页也是龟速。

数据库备份的定时任务更是炸裂,总共 50G 硬盘的服务器,硬是被数据库备份塞了 40G+,在某一天的清晨直接炸服了。

而且访问量越大,数据量越大,导致之前的技术债都浮出了水面。从这次迁移看得出,事情越做越大,技术债就越要一点一点还。没办法,既然是债,不如一块都还了。于是今天策划了一场数据库迁移。

之前的架构设计

为了给甲方节约成本(也确实用不上太多的成本),只买了一台 2C4G 的 ECS。由于懒得在服务器上安装各种复杂的环境,索性直接全部上 docker。

Mysql、redis、应用,各自一个 docker-compose.yml 文件,各自一个容器。把数据、缓存、日志等不需要每次重新部署的时候都清掉的目录外挂出来,docker compose up -d 一键启动。为了躲避一些黑客对常用端口的扫描,我把这三个容器都放到了一个非常隔路的端口上,差不多就是 rand(1000, 9999) 生成出来的一样(实际上还是比这更有规律一些)。

Nginx 还是装到了宿主机上,让 Nginx 去代理后端服务和管理后台的前端。

一开始是用 Gitee 的流水线,后来受好朋友推荐,用上了 CNB - 云原生构建。免费额度足够我用了,直接白嫖到了现在。

没有正式和测试环境,测试的时候基本就是用几个特殊账号在正式库上点功能。就这套架构跑了一年多才逐渐出现问题,所以大部分面试造的飞机还没我这个实用。「纸上得来终觉浅」。

迁移方案和过程

  1. 停机

小程序后台有一个停止服务,还挺好用。关掉前台页面后就可以上服务器上一键停服 docker compose down

  1. 复制数据

Navicat 可以两边数据库连上之后直接 Ctrl C/V,还是挺方便的。但在拷贝一张 32w 条数据的表时出现了点问题,有一些 SQL 被截断了。由于勾选了 “错误时继续”,拷贝还是继续进行,但完成后使用 select count(id) 发现,32w 条数据丢了 2000 多条。

这肯定不行。迁移完了丢数据,那我直接删库开新服得了呗。删掉数据不全的表,改用 mysqldump 方案。

sh
# 源库导出(单表)
mysqldump -h <源host> -u <user> -p \
  --single-transaction --quick --hex-blob \
  --complete-insert \
  --net_buffer_length=16M --max_allowed_packet=512M \
  <源库> calendar_score_log > score_log.sql
# 目标库导入(先清空或换表名再切换)
mysql -h <目标host> -u <user> -p \
  --max_allowed_packet=512M \
  <目标库> < score_log.sql

遇到问题还得是原生的工具最好用。

数据表都迁移完成,管我的 cursor 兄弟要了一条 SQL 来核对数据。

sql
SELECT CONCAT(
  'SELECT ''', TABLE_NAME, ''' AS table_name, COUNT(*) AS cnt FROM `', TABLE_NAME, '`',
  CASE
    WHEN TABLE_NAME = (
      SELECT TABLE_NAME FROM information_schema.TABLES
      WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
      ORDER BY TABLE_NAME DESC LIMIT 1
    ) THEN ''
    ELSE ' UNION ALL'
  END
) AS sql_line
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_TYPE = 'BASE TABLE'
ORDER BY TABLE_NAME;

核对完只丢了个位数的日志数据。查了一下具体的数据,是我中间启动了服务,自己点出来的。

数据库一变二

由于之前只有一个库,所以开发和测试都在正式库上做。我们的做法就是测试的时候盯着这几个内部人员的号祸害。迄今为止,我自己的号上有 5000 多积分,足以搬空积分商店。我都不知道哪来的。

终于成为了尊贵的 RDS 充值用户,那自然少不了安排一个测试库。

测试库的迁移就比较简单了,直接 Ctrl C/V,数据爱丢不丢吧,差不多就行,数据表没丢就行。

然后让 cursor 兄弟改装了一下 Egg.js 的数据库实例部分,让后端可以根据请求 url 来切换数据库。

设计测试环境这块也想得很简单,就根据域名来决定库。请求测试域名就是测试库,请求正式域名就是正式库。反正本地开发的时候想换哪个域名就换哪个域名。

javascript
/**
 * 请求级 MySQL 上下文:用 AsyncLocalStorage 避免并发下直接改写 app.mysql 串库。
 * app.js 将 app.mysql 包成 Proxy 后,业务仍写 this.app.mysql.xxx,实际打到当前请求选中的 client。
 *
 * 无请求上下文(定时任务等)时走 defaultName(config.mysqlDefaultClient,默认 prod)。
 */
'use strict'

const { AsyncLocalStorage } = require('async_hooks')

const storage = new AsyncLocalStorage()

function getStore() {
  return storage.getStore() || null
}

function getStoreMysql() {
  const store = getStore()
  return store && store.mysql
}

function getDbEnv() {
  const store = getStore()
  return (store && store.dbEnv) || null
}

function runWithMysql(mysql, dbEnv, fn) {
  return storage.run({ mysql, dbEnv }, fn)
}

/**
 * @param {object} multi egg-mysql 多 client 实例(有 .get(name))
 * @param {string[]} clientNames 如 ['prod','dev']
 * @param {string} defaultName 无 ALS 时回退(local 建议 'dev')
 */
function wrapMysql(multi, clientNames = ['prod'], defaultName = 'prod') {
  const nameSet = new Set(clientNames)
  const fallback = nameSet.has(defaultName) ? defaultName : clientNames[0]
  const current = () => getStoreMysql() || multi.get(fallback)

  return new Proxy(multi, {
    get(target, prop, receiver) {
      if (prop === 'get') {
        // app.mysql.get('prod') → 取命名 client
        // app.mysql.get(table, where) → 当前请求库上的按表查询
        return function mysqlGet(...args) {
          if (args.length === 1 && typeof args[0] === 'string' && nameSet.has(args[0])) {
            return target.get(args[0])
          }
          return current().get(...args)
        }
      }

      // 仍允许访问多 client 原始能力(少用)
      if (prop === 'getClient' || prop === 'clients' || prop === '_rawMulti') {
        if (prop === '_rawMulti') return target
        return Reflect.get(target, prop, receiver)
      }

      const client = current()
      const val = client[prop]
      if (typeof val === 'function') {
        return val.bind(client)
      }
      return val
    },
  })
}

module.exports = {
  storage,
  getStore,
  getStoreMysql,
  getDbEnv,
  runWithMysql,
  wrapMysql,
}

还得是 Cursor,赞美人类科技,赞美 AI。

上线和测试

于是这套东西就上线了。push 代码之后,CNB 的流水线就开始跑,不到一分钟就完成了。自己在本地来一波冒烟测试,又到正式版小程序跑一波冒烟测试。把功能都点了一遍,啥事没有。

再这么跑几天,没啥问题我就把旧的在 docker 里的数据库一停,迁移就彻底结束了。

总结

  • 写着玩和真的做商业项目真不是一回事。可能都能跑,但不一定跑得一样稳。

  • 一个人做独立开发真是又爽又累。

  • AI 牛逼,我再也不是守旧派了。拥抱 AI,该 AI 写就 AI 写,但自己还是要审核一遍。

  • 好困。

最后更新于:

评论区
评论区空空如也
发送评论
名字
0 / 20
邮箱
0 / 100
评论内容
0 / 140
由于是非实名评论,所以不提供删除功能。如果你需要删除你发送的评论,或者是其他人的评论对你造成了困扰,请 发邮件给我 。同时评论区会使用 AI + 人工的方式进行审核,以达到合规要求。