
如果你把 go-view 接进 ruoyi-vue-pro 做数据大屏上线跑两三个月后很可能撞上一个让人头皮发麻的问题——数据库突然变得又肥又慢备份体积翻了几倍打开大屏管理列表要转圈好几秒。你第一反应可能是日志表或者定时任务出了问题但排查到最后大概率会发现罪魁祸首是那张大屏配置表同一个大屏库里躺着几十甚至上百条记录每条记录的某个字段都是几十KB甚至几MB的图片数据有些是整屏封面截图有些是配置JSON里内嵌的素材base64。这篇文章就是我从踩坑到清理、再到彻底调整存储链路的全过程记录适合正在用 ruoyi-vue-pro 集成 go-view、或者已经发现大屏表异常膨胀的同学对照排查。我会讲清楚图片到底是从哪条链路写进数据库的、怎么在不丢数据的前提下把存量图片搬到文件服务、以及如何从源头上避免再次复发。1. 问题根源go-view 的保存流程把“配置”和“图片”一起塞进了库1.1 大屏配置和封面快照为什么会在同一条链路里go-view 前端在编辑大屏时核心产物其实是两个东西一个是大屏的配置JSON里面包含组件的坐标、尺寸、数据源配置、样式参数另一个是一张预览快照图片你把大屏拖拽成什么样子前端就会通过 html2canvas 之类的工具把整个画布渲染成一张图片用来在大屏列表页展示卡片封面。在官方演示环境里这两样东西是可以只存在本地的无非是 localStorage 或 IndexedDB。但凡是接进一个可多人协作的业务系统几乎所有人都会做同样的二开把 go-view 的前端页面挂到 ruoyi-vue-pro 里后端自己写一套大屏配置的增删改查接口。前端保存大屏时把配置JSON和这张封面图一起 POST 上来后端用一个接口同时存进同一张表。这个设计在初期非常直观——列表接口一次查出来既带JSON又带封面图前端直接渲染省得再发一次请求。问题就出在这个“省事”上。很多人包括当时的我给预览图字段用的是TEXT甚至LONGTEXT直接存data:image/png;base64,....这种超长字符串。单张图几百KB感觉没什么但同一个大屏如果保存行为频繁或者每次编辑都触发自动保存积少成多就是一场数据库灾难。1.2 图片积压的三种典型路径先说结论大方向就三类你可以对照自己的代码和库表去确认。第一类是封面快照随保存不断产生。保存一次大屏就生成一张封面图并更新进大屏主表。如果存在自动保存逻辑比如编辑器里加了onChange监听或者定时器高频保存那么哪怕你只改了一个组件坐标几分钟内也会触发几十次保存。如果设计成覆盖更新字段还会反复膨胀如果设计成插入历史快照表那就是一条条巨无霸记录疯狂增长。第二类是历史快照或版本管理表。有些项目会借鉴 go-view 本身的“快照”概念给大屏做历史版本每次保存都插入一条screen_snapshot记录里面既有 config JSON 又有 preview_image。一周下来一个只有十多个组件的大屏配置JSON可能只有几十KB但每张快照图几百KB几百条快照就是几百MB。第三类是配置JSON里直接内嵌素材图。这个最容易让人忽略。大屏里的背景图、边框图片、图片组件的 src如果前端拖拽上传时直接把 base64 塞进组件属性这个 config JSON 本身就变得很大。一个图片组件加一张背景图动辄1~2MB10个组件就是十几MB再叠加封面图数据库不被撑爆才怪。这三条路径往往不是互斥的很多项目是三条同时存在最后呈现出来的现象就是标题里说的“同一个大屏报表在数据库中存储了大量的图片”。1.3 从现象反推数据库卡顿其实是“图片读取”卡顿判断是不是图片问题最直观的现象是大屏列表页打开特别慢但大屏详情页或编辑页不慢导出JSON备份特别慢用SHOW TABLE STATUS一看大屏相关表的Data_length远超其他业务表。如果你发现某张表里有一批LENGTH(某个字段)特别大的记录而且这些记录关联到同一个大屏ID基本就能锁定是图片堆积。我当初排查时最惊讶的一点是这张表的记录数其实不算多每行看着也就几千条但因为没有清理机制一条记录里动不动就是 1MB 以上的 base64几万行加起来就是几十GB整个 MySQL 的备份、主从同步全部被拖慢。这已经不只是“慢查询”问题而是存储架构问题。2. 动手前先体检几条SQL和日志快速定位“图片堆积”现场2.1 给大屏表“称重”的SQL先把现象量化再谈方案。下面这段SQL可以帮你找到单条图片体积最大的大屏记录SELECT id, project_name, LENGTH(preview_image) AS image_bytes, LENGTH(config_json) AS config_bytes, update_time FROM screen_project WHERE preview_image IS NOT NULL AND LENGTH(preview_image) 0 ORDER BY image_bytes DESC LIMIT 20;然后再汇总一下这堆图片一共占了多少空间、多少行SELECT COUNT(*) AS row_count, SUM(LENGTH(preview_image)) AS total_image_bytes, ROUND(SUM(LENGTH(preview_image)) / 1024 / 1024, 2) AS total_image_mb, MAX(LENGTH(preview_image)) AS max_image_bytes FROM screen_project WHERE preview_image IS NOT NULL AND preview_image ! ;如果表已经非常大执行LENGTH全表扫描会有点压力建议先通过INFORMATION_SCHEMA.TABLES看整表大小再抽部分数据确认。不要大半夜一个慢查询把线上库拖死。2.2 区分“封面快照”和“配置里的素材图”接下来要看 config_json 里有没有内嵌 base64。可以用一个不太严谨但很实用的SQLSELECT id, project_name, (LENGTH(config_json) - LENGTH(REPLACE(config_json, data:image, ))) / LENGTH(data:image) AS img_embed_count FROM screen_project WHERE config_json LIKE %data:image% ORDER BY img_embed_count DESC LIMIT 20;这个SQL只是粗略统计data:image出现的次数不用于精确计数但足够排查。如果发现img_embed_count大于 0说明大屏配置里已经内嵌了素材图清理的时候要一并处理否则只改封面图问题不会根治。2.3 从接口日志反推高频写入源SQL 能告诉你结果接口日志能告诉你过程。我这次排查时翻了后端的管理端接口访问日志发现/screen/project/save这个接口在编辑期间被高频调用而且几乎每次请求体里面都带一个超长的previewImage字段。再到前端编辑器代码里一找果然有个watch监听配置变化变化后立即调保存接口保存时又强制截一张全屏图于是形成了“改一个组件位置 → 全屏截图 → 写库”的死亡循环。你自己排查时也建议先做两件事第一打开浏览器开发者工具的 Network 面板在编辑大屏时观察保存接口的触发频率第二在后端日志里按大屏ID统计一下保存次数如果几分钟内上百次基本就是有自动保存逻辑在作祟。这个步骤不需要写任何额外代码纯靠观察就能定位。3. 存储策略重设计让数据库只存“地址”不存“内容”3.1 复用 ruoyi-vue-pro 的文件模块而不是自建存储思路很简单所有图片不管是封面快照还是素材图都交给 ruoyi-vue-pro 的文件模块统一管理。文件模块本身支持本地磁盘、MinIO、云OSS数据库只需要留一个cover_url之类的字段记录相对路径或完整地址。前端列表页要展示封面时直接通过 URL 访问不再经过数据库的大字段。这个方案带来的收益是立体性的。数据库表体积和备份体积瞬间回归正常大屏列表接口的响应体从几百KB变成几KB查询和网络传输都快图片可以走静态资源服务甚至CDN后续清理历史封面时只需要在文件系统层面操作不用再考虑删除数据库大字段引发的 binlog 膨胀和主从延迟。提示如果项目中已经启用了 ruoyi-vue-pro 自带的文件表你连新建文件存储逻辑都不用直接调现成的文件上传接口后续管理也能在后台文件管理页面看到非常省事。3.2 表结构调整preview_image → cover_url以大屏主表为例我当时是把preview_image保留成可空字段同时新增一个cover_url。DDL大致如下表名和字段名按你们项目实际结构来换ALTER TABLE screen_project ADD COLUMN cover_url VARCHAR(512) NULL COMMENT 封面图地址 AFTER preview_image; -- 迁移完成后确认没有代码再依赖 preview_image再逐步把旧字段数据清掉不要一上来就直接DROP COLUMN。先把程序改成读写新字段跑一段时间稳定后再把旧字段清理掉。这样万一线上出现兼容问题还可以快速回滚。对于快照表也是同理screen_snapshot增加cover_url后续写入只存 URL。3.3 配置JSON里的素材图也要URL化这块稍微麻烦一点。如果你在大屏配置JSON里发现大量data:image有两个处理方向一是在 go-view 前端的上传控件处改造拖入图片组件时如果选择本地文件先把文件传一遍拿到 URL 后再写入组件属性而不是直接塞 base64二是写一个“配置净化”函数在保存大屏时遍历 config 的各个组件凡是src、backgroundImage等属性以data:image开头的就先上传再替换为 URL。第一种是治本第二种适合先清理存量。如果大屏本身的素材总量不大也可以暂时接受部分内嵌但长期看还是建议统一走文件服务。毕竟 config JSON 越干净后面做版本对比、权限控制、数据导出都会越轻松。4. 前端两步走go-view 保存时先传图再存配置4.1 找到go-view里的保存逻辑和截图入口在 go-view 前端工程中搜索canvas.toDataURL、domToImage、html2canvas这些关键词基本就能定位到生成封面图的地方。不同版本的叫法不一样但特征很明显。然后看它的调用上下文找到保存大屏的那个方法把“先组装好配置加图片再一次性提交”改成“先上传图片拿 URL再提交配置”。核心流程变化只有一句话原来是一次接口调用的save API(payload: { config, previewImage: dataURL })改造后变成两次逻辑——先upload API(blob)拿到previewUrl再save API(payload: { config, coverUrl: previewUrl })。虽然多了一步但收益是数据库不再接图片大字段。4.2 上传接口接入与返回字段适配ruoyi-vue-pro 的文件上传接口通常形如POST /admin-api/infra/file/upload接收MultipartFile返回结果里有文件地址。前端可以直接用项目里现成的uploadFileAPI。参考代码如下// 把 dataURL 转成 Blob 再上传 function dataURLToBlob(dataURL: string): Blob { const arr dataURL.split(,); const mime arr[0].match(/:(.*?);/)![1]; const bstr atob(arr[1]); const u8arr new Uint8Array(bstr.length); for (let i 0; i bstr.length; i) { u8arr[i] bstr.charCodeAt(i); } return new Blob([u8arr], { type: mime }); } async function saveProject() { const dataURL await generatePreviewImage(); // 生成本地封面图 const blob dataURLToBlob(dataURL); const formData new FormData(); formData.append(file, blob, screen-cover-${Date.now()}.png); const uploadRes await uploadFile(formData); // 不同版本返回结构不一样以自己项目为准 const coverUrl uploadRes?.data?.url ?? uploadRes?.url; await saveScreenProject({ id: projectId, config: JSON.stringify(screenConfig), coverUrl }); }这里最容易被坑的就是返回结构我见过两种版本一种直接返回{ url: ... }一种返回{ data: { url: ... } }。写代码前先打印一次上传接口的 response把字段对齐再继续免得 debug 半天。4.3 两个容易被忽略的细节图片格式与异步耗时如果封面图只是列表页的小卡片没必要存image/png原图可以转成image/webp或image/jpeg并压缩。用canvas.toBlob或者toDataURL(image/webp, 0.85)都可以具体看目标浏览器兼容性。压缩之后封面图可能从 1MB 降到一两百KB上传更快文件服务压力也更小。另一个细节是截屏生成往往需要遍历 DOM大屏组件多了以后耗时可能好几秒。保存流程变成两步之后用户体验上会有一个明显变化——原来的“一个保存按钮等半天”变成“图片上传转圈 保存完成”两个阶段。建议前端加上 loading 状态并且禁止按钮重复点击。我见过因为保存按钮没有 loading用户着急连点几秒钟产生好几张封面图的真实案例这在改造后虽然不会造成数据库膨胀但会造成文件系统里的孤儿文件变多。5. 存量清理把历史base64成批导出、回填地址、删旧字段5.1 写一个可分批执行的迁移脚本存量数据清理的正确姿势是分批查询 → base64 解码 → 写文件 → 回填 URL → 清旧字段。不要一条 SQL 从头跑到尾几十万行大字段更新会把事务日志撑爆还会拖慢线上库。以下是伪代码核心逻辑可以直接放进一个临时接口或定时任务里跑完即可下线Resource private JdbcTemplate jdbcTemplate; public void migratePreviewImages() { int batchSize 100; Long lastId 0L; while (true) { ListMapString, Object rows jdbcTemplate.queryForList( SELECT id, preview_image FROM screen_project WHERE id ? AND preview_image IS NOT NULL AND preview_image ! ORDER BY id ASC LIMIT ?, lastId, batchSize); if (rows.isEmpty()) { break; } for (MapString, Object row : rows) { Long id (Long) row.get(id); String base64Text (String) row.get(preview_image); byte[] bytes parseBase64ToBytes(base64Text); String url fileApi.upload(bytes, screen-cover- id .png); jdbcTemplate.update( UPDATE screen_project SET cover_url ?, preview_image NULL WHERE id ?, url, id); } lastId (Long) rows.get(rows.size() - 1).get(id); // 每批间隔一会儿避免拖垮数据库 } } private byte[] parseBase64ToBytes(String base64Text) { String pure base64Text; if (base64Text.contains(,)) { pure base64Text.substring(base64Text.indexOf(,) 1); } return Base64.getDecoder().decode(pure); }这段代码里有两个关键点一是用id lastId做游标避免深分页二是解析 base64 之前一定要去掉data:image/png;base64,这类前缀。如果你拿到的 dataURL 是data:image/jpeg;base64,....也一样处理只要有逗号就取逗号后面的部分。5.2 先备份再执行清理后检查旧字段正式跑脚本之前一定要导出大屏相关表的备份至少备份id preview_image关键字段。图片数据一旦被清成 NULL再想找回只能靠文件服务文件服务如果没配好数据就真丢了。跑完脚本后重点检查三处第一cover_url是否都能访问随便抽几条在浏览器里打开第二大屏列表接口是否正常返回封面图第三screen_project表的总大小是否明显下降。确认无误后再考虑把preview_image字段彻底删掉尽量把迁移窗口控制在“程序已经不再写旧字段”之后。提示清理过程中的 UPDATE 操作一定要分批提交否则单条事务就会把数据库的连接占住很久还可能造成主从延迟。如果你想更快可以在低峰期把迁移脚本跑在从库上最后再切换。5.3 存量快照表和配置内嵌图的清理如果还有screen_snapshot这类快照表清理思路相同保留最近 N 条快照并迁移其中的图片为 URL其余历史快照直接删除。不要一次 DELETE 全表按大屏ID逐个处理避免锁表时间过长。配置JSON里内嵌的素材图如果要写净化程序建议单独写一个迁移脚本而不是塞进封面迁移那一步。因为 JSON 解析涉及组件类型、字段路径的判断容易漏应当单独测试。我的做法是先导出单个大屏的 config 到本地手动分析组件结构再写通用的遍历逻辑跑完每个大屏后以“大屏预览页能否正常展示”作为验收标准。这类脚本跑一遍即可不必做成常驻任务。6. 防复发从“每次保存都截图”改成“只有发布才截图”6.1 业务规则收敛是根本解技术手段只能治标真正治本的是把业务规则改掉——封面图不是编辑过程的产物而是发布行为的产物。我的做法是在大屏表加了一个status字段区分草稿和已发布。编辑时无论怎么自动保存接口都只写 config_json不碰封面图只有点击“发布”按钮时前端才调一次截屏并把生成的封面图上传、保存。这样同一个大屏的封面图数量最多就等于发布次数绝不会因为编辑频率而无脑膨胀。如果你不想加状态字段也可以把前端编辑器里的自动保存逻辑拆开配置变更只自动保存 JSON封面图只在手动点击“保存并生成封面”时生成。两种方式本质上都是一个原则把“写图片”的动作从高频路径里抽出来放到低频入口里去。6.2 快照数量限制与文件服务的配套策略如果你保留了大屏快照功能要限制快照数量。比如每个大屏最多保留最近 5 个版本超过就删除最旧的快照记录并同步删除文件服务中对应的封面图。删除文件服务图片需要注意文件模块里可能还有其他地方引用同一张图不能盲目物理删除。比较稳妥的做法是保留文件记录只解除大屏与它的引用关系通过定时任务再清理真正的孤儿文件。如果你是刚开始接 go-view强烈建议在接口设计阶段就把图片参数从保存DTO里去掉强制前端做到“配置归配置、图片归文件服务”。这个约束越早做后面越省心。已经写进数据库的 base64 也并不可怕迁移脚本跑一遍就好真正可怕的是清完之后旧逻辑又因为某些历史调用路径被重新触发。6.3 日常监控每周看一眼大屏相关表体积最后一道防线是监控。可以在后端加一个定时任务每周统计大屏相关表的data_length和image_bytes如果连续几周涨幅超过预期就自动发告警。如果不想搞复杂系统用最朴素的 SQL 每周手动看一眼也可以SELECT table_name, ROUND((data_length index_length) / 1024 / 1024, 2) AS size_mb, table_rows FROM information_schema.tables WHERE table_schema your_database_name AND table_name IN (screen_project, screen_snapshot);不需要多复杂的监控体系能发现趋势就够了。如果哪周突然暴涨几百 MB大概率又是某个自动保存逻辑给误加回去了早点发现比事后清理轻松得多。最后说点个人体会。我之前一直以为大屏的数据库膨胀是“使用久了自然该变大”直到真的把图片从库里迁出去才意识到很多膨胀根本不是业务增长而是存储边界没有划清楚。配置和素材、配置和快照本来就应该是分开的事物数据库只关心业务数据图片就交给文件服务。如果你正准备把 go-view 接进 ruoyi-vue-pro强烈建议一开始就把封面图走文件上传、配置 JSON 里拒绝 base64、快照数量上限设好这三条看似多写了点代码但后面能省下至少一个通宵的清理时间。已经踩坑的同学也别慌文章里的 SQL、脚本和步骤基本可以照着抄记得先备份再动手跑完之后你会发现大屏列表接口从几百 KB 的响应变成几 KB那种舒畅感真的会让人上瘾。