尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PostgreSQL图片存储方案全对比:BYTEA、大对象与对象存储架构实践

PostgreSQL图片存储方案全对比:BYTEA、大对象与对象存储架构实践 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开销单字段约1GBLarge 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用来保留原始文件名下载时响应头要有filenamemime_type告诉浏览器是image/jpeg还是image/pngimage_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( host127.0.0.1, dbnameappdb, userappuser, passwordsecret ) 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(host127.0.0.1 dbnameappdb userappuser passwordsecret) 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与getBytesJava后端接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_unlinkSELECT 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就会触发TOASTThe 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没有类似的网络包上限只要驱动和内存扛得住单张大图可以直接塞进去。迁移时还要注意几个函数对应关系MySQLPostgreSQLLOAD_FILE(/path/f.jpg)pg_read_binary_file(/path/f.jpg)FROM_BASE64(...)decode(..., base64)TO_BASE64(...)encode(..., base64)LONGBLOBBYTEALOAD_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方案的性能压力会小很多对象存储方案的流量费用也能省一大截。图片处理这种事越靠前做后面越舒服。
返回列表