每次在团队里做技术方案评审,只要碰上“传文件并保存业务数据”这种涉及跨数据源操作的场景,总会有兄弟跳出来焦虑分布式一致性的问题。
大家脑子里立马蹦出来的全是高大上的词:分布式事务、最终一致性、补偿机制……
说实话,这其实是典型的把简单问题复杂化。
坚决抛弃任何试图在 DB 和云存储之间做强一致性的方案!
别焦虑一致性问题
比如我们云存储会用 MinIO,在生产环境最实用、最稳妥的策略就是:无脑选择先传 MinIO,后写数据库,写失败了产生的废弃文件直接摆烂不管,或者弄个定时任务异步回收。
上传 MinIO 是个走网络的 HTTP 请求,本地数据库写入是本地磁盘 I/O 或者内网极速 I/O,这两个动作压根就不在一个维度上。更何况,MinIO 这种对象存储天生就不支持类似关系型数据库的 rollback 操作。
在业务代码里,最不容易出生产事故的代码流程:
// 核心心法:网络 I/O 绝对不能包裹在数据库事务里!
public String uploadAndSave(MultipartFile file){
// 第一步:在毫无事务负担的情况下,裸调 MinIO
// 这一步就算网络抖动卡了 10 秒,也只是阻塞一个 Tomcat 的工作线程,绝不会拖死数据库
String fileUrl = minioService.upload(file);
// 第二步:拿着 URL 去调带事务的数据库写入方法
try {
dbService.saveRecord(fileUrl);
} catch (Exception e) {
// 第三步:数据库崩了?啥也别干,直接报错。
// MinIO 里那个孤儿文件随它去吧,别在这儿手欠写什么 try-catch 补偿删除逻辑!
throw new BusinessException("保存失败");
}
return fileUrl;
}
这段代码量少得可怜,完全可控,团队刚毕业的新人过来一看就懂。最关键的是,这套逻辑天然避开了长事务问题,不管 MinIO 那边网络多卡,都不会占用数据库的连接。
为什么说试图做强一致性是过度设计?
我是最怕遇到那种爱拍脑袋搞过度设计的人。有的人觉得既然要保证一致,那就直接上 Seata 之类的分布式事务框架。
这纯纯属于病急乱投医。Seata 的 AT 模式是靠解析 SQL 和记录 undo_log 来实现回滚的,请问 MinIO 提供这玩意了吗?
没有。
为了让 MinIO 支持分布式事务,你就得搞 TCC 模式,必须自己硬着头皮手写一套 Try-Confirm-Cancel 的补偿代码。就为了让用户上传一张破头像,你把整个业务的复杂度翻了三倍。将来线上出了 Bug,排查报错的时候能在好几个微服务之间折腾半天,性价比极低。
还有人说,那我退一步,用 本地消息表 + RocketMQ 做最终一致性总行了吧?
思路听着很正,先写本地消息表发 MQ,消费者拿到消息再去异步传 MinIO。但这在对象存储场景下是个大坑。你仔细想想,传文件是极其消耗带宽和内存的操作。
你打算把庞大的二进制文件流直接塞进 MQ 里?还是把文件暂存在本地磁盘,再让 MQ 通知消费者去读盘?前者直接把高并发的 MQ 当成了运送大件垃圾的卡车,后者凭空多引入了一个极易出故障的本地状态流转中间件。
这完全是自嗨式架构!!!
废弃文件撑爆硬盘怎么办?
讲到这里,肯定有人心里犯嘀咕:如果写数据库失败了,MinIO 里不就留下了一个永远没人用的垃圾文件吗?时间长了把硬盘撑爆怎么办?
实话实说,这根本不是当前阶段该你考虑的问题。
在大厂的实际业务中,要明白一个成本铁律:存储空间是所有 IT 资源里最不值钱的。 程序员的开发成本、系统的排障成本,远远高于买几块硬盘的钱。
哪怕你的系统一天产生一万个上传失败的垃圾图片,一个月攒下来也就几十个 G,放到 MinIO 里算个啥?用高昂的代码复杂度和运维成本,去抠这点极其廉价的磁盘空间,这笔账怎么算都亏。
如果实在有代码洁癖,或者你们团队就是想严谨一点,解决办法也很简单:做异步回收。
搞个定时任务,每天半夜两点扫一遍昨天上传到 MinIO 的文件记录,拿着文件路径去数据库里 IN 一把。如果在数据库里找不到这个路径,直接调用 removeObjects 批量删掉。
这样一来,你的业务主链路清清爽爽,没有任何耦合,一致性问题在后台静默就解决了。
极端业务场景怎么破?
当然,肯定有喜欢较真的兄弟会跳出来杠:万一我传的不是几十 KB 的图片,而是几个 G 的绝密监控视频呢?留在 MinIO 里既占地方又不安全,怎么搞?
真碰上这种特殊的业务边界,可以用 临时桶(Temp Bucket)策略。
在 MinIO 里建两个桶,一个叫 temp,配置生命周期规则(Lifecycle Rule),存活超过 24 小时自动物理销毁;另一个叫 formal,作为永久存储。
- 业务端上传视频,一律先丢进
temp 桶,马上拿到 URL,然后去写数据库。
- 数据库写成功了,发个极轻量的异步事件(MQ 或者普通异步线程),后台偷偷把文件从
temp 桶 copy 到 formal 桶,顺便更新一下数据库里的 URL。
- 如果数据库写失败了呢?你什么都不用做。 24小时后,MinIO 的底层机制自己就把那个大文件给删了。
这样连一行定时任务的代码都不用写,MinIO 的原生特性就帮着把一致性兜底了。
说在最后
最后,在这个话题上,我的经验是永远记住一条架构红线:
任何涉及外部网络调用的操作,不管是上传 MinIO、调第三方的 HTTP 接口,还是发验证码邮件,千万、绝对不能包裹在数据库的 @Transactional 事务代码块里!
把网络 I/O 剥离出事务,系统至少能避开 80% 莫名其妙的生产宕机。有类似踩坑经验的兄弟,不妨多去云栈社区交流一下,分享些务实的开发心得。