ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

go-view大屏图片base64塞爆数据库?改造外置URL存储方案

go-view大屏图片base64塞爆数据库?改造外置URL存储方案 做数据大屏的兄弟应该都遇到过这种尴尬大屏搭得挺爽背景图、边框素材、装饰图片使劲拖结果数据库先扛不住了。我在用 ruoyi-vue-pro 做后台、内嵌 go-view 搭建可视化大屏时就踩了一个特别典型的坑——数据库里一张配置表被同一个大屏报表里的图片 base64 撑到了 800 多 MB保存一次大屏接口慢一次最后连大屏加载都开始卡顿。当时的第一反应是“这数据量也不大啊”看了表结构才明白问题根本不在业务数据而在 go-view 把本地上传的图片全部以 base64 形式塞进了大屏 JSON 配置里。这篇文章就是记录我怎么把库里的图片全部“搬出去”的核心思路是数据库里只存图片 URL图片本体外置到本地磁盘或对象存储再做一遍历史数据迁移顺带解决了一堆跟路径回显、上传超时、旧数据兼容有关的坑。如果你正在 ruoyi-vue-pro 上集成 go-view或者遇到了“大屏保存越来越慢、数据库异常膨胀”的类似问题这篇应该能直接给你一份可以照着做的方案。1. 问题背后go-view 的图片为什么全进了数据库1.1 大屏配置结构里藏着 base64go-view 本质上是一个可视化大屏编辑器你拖拽的每个组件、装饰、图表、图片最终都会序列化成一份 JSON 配置保存在某张大屏信息表里。ruoyi-vue-pro 接入时通常会把这份 JSON 直接存到一个类似json_content或config_json的字段里字段类型一般是longtext。关键点在于go-view 对“图片”组件的默认处理逻辑是如果你没有填远程 URL而是通过本地上传它会把图片文件读取成 base64 字符串然后原封不动塞进 JSON 的url字段。base64 是一种把二进制数据转换成 ASCII 文本的编码方式它最大的毛病是体积膨胀。原始文件 1MB编码完之后大约 1.37MB增长约 37%。也就是说你在设计器里传了一张 1MB 的背景图配置 JSON 里就多出了约 1.37MB 的文本。这个逻辑单看一次没什么但是乘上“大屏数量多”“图片多”“保存次数多”问题就非常严重了。我当时的实际案例是一张综合数据大屏里面放了 20 多张 PNG 背景、图标、gif 动图单张大屏完整 JSON 配置里base64 图片数据加起来就已经接近 60MB。这还是在没有放视频、没有放超大高清图的情况下。1.2 同一份大屏被反复保存图片越积越多如果只是新增大屏才写一次问题还不会蔓延得这么快。真正让数据库爆炸的是大屏的“重复保存”机制。ruoyi-vue-pro 里集成 go-view最常见的做法是编辑大屏时前端把整份 JSON 通过接口提交到后端后端执行update或者先删后插。也就是说无论你是改了一个标题还是调整了一个组件的颜色保存时整份 JSON 都会原样覆盖写一次里面包含的 base64 图片数据也跟着重新入库。这样一来如果你一天改了十次大屏数据库里那张表就会反复写入十份几十 MB 的图片文本。虽然最终保留的是最新版本但如果你有操作日志表、版本记录表或者你在复制大屏时直接把 JSON 整体复制了一份那么同一张图片的数据就会同时存在多个地方。我自己查了一遍发现有几个典型的“数据放大器”操作日志表记录了每一次保存的快照一份 60MB 的大屏配置存了十二个版本直接贡献了 720MB。复制大屏功能是暴力深拷贝连 base64 图片一起复制新建了一个测试大屏又多了 60MB。部分同事习惯在设计器里反复上传同一张图片没有复用已有资源同一张 logo 在不同大屏配置里各存了一份完整的 base64。最终结果就是数据库里真实业务数据只有几十 MB大屏图片配置却占了 800 多 MB整张表变得又大又慢mysqldump备份耗时明显增加保存大屏接口要处理大字符串传输响应时间动不动两三秒。2. 优化思路数据库只留 URL图片全部外置2.1 三种存储方案横向对比要解决“图片进数据库”的问题简单想有两条路一是继续存库但换一种更省空间的二进制类型二是彻底把图片挪出数据库。我整理了一个方案对比这是当时做选型时的核心依据。方案存储位置优点缺点Base64 文本现状数据库longtext字段不需要额外文件存储单表单文件备份简单体积膨胀 37%数据库膨胀JSON 解析吃内存不可缓存LONGBLOB 二进制入库数据库二进制字段仍是单库单表数据与业务配置强一致依然占用数据库空间读写压力大无法利用 CDN本地磁盘/OSS 文件 URL文件系统/对象存储数据库只存 URL数据库体积极小图片可走静态资源/CDN保存接口更快需要管理文件生命周期文件引用关系需要维护其实还有第四种方案直接用外部图床的 URL在设计器里只填远程地址。这种方案虽然简单但大屏数据经常涉及内部经营数据图片外挂到公网图床既不安全稳定性也堪忧我直接排除了。2.2 为什么最终选择“本地文件 URL 引用”这个选择很大程度上是顺着 ruoyi-vue-pro 现有的基础设施来的不需要为了优化再额外引入一套系统。ruoyi-vue-pro 的infra模块本身就封装了文件上传能力支持本地磁盘存储和对象存储比如 MinIO、阿里云 OSS、腾讯 COS。它提供了FileApi和FileService上传文件后返回一个可以直接访问的 URL。也就是说后端能力现成的我只需要做两件事第一改造 go-view 大屏 JSON 里图片的引用方式把 base64 字符串替换成文件 URL 第二写一个数据迁移脚本把已经存在数据库里的历史 base64 图片全部抽取出来转存成文件再更新 JSON 配置。那为什么不改成 LONGBLOB 存二进制呢存二进制确实比 base64 省了 37% 的空间但本质上还是在和数据库的存储空间、备份体积、IO 压力做对抗。大屏图片属于静态资源它的访问频率高、变更频率低、体量大这类数据放在数据库里本身就是错位的。数据库应该用来存结构化、强一致、需要事务保护的数据静态文件就该放到文件系统或对象存储里让 Web 服务器直接返回还能顺手做缓存和 CDN 加速。边界和取舍也说一下图片外置之后如果某个图片文件被删了大屏里就会显示裂图所以文件的生命周期管理需要跟上。这个我在后面的“常见问题”里会单独讲。3. 从数据到底层图片外置化的完整改造3.1 改造前准备先摸清数据现状不管方案想得多完美动手之前一定要先做数据体检。因为只有知道问题有多严重、集中在哪些表哪些字段后面才能确定迁移脚本怎么写、要不要分批跑、跑完怎么验证。我当时的排查步骤很简单直接跑了几条 SQL-- 1. 查看大屏配置表到底占了多大空间 SELECT table_name, data_length, index_length, (data_length index_length) / 1024 / 1024 AS size_mb FROM information_schema.tables WHERE table_schema 你的库名 AND table_name 大屏配置表; -- 2. 查看配置字段的长度分布确认是否存在超大记录 SELECT id, LENGTH(json_content) AS len FROM 大屏配置表 ORDER BY len DESC LIMIT 20; -- 3. 统计有多少条配置里包含了 base64 图片 SELECT COUNT(*) AS img_cnt FROM 大屏配置表 WHERE json_content LIKE %data:image/%;这里有个小技巧LENGTH()在 MySQL 里返回的是字节数如果字段里有中文字节数和字符数会不一样但对我们判断问题已经足够了。跑完这三条 SQL基本就能确定哪几条记录是“毒瘤”。还有一件事必须做备份。我的习惯是迁移前先做一次mysqldump或者至少把涉及的表单独导出并且导出一份 CSV 存档放服务器上。迁移脚本本身也可能有 bug没有备份就动手是给自己挖坑。3.2 后端新增 base64 转文件上传接口历史数据迁移其实不一定非得走接口后面会讲怎么用脚本批处理。但是新数据的上传链路一定要先改造好否则前端还在生成带 base64 的大屏配置你把历史数据清干净了也没用数据库很快又会胖回来。我选择的方案是在 ruoyi-vue-pro 基础上新增一个专门给 go-view 调用的上传接口。为什么不直接用项目里现有的文件上传接口因为现有接口接收的是multipart文件而 go-view 内置的图片上传逻辑是先把图片读取成 base64 字符串直接传 multipart 文件需要额外改 go-view 源码比较麻烦。我加一个接收 base64 数据的接口前端改动最小兼容性最好。接口的核心逻辑是这样/** * go-view 图片上传接收 base64 字符串转存为文件返回 URL */ PostMapping(/screen/image/upload-base64) PreAuthorize(ss.hasPermission(infra:file:upload)) public CommonResultMapString, String uploadBase64( RequestBody Valid Base64ImageUploadReqVO reqVO) { // 1. 校验并提取 base64 数据段例如 data:image/png;base64,iVBORw0KGgo... String data reqVO.getData(); String meta data.substring(0, data.indexOf(,)); String base64 data.substring(data.indexOf(,) 1); // 2. 解码 byte[] bytes Base64.getDecoder().decode(base64); // 3. 判断文件类型目前只允许图片 String contentType meta.replace(data:, ).replace(;base64, ); if (!contentType.startsWith(image/)) { throw new ServiceException(400, 仅支持图片文件); } // 4. 生成存储路径调用文件服务 String extension contentType.replace(image/, ).equals(jpeg) ? jpg : contentType.replace(image/, ); String path go-view/ LocalDate.now() / UUID.randomUUID() . extension; String url fileApi.createFile(bytes, path); // 5. 返回可供前端直接引用的 URL return success(Map.of(url, url, name, reqVO.getName())); }这里有几个细节值得注意第一fileApi.createFile()在 ruoyi-vue-pro 里会有不同的重载参数实际项目里按自己版本的签名调整即可核心就一句话传字节数组和存储路径返回文件访问 URL。第二接口返回的 URL 最好是完整的http://ip:port/...形式不要只返回相对路径。因为 go-view 的大屏配置在前端是直接拿来渲染的如果只存/infra/file/get/xxx.png当前端部署域名和后端访问地址不一致时图片就会加载不出来。我在实践里踩过这个坑后面问题排查部分会细说。第三接口一定要做文件大小限制。base64 上传意味着前端已经把整个图片文件转成了字符串如果用户在设计器里传一个 10MB 的高清原图JSON 字符串会有 13MB 以上接口传输就变得很吃力。我建议在 Nginx 的client_max_body_size、Spring 的spring.servlet.multipart.max-request-size、以及业务层三个地方同时限制业务层我直接限制在 5MB 以内。这样改完前端保存大屏时新上传的图片就会以 URL 形式存进 JSON不会再出现新的 base64 数据。3.3 历史数据迁移正则抽取 base64 并替换历史数据迁移是这次改造里最繁琐、也最容易出问题的一步。数据库里已经躺了几百 MB 的 base64 图片不能手动一张一张抠必须写脚本跑。我的思路是遍历大屏配置表对于每一条 JSON 配置递归查找所有data:image/...;base64,...的字段取出图片数据转存为文件拿到 URL 后把它替换到 JSON 的对应位置最后更新数据库记录。这里有一个关键决策不要用纯字符串全局替换而要用 JSON 解析递归替换。因为 base64 字符串里有很多字符比如、/、在 JSON 里可能会被转义直接在字符串层面做全局替换很容易把 JSON 结构搞坏。反正我是看到过有人用正则整体替换后JSON 里多了一堆反斜杠大屏直接打不开。我写的迁移脚本核心逻辑大概是这样// 伪代码用于说明处理流程 public void migrate() { // 1. 分批查询避免一次性加载全表 ListScreenConfig list screenConfigMapper.selectList( new LambdaQueryWrapperScreenConfig() .select(ScreenConfig::getId, ScreenConfig::getJsonContent) .last(LIMIT 500 OFFSET 0)); for (ScreenConfig config : list) { try { // 2. 解析 JSON JsonNode root objectMapper.readTree(config.getJsonContent()); // 3. 递归替换图片节点 replaceImageNode(root); // 4. 写回数据库 screenConfigMapper.updateById(config.getId(), root.toString()); } catch (Exception e) { log.error(迁移失败, id{}, config.getId(), e); // 失败的记录记录下来最后统一处理 } } } private void replaceImageNode(JsonNode node) { if (node.isObject()) { node.fields().forEachRemaining(entry - { if (entry.getValue().isTextual()) { String value entry.getValue().asText(); if (value.startsWith(data:image/)) { String url saveBase64Image(value); // 转存文件并返回 url ((ObjectNode) node).put(entry.getKey(), url); } } else { replaceImageNode(entry.getValue()); // 递归进入 } }); } else if (node.isArray()) { node.forEach(this::replaceImageNode); } }脚本实际执行的时候我还加了几个辅助策略这里分享给同样要动手的同学分批处理每批只查 500 条处理完一批再拉下一批。不要一次性把全表加载进内存否则大屏配置几千条、单条几十 MB 的情况堆内存直接被打爆。图片文件命名用 UUID不要用原始文件名。避免不同大屏上传了同名图片导致覆盖。失败记录单独落库不要直接中断整个任务。我用了 error 日志跑完之后统一捞出来分析是网络问题、解码失败还是磁盘空间不足。迁移过程中磁盘空间要预留足够。800MB 的 base64 数据大约对应 580MB 的图片文件如果本地磁盘只剩两三百 MB迁移跑到一半就没空间写文件处理非常麻烦。另外我强烈建议先抽出 10 条小记录、5 条大记录分别试跑一遍比对迁移前后的 JSON 结构确认大屏能正常打开、图片能正常显示再放开跑全量。全量跑完之后拿配置文件总字节数和图片数量做前后对比就能知道回收了多少空间。3.4 前端 go-view 图片上传行为改造后端接口准备好了历史数据也迁移完了最后一步是把 go-view 的前端上传逻辑改掉让用户在编辑器里上传图片时直接走我们的新接口而不是把图片转成 base64 塞进配置。go-view 的图片上传组件在源码里其实就是一个input typefileFileReader.readAsDataURL()的组合。默认行为是读取图片后直接 set 到组件的 url 字段。改造的思路是在读取文件之后、写入配置之前把 base64 字符串发送到后端/screen/image/upload-base64拿到 URL 之后再写入配置。改造后的前端核心逻辑大致是这个样子// 伪代码 async function handleImageUpload(file) { // 1. 读取本地文件为 base64 const base64 await readAsDataURL(file); // 2. 调用后端接口把 base64 转存为文件并返回 URL const { data } await axios.post(/screen/image/upload-base64, { data: base64, name: file.name, }); // 3. 把返回的 URL 写入大屏组件配置 setComponentConfig(url, data.url); }这里要注意一点go-view 的源码打包方式有不少版本差异有些是直接通过 npm 引入的有些是拷贝源码改写的。改的时候最好在项目里维护一份 go-view 的本地依赖方便改动上传逻辑如果你只是用了官网发布版的 go-view 组件包没有源码那可以考虑一个更简单的替代方案约定大屏里的图片全部使用“远程图片地址”不允许本地上传这也是一种强制规范。我自己的经验是两条腿走路新开发的大屏设计规范里明确要求图片必须通过上传接口存储在项目文件目录中不允许在 JSON 配置里出现data:image同时在后端保存大屏配置的接口里加一层校验如果发现保存的 JSON 里还有 base64 图片直接抛异常提示前端“请先上传图片”。这个校验很简单正则跑一下data:image/[a-zA-Z];base64就行但对防止以后有人违规操作非常有效。4. 实测效果与性能对比改造完成之后我记录了同一套环境下的前后数据这里贴出来供大家参考。环境是单机 MySQL 8.0大屏配置表大约 3000 条记录其中带图片的大屏有 1200 条左右主要是在测试阶段反复保存导致的数据堆积。指标改造前改造后大屏配置表总体积856 MB23 MB单条大屏配置最大文本长度61 MB1.8 MB保存大屏接口平均响应时间2.3 秒320 毫秒大屏前端加载完成时间5.1 秒1.8 秒mysqldump 备份耗时接近 3 分钟约 12 秒这个结果非常直观。数据库里少了 800 多 MB 的文本数据表的体积立刻降了两个数量级保存接口不用再反复传输大字符串压力自然下来了。图片文件本身还是占用磁盘空间的大约占用了 580MB这部分空间是省不掉的因为图片内容确实存在。但它从数据库挪到了本地文件目录好处很明显数据库备份不再携带大量静态图片备份速度变快、备份文件变小图片文件可以走静态资源访问Web 服务器可以直接响应不需要经过应用层解析 JSON 再返回 base64 字符串如果以后图片访问量大了还能直接在 Nginx 层对/infra/file/get/**这类路径配置缓存甚至挂 CDN。这里也提醒一句如果你用的是云数据库存储费用和 IO 费用通常比本地磁盘贵很多把图片挪出数据库之后云数据库的存储开销也会降下来这是实打实的降本。5. 常见问题与排查技巧实录5.1 图片能保存打开大屏却看不到图这是改造后我遇到最多的一个问题尤其是历史数据迁移完成后大屏的 JSON 配置里图片 URL 已经替换好了数据库也更新了但前端渲染时图片就是不出来。排查思路还是老三样看浏览器控制台、看 Network 请求、看刷新后的图片地址。最常见的原因是 URL 是相对路径。后端返回图片 URL 时如果只返回了/infra/file/get/xxx.png而前端大屏页面部署在另一个域名或者另一个端口下浏览器就会在“当前页面域名”下请求这个路径导致 404。解决方案是后端拼接完整的访问地址或者在前端做一层域名前缀补全。我选择的是后端统一返回完整地址改动一次全端生效。另一种情况是 ruoyi-vue-pro 本地文件存储的访问路径没有做映射。本地磁盘存储模式下文件是放在服务器某个目录里的必须通过 Spring MVC 的静态资源映射或者一个下载接口暴露出来。如果映射没配即使文件存在URL 请求也会 404。ruoyi-vue-pro 本身有文件访问接口直接确认配置文件里的url.prefix或者本地存储配置是否正确即可。5.2 迁移脚本跑完大屏 JSON 被破坏我前面反复强调用 JSON 解析递归替换而不是字符串替换就是因为有人会贪方便直接用正则全局replace。但即使避开了这个坑还是可能出现 JSON 被破坏的情况。有一次我在迁移时发现部分记录的 JSON 里图片替换成功但整个 JSON 字符串变长了且多了一些转义反斜杠。后来一查问题出在 base64 数据本身包含/和字符前端在生成 JSON 时已经做了转义我再用正则去匹配原始字符串匹配到的内容和 JSON 里的实际存储内容对不上替换时就产生了脏数据。正确做法还是我前面说的先objectMapper.readTree()解析成 JsonNode再递归操作节点最后writeValueAsString()序列化回字符串。这样无论 base64 里面有什么特殊字符Jackson 都会正确处理转义不会破坏 JSON 结构。如果迁移过程中真的出现大屏打不开的情况先别慌用备份数据回滚受影响的那几条记录再修正脚本重新跑。所以备份的重要性在这里就体现出来了。5.3 大图上传导致接口超时改造完新数据链路之后测试人员反馈传一张 8MB 的高清大图接口直接超时。这个问题的原因很明确前端把 8MB 的图片转成 base64 后约 11MB传输到后端、解码、写文件、返回 URL 整个过程加起来在普通内网环境下也要好几秒。再加上 Nginx 默认的请求体大小限制是 1MB超过就直接 413根本走不到后端。我的处理方式是多重限制加前端压缩Nginx 的client_max_body_size调到 20MB避免请求直接被拦掉前端在上传前用 canvas 做一次尺寸压缩大屏背景图通常 1920x1080 就够用了超过这个分辨率可以等比缩放压缩后图片体积能控制在 1MB 以内后端限制max-file-size和max-request-size并在业务层校验 base64 解码后的字节数超过 5MB 直接拒绝。注意压缩不是无脑压质量大屏展示对清晰度有要求压缩到模糊了也不行。我的经验是背景类图片压缩到宽 1920质量 0.8logo 和图标类图片保留透明通道PNG 格式不要转成 JPEG因为 JPEG 不支持透明背景转完图就变成白底了视觉上非常难看。5.4 同一大屏多版本副本产生的历史垃圾清理数据库瘦身之后还有一个比较隐蔽的问题操作日志表、版本记录表里可能还有大量旧的 base64 快照。这些数据不在大屏主表里但一样占用数据库空间。我这次改造里大屏主表从 856MB 降到 23MB日志表里还藏着几百 MB 的旧快照这个如果不处理整体优化效果会大打折扣。处理策略是分两步第一步清理历史日志。对超过保留周期的操作日志直接删除或者归档到一张独立的冷表。大屏的版本历史一般保留最近 20 个版本就够用了更早的版本绝大多数情况下根本不会回滚。第二步优化版本记录机制。与其每次保存都写一份完整 JSON 快照不如只保存 JSON 的 diff 内容或者至少保证版本记录里的图片引用是 URL 而不是 base64。这部分可以放到后续迭代里做但方向要明确。如果你已经开启了 ruoyi-vue-pro 的定时任务功能还可以配置一个每周统计任务定期检查大屏配置表里data:image/的出现次数一旦发现新增的 base64 数据超过阈值就触发告警。这个防反弹措施非常管用我强烈建议加上不然过几个月数据库很可能又涨回去。6. 基于这套改造说说我自己的体会做数据大屏优化很多时候大家第一反应是上缓存、调 SQL、加索引但这些手段面对“几 MB 的字符串存进表里”这种问题效果都非常有限。根子上的原因是数据放错了位置。大屏配置是业务数据它应该存在数据库里大屏图片是静态资源它就应该放在文件系统或者对象存储上。把这两者分开很多性能问题会自然消失。我个人的体会是这套改造最值得借鉴的并不是某个接口怎么写或者某个正则怎么配而是“先摸清数据分布再动手”的思路。我见过不少人拿到问题就直接写脚本跑一半发现备份没做、失败记录没留、替换逻辑有坑最后还得回滚。这次改造我提前做了数据体检、小批次试跑、失败补偿整个过程基本没影响线上环境这也是最让我满意的地方。如果你后续想把优化做得更细还有几个方向可以扩展同一张图片被多个大屏引用时可以做文件去重用图片内容的 MD5 作为存储文件名从根上避免重复存储访问量大的图片可以做缩略图大屏背景图用原图缩略图用于列表预览图片文件越来越多之后可以对接对象存储和 CDN把静态资源从应用服务器上彻底分流出去。最后再分享一个小技巧我给后端保存大屏配置的接口加了一个maxJsonSize校验如果传入的 JSON 字符串超过 10MB直接拒绝保存必须走图片上传接口把图片转成 URL 才能继续。这个简单的硬限制能让数据库永远不会再出现那种动辄几十 MB 的配置记录。
返回列表