1. 为什么非要把图片塞进数据库:三种现实诉求
几年前我接手一个内部工单系统,业务方要求用户提交的截图必须与工单记录强关联,退单和审计时能随时调出原图。当时的架构是图片落在NFS共享目录,MySQL里只存文件路径。上线第一周就暴露问题了:其中一台应用服务器的NFS挂载点短暂失联,应用层明明返回了写入成功,事后却找不到文件;后来的一次资源清理任务还误删了一批历史工单的截图。
那次事故之后,我对“数据库里只存路径”的方案变得非常谨慎。PostgreSQL保存图片在圈子里一直是个有争议的话题,但实际需求确实不少:电商商品图、工单附件、用户头像、合同扫描件、题库里的题目配图……很多项目初期没有对象存储基建,没有独立的图片服务器,甚至没有统一的文件服务,为了先让数据和文件同时可用,最简单稳妥的办法就是把图片放进数据库。
把图片存数据库,本质上是追求三样东西。
一致性。文件写入磁盘和数据库记录更新是两个独立操作,中间任何一步失败都会留下“数据库有记录、文件丢了”或者“文件还在、记录被回滚”的脏状态。图片本身进了库里,主记录提交,图片就提交;主记录回滚,图片也跟着消失。这在工单、订单、审批这类强事务场景里非常省心。
统一备份。数据库备份走了,图片就跟着走了。不用再考虑文件系统快照和数据库备份的配合,也不用担心恢复了数据库但忘记恢复文件目录,结果系统能启动、图片全裂开。
权限整合。数据库的用户权限体系可以顺带覆盖图片字段。业务上要求“订单关闭后图片不可见”“退款后附件自动回收”,写一条WHERE条件就能完成,文件系统那套ACL根本管不了这么细的业务规则。
当然,我也得把丑话说在前面:PostgreSQL保存图片不是“把大图往库里一塞”这么简单。它牵扯二进制类型选型、TOAST存储机制、IO性能、备份膨胀、应用层编解码一堆问题。这篇把踩过的坑、验证过的路子、还有最终架构取舍一次讲清楚。
2. PostgreSQL存图片的三条路:BYTEA、大对象、文件路径
2.1 BYTEA:最直接的二进制字段
BYTEA是PostgreSQL原生的二进制类型,从名字就能看出来,byte array,字节数组。它用来保存任意字节流,理论上单个字段最大可以到1GB,实际业务根本用不到这个上限。插入时它接受两种输入格式,默认是hex格式,以\x开头后面跟十六进制字符串,比如一张PNG图片的开头通常是\x89504e47。还有一种escape格式,兼容老版本,日常基本用不到。
BYTEA最直观的优点:它就是你业务表里的一个普通列,跟着表的行锁、事务、MVCC、备份、权限一起走。应用层拿到的就是一个字节数组,传给前端就是完整的二进制流。很多ORM框架对BYTEA的支持也最成熟,不需要额外装插件,不需要特殊的驱动配置。
缺点同样明显。大图片塞进BYTEA之后,表的单行体积会非常大,频繁读写会带来TOAST压力和IO开销。此外,BYTEA列无法建立有意义的索引(除了全等比较),没法实现“按图片内容检索”这种需求。但它依然是绝大多数场景下最推荐的方案,我后面所有的实操代码都先按BYTEA展开。
2.2 Large Object:为超大文件准备的独立存储
Large Object,简称LO,是PostgreSQL为超大对象提供的独立存储机制。它不像BYTEA那样把所有字节塞在同一行里,而是把数据拆成一个个约2KB的chunk,存进专门的系统表pg_largeobject,业务表里只保存一个OID(对象标识),相当于一个指针。
LO有一整套操作函数:lo_creat创建、lo_unlink删除、lo_import从文件导入、lo_export导出到文件、lo_from_bytea从字节数组构造、lo_get/lo_put分段读写。因为支持分段读取,LO非常适合几百MB甚至GB级别的文件,比如视频片段、原始相机素材、大型压缩包。
LO最大的坑在于生命周期管理。删除业务记录时,LO对象不会自动跟着删,必须显式调用lo_unlink,否则这些对象就像孤儿一样躺在系统表里,空间越占越多。后面第4节我会专门讲怎么清理。
2.3 外部文件加数据库元数据:不是存储技术,是架构思路
严格说这不算PostgreSQL的存储方式,而是另一种架构选择:图片二进制文件放在文件系统或者对象存储里,数据库里只存文件路径、文件名、大小、Content-Type、SHA256、上传时间这些元数据。
这可能是生产环境里用得最多的方案。对象存储有CDN加速、缩略图、防盗链这些能力,数据库负责记录“这个文件是谁传的、传到了哪里、多大、内容摘要是什么”。但它需要额外的基建,也不是所有团队一开始就有的。我更愿意把它理解为项目规模上去之后的演进目标,而不是入门就一定要上的架构。
2.4 三条路的直观对比
| 方案 | 适合对象 | 事务一致性 | 备份复杂度 | 读取性能 | 典型上限 |
|---|---|---|---|---|---|
| BYTEA | 几KB到几MB的图片/附件 | 随表完整事务 | 随库一起备份 | 全量读回,大字段有TOAST开销 | 单字段约1GB |
| Large Object | 大文件,流式分段读写 | 事务内可管理,但删除需手动 | 随库备份,需一并处理孤儿对象 | 流式读取,适合分段处理 | 理论可达数TB |
| 文件路径/对象存储 | 海量大文件,高并发读 | 需要应用层补偿 | 独立备份,需与DB恢复配合 | 走对象存储/CDN,性能高 | 几乎无上限 |
选型逻辑一句话总结:图小、量少、图省事,用BYTEA;图大、量多、有基建,走对象存储;LO在两者之间,适合“单文件非常大但又想统一备份”的特殊需求。
3. 手把手实操:BYTEA存图与取图的完整链路
3.1 建表:除了图片本身,还需要存什么
很多新手建表只写一个image_data BYTEA字段,存进去之后才后悔:取出来不知道怎么告诉浏览器这是什么类型的图,文件名也丢了。BYTEA只是裸字节流,图片的格式、文件名、尺寸这些信息不会自动跟着走。
我建议至少把这张表的字段建全:
CREATE TABLE product_images ( id BIGSERIAL PRIMARY KEY, product_id INTEGER NOT NULL, file_name TEXT NOT NULL, mime_type TEXT NOT NULL, image_size INTEGER NOT NULL, image_width INTEGER, image_height INTEGER, image_data BYTEA NOT NULL, uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_product_images_product_id ON product_images(product_id);file_name用来保留原始文件名,下载时响应头要有filename;mime_type告诉浏览器是image/jpeg还是image/png;image_size是字节数,展示列表时可以避免读大字段;image_width和image_height是一次查询出尺寸,避免后端反复解码图片取宽高。
这里有个很关键的设计习惯:不要把image_data和业务主表放在一起被SELECT *扫到。图片字段和商品信息混在同一行,会导致任何一次简单的商品列表查询都要把图片字节从TOAST表里捞出来。实际项目中,我会把图片拆到独立的product_images表,和商品主表用product_id关联,只有真正要看大图时才JOIN这张表。
3.2 Python侧读写:psycopg2和psycopg3的真实差异
Python是我最常用的操作手段,你以为的“把图片存数据库”在代码里其实特别直白。
写入:
import psycopg2 from pathlib import Path conn = psycopg2.connect( host="127.0.0.1", dbname="appdb", user="appuser", password="secret" ) cur = conn.cursor() img_file = Path("product.jpg") data = img_file.read_bytes() cur.execute( """ INSERT INTO product_images (product_id, file_name, mime_type, image_size, image_data) VALUES (%s, %s, %s, %s, %s) """, (1001, img_file.name, "image/jpeg", len(data), psycopg2.Binary(data)) ) conn.commit()注意psycopg2.Binary(data)这个包装是必须的,否则psycopg2会尝试把bytes当成字符串处理,类型会推断错。psycopg3里这个细节变了,它原生支持bytes,直接传就行:
import psycopg conn = psycopg.connect("host=127.0.0.1 dbname=appdb user=appuser password=secret") cur = conn.cursor() cur.execute( "INSERT INTO product_images (product_id, file_name, mime_type, image_size, image_data) VALUES (%s, %s, %s, %s, %s)", (1001, "product.jpg", "image/jpeg", len(data), data) ) conn.commit()读取:
cur.execute( "SELECT file_name, mime_type, image_data FROM product_images WHERE id = %s", (1,) ) row = cur.fetchone() with open(row[0], "wb") as out: out.write(bytes(row[2]))psycopg2读出来的BYTEA是memoryview类型,必须包一层bytes()才能落盘;psycopg3则直接返回bytes。这个差异踩过才知道,不报错但结果不对,写进文件发现打不开。
3.3 Java侧读写:JDBC的setBytes与getBytes
Java后端接PostgreSQL的BYTEA更简单。驱动层面已经做了转换,setBytes和getBytes直接对应数据库的BYTEA。
PreparedStatement ps = conn.prepareStatement( "INSERT INTO product_images (product_id, file_name, mime_type, image_size, image_data) VALUES (?, ?, ?, ?, ?)" ); ps.setInt(1, 1001); ps.setString(2, "product.jpg"); ps.setString(3, "image/jpeg"); byte[] data = Files.readAllBytes(Paths.get("product.jpg")); ps.setInt(4, data.length); ps.setBytes(5, data); ps.executeUpdate();读取:
PreparedStatement ps = conn.prepareStatement( "SELECT file_name, mime_type, image_data FROM product_images WHERE id = ?" ); ps.setInt(1, 1); ResultSet rs = ps.executeQuery(); if (rs.next()) { String fileName = rs.getString("file_name"); byte[] data = rs.getBytes("image_data"); Files.write(Paths.get(fileName), data); }如果图片很大,Java侧尽量用rs.getBinaryStream("image_data")流式读取,而不是一次性getBytes到内存,否则一张100MB的图就能把年轻代堆撑爆。
3.4 数据库服务器本地的文件导入技巧
有些场景下图片文件就在数据库服务器上,可能是DBA手工导入,也可能是初始化脚本灌数据。这时可以使用pg_read_binary_file把服务器本地文件读成BYTEA:
-- 只能读数据库服务器的本地路径,且通常需要超级用户权限 INSERT INTO product_images (product_id, file_name, mime_type, image_size, image_data) SELECT 1001, 'import.jpg', 'image/jpeg', pg_stat_file('/tmp/import.jpg').size, pg_read_binary_file('/tmp/import.jpg');pg_stat_file返回一个复合类型,包含大小信息。这样不用经过应用层,几十GB的数据导入也能在服务端直接完成。但要记住,这个函数只能访问数据库服务器上的文件系统,你本机客户端连远程库时不能这么用。
4. 大对象(Large Object)的正确打开方式
4.1 psql与SQL函数里的导入导出,路径到底在哪个端
大对象的导入导出有个特别容易混淆的点:lo_import函数和psql里的\lo_import命令,表面看起来一样,实际读文件的路径完全不同。
SQL函数lo_import('/tmp/a.jpg')里的路径是数据库服务器上的路径,不是客户端路径。你连的如果是远程数据库,这个路径必须存在于远程那台机器上。
而psql的\lo_import命令不一样,它是一个客户端命令:
# 注意这里是psql客户端 \lo_import /tmp/local_photo.jpg它读取的是psql所在机器的本地文件,通过网络把文件内容传到服务器端创建大对象,然后返回OID。\lo_export同理,指的是客户端本地路径,读数据库的大对象写到psql所在机器上。
所以在排障时要先分清楚:用SQL函数说“文件找不到”,去看数据库服务器的文件系统;用psql命令说“文件打不开”,去看自己客户端的文件系统。这个坑我见过不止一次,两边路径对不上,排查了半天。
从SQL里检查大对象:
-- 列出所有大对象OID SELECT oid, pg_size_pretty(lo_size(oid)) FROM pg_largeobject_metadata;4.2 从bytea方向构造大对象,以及孤儿对象清理
除了直接从文件导入,PostgreSQL还提供了lo_from_bytea函数,从字节数组构造大对象。它是反过来,允许在SQL里把一段BYTEA升级成LO,这对数据迁移、从BYTEA方案平滑切换很有用:
-- 第一个参数OID传0表示让系统自动分配 SELECT lo_from_bytea(0, pg_read_binary_file('/tmp/bigdata.bin')); -- 也可以用已有表的BYTEA字段来构造 SELECT lo_from_bytea(0, image_data) FROM product_images WHERE id = 1;删除大对象用lo_unlink:
SELECT lo_unlink(16385);我在前面提过,大对象最大的坑是孤儿对象。业务表里删除了记录,但pg_largeobject里的数据还在,日积月累磁盘空间白白吃掉几个GB。定期清理的思路是:把业务表里还引用着的OID收进一个集合,然后从pg_largeobject_metadata里找没被引用的,逐一lo_unlink:
-- 假设业务表里存大对象OID的字段是 image_oid SELECT lo_unlink(oid) FROM pg_largeobject_metadata WHERE oid NOT IN (SELECT image_oid FROM product_images WHERE image_oid IS NOT NULL);如果有多张业务表都引用了大对象,需要把所有引用OID查出来后UNION在一起。这个脚本适合放在定时任务里,每天凌晨跑一次。
4.3 大对象适用的边界在哪
大对象适合的场景很明确:单文件特别大、需要分段读取、不想让单行记录把整个数据块吃满。比如我从朋友那里见过一个教学资源平台,把实验课的视频切片存成LO,前端播放时用Range请求一段段lo_get,效果确实比BYTEA一把梭要好。
但它不适合高频小图。每张几十KB的头像如果都建一个大对象,业务表里存一堆OID,查询时还要二次跳转,孤儿清理也费劲,远不如BYTEA直接存在行里省事。一句话,大对象是给“大文件”准备的,小图片用它属于杀鸡用牛刀。
5. 存完图片之后:TOAST机制、性能下滑与表膨胀
5.1 TOAST是怎么处理图片字段的
PostgreSQL的堆表设计初衷是不希望单行记录太大,默认行大小超过约2KB就会触发TOAST(The Oversized-Attribute Storage Technique)机制。TOAST会把超大的字段值压缩,压缩后还放不下就搬到旁边专门的TOAST表里,原行内只留一个指针。
对于图片,问题很特别:JPEG、PNG本身就是高度压缩过的格式,数据库再用pglz或lz4去压,收益微乎其微,实测经常只能再压掉1%到3%。也就是说,图片数据进了数据库基本是“外置”到TOAST表,而不是压缩后放在原地。
想确认一张表的TOAST情况,可以跑这个查询:
SELECT pg_size_pretty(pg_total_relation_size('product_images')) AS total, pg_size_pretty(pg_relation_size('product_images')) AS main_table, pg_size_pretty(pg_relation_size(toast.reltoastrelid)) AS toast_table FROM pg_class AS tbl JOIN pg_class AS toast ON toast.oid = tbl.reltoastrelid WHERE tbl.relname = 'product_images';看到toast_table占比很高别慌,这是正常现象。真正要关注的是查询是否每次都把TOAST数据拉回主表。
5.2 图片字段拖慢查询的真实原因
很多同学发现存图片之后,表查询变慢了,第一反应是“数据库撑不住图片”。其实不然。
PostgreSQL的TOAST设计有个很好的特性:SELECT中的列如果没包含大字段,数据库压根不会去读TOAST表。所以查询慢往往是因为应用层写了SELECT *,每次列表展示都把image_data这列捞出来,数据库被迫把几MB的TOAST数据读回、解包、传回应用,然后应用只用其中的product_id和file_name。
解决方式很简单:
- 列表查询永远只指定需要的列,不要写
SELECT *; - 图片独立表存放,列表页先只查业务主表,点击详情再查图片表;
- 如果图片展示频率极高,加一层Redis或者CDN缓存,数据库读一次就够了。
我在项目里就是靠这几条把图片表的查询耗时从几百毫秒压回了几十毫秒。问题不在数据库,而在查询习惯和表结构设计。
5.3 表膨胀与空间回收
图片表因为单行体积大,更新和删除产生的死元组也大,更容易膨胀。一个常见现象:删了一批历史图片后,表空间占用却一点没降。原因就是VACUUM回收的是可复用空间,不会把文件缩小;要真正把空间还给操作系统,得用VACUUM FULL。
需要注意,VACUUM FULL会锁表,生产环境不能直接对着大表跑。我一般这样做:
- 日常只做普通
VACUUM,保持表可写、空间可复用; - 定期维护窗口执行
VACUUM FULL,或者用pg_repack在线整理; - 删除图片尽量避开业务高峰期,配合维护计划分批次删。
另外,PostgreSQL 14之后可以配置default_toast_compression参数选择pglz或lz4。lz4压缩更快、CPU开销更低,虽然图片压缩收益不大,但如果是其他文本类资源,这个参数值得调。
5.4 和MySQL的BLOB对比:差异与迁移注意点
MySQL处理图片字段通常用BLOB家族:TINYBLOB、BLOB、MEDIUMBLOB、LONGBLOB,区别只是最大长度。PostgreSQL的BYTEA不分这四档,一个类型通吃,单字段上限1GB,实际更宽松。
两边最大的差异在传输限制。MySQL有个max_allowed_packet参数,数据包超过设定值,插入会直接报错。很多从MySQL迁到PostgreSQL的团队对PostgreSQL没这个参数感到意外,以为哪里配置漏了。其实PG没有类似的网络包上限,只要驱动和内存扛得住,单张大图可以直接塞进去。
迁移时还要注意几个函数对应关系:
| MySQL | PostgreSQL |
|---|---|
LOAD_FILE('/path/f.jpg') | pg_read_binary_file('/path/f.jpg') |
FROM_BASE64(...) | decode(..., 'base64') |
TO_BASE64(...) | encode(..., 'base64') |
LONGBLOB | BYTEA |
LOAD_FILE和pg_read_binary_file都要求文件在数据库服务器本地,但PG这边通常要求超级用户权限,普通用户执行不了,授权时要清楚。
6. 生产环境踩坑实录:base64、导出乱码与从库延迟
6.1 base64存TEXT字段:一个让人后悔的冲动
我第一次在项目里存图片,图省事,直接在表里加了一个TEXT字段,把图片转成base64字符串往里塞。当时觉得这样“兼容性好”,前端拿来就能用。上线两个月后问题集中爆发:
- 存储膨胀了大约33%,base64用4个字符表示3字节,原来的10GB图片数据变成了13GB多;
- 查询时每次都要把字符串传回应用层再解码成图片,CPU白烧一遍;
- 想直接在数据库层做图片大小判断、二进制处理,完全没有办法;
- 日志、慢查询文件里全是几MB的base64长串,日志系统直接被打爆。
后来我把字段改成BYTEA,应用层在HTTP传输时才做base64编码,落库一律用原始字节流。空间下来了,日志干净了,慢查询也没了。记住这句话:base64只是传输层的编码格式,不是存储格式。
6.2 COPY BINARY导出:看起来像文件但不是文件
图片存进数据库之后,DBA经常会想把它导出成文件看一下。有的同学会写出这样的命令:
\copy (SELECT image_data FROM product_images WHERE id = 1) TO '/tmp/photo.jpg' WITH (FORMAT binary)然后发现导出的文件用图片软件打不开,文件头是奇怪的字节。原因很简单:COPY ... WITH (FORMAT binary)用的是PostgreSQL私有的COPY BINARY格式,文件开头有固定头PGCOPY\n\377\r\n\0,后面还跟着列的数目、长度等元信息,不是裸的图片字节流。就算你只SELECT了一列BYTEA,它也不是原图。
想要把BYTEA还原成真正的图片文件,三条路最靠谱:
- Python/Java应用层读出来后写文件,这是最通用的;
- 先用
lo_from_bytea把BYTEA转成大对象,再用lo_export导出; - pgAdmin里查看BYTEA字段,选择下载或者复制十六进制再转,但大图不推荐。
不要试图用COPY直接导出二进制文件,那是给数据迁移用的,不是给媒体文件用的。
6.3 从库延迟与WAL洪峰
大规模批量导入图片时,主库会产生大量WAL日志。因为图片字节本身就要写进WAL,主从架构下备库要重放相同的数据量,一旦批量导入速度超过备库回放速度,从库延迟就肉眼可见地上去了。
我踩过的一次事故:业务初始化要导入10万张历史商品图,总共大概30GB,我用了一个大事务循环插入。主库插入完成倒是快,备库延迟最高到了半个小时,所有从库读的接口全部返回旧数据,线上差点出大问题。
从此以后,图片批量导入我严格遵循几条纪律:
- 绝对不用一个事务装所有图片,每批100到200张提交一次;
- 上传前先压缩、限尺寸,能压到500KB以内就不到处传2MB的原图;
- 批量任务挂在低峰期,避免和正常业务抢WAL带宽;
- 如果主从延迟确实太严重,优先停下来等备库追平再继续。
6.4 内存OOM与流式读取
有一次同事反馈:“存了图片之后,应用跑几天就OOM。”我一看代码,导出接口用JDBC的getBytes把所有图片字段一次性读进内存,十万张图全量加载,不炸才怪。
正确姿势是流式读取和分页配合。Java里用getBinaryStream,一次处理一张;Python里用服务端游标,边读边写。伪代码逻辑差不多:
cur = conn.cursor("export_images_cursor") cur.itersize = 100 cur.execute("SELECT id, file_name, image_data FROM product_images WHERE product_id = %s", (1001,)) for row in cur: with open(row[1], "wb") as f: f.write(bytes(row[2]))另外,应用内存调优别忘了一个关键点:图片数据本来就不该长期停留在堆内存里。用完立即置空引用,别在集合里存着一堆大字节数组等GC。这个习惯比什么JVM参数都管用。
7. 最终架构:数据库存元数据,对象存储放文件
7.1 混合架构怎么落
项目规模上来之后,我最终采用的方案基本都是混合架构:PostgreSQL保存图片的元数据,对象存储或分布式文件系统保存图片二进制。
落地方案并不复杂,还是那张product_images表,把image_data列换掉,加上对象存储的路径和校验信息:
CREATE TABLE product_images ( id BIGSERIAL PRIMARY KEY, product_id INTEGER NOT NULL, file_name TEXT NOT NULL, mime_type TEXT NOT NULL, image_size INTEGER NOT NULL, object_key TEXT NOT NULL, -- 对象存储里的key sha256 TEXT NOT NULL, -- 内容校验 uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now() );应用层上传时,先传文件到对象存储拿到object_key,再写一条记录。下载时先查数据库拿到object_key,再从对象存储取文件。删除时两边配合:先删对象存储的文件,再删数据库记录;万一删文件失败,记录还在,选一个补偿任务清理。
这中间最需要保证的是数据库记录和文件状态的最终一致。我的做法是:业务主流程先写数据库记录,提交成功后异步上传文件;上传成功更新状态字段,上传失败则标记并定时重试。文件删除同理。通过状态机把一致性从“强一致”降级为“最终一致”,换来的是海量图片的可用性。
7.2 我的规模判断标准
很多朋友问我:“到底多少量级才该切换到对象存储?”我给不出一个放之四海而皆准的数字,但可以分享我自己的判断标准,你照着套就行:
- 单张图片小于1MB,总量半年内不超过50GB,无对象存储基建:直接用BYTEA,省心,一致性好;
- 单张图片在1到10MB之间,总量预估会到几百GB:认真考虑上对象存储,但可以先用BYTEA跑起来,留好迁移字段;
- 原图超过10MB,或者总量几TB起:必须上对象存储,数据库里只留元数据。
最怕的不是选错方案,而是表结构设计时没预留演进空间。我习惯一开始就把image_data和元数据字段分开,切换存储时只用改应用层一个模块,数据库表结构基本不动。
最后再分享一个实践小经验:不管选哪条路,上传图片在入库存前先做一次压缩和水印处理。数据库里存一张适合业务使用的中等分辨率图,原始文件走对象存储冷备,这样BYTEA方案的性能压力会小很多,对象存储方案的流量费用也能省一大截。图片处理这种事,越靠前做,后面越舒服。