1. 先把问题看清楚:馆藏级原图为什么不能“直接上传、直接访问”
做文化遗产数字化项目的人,几乎都会遇到同一个瞬间:扫描室里一台高精度设备辛苦扫了一整天,产出一张几个 GB 的 TIFF,交付方一句话——“放到网站上,让大家都能看清楚”。这句话听起来很朴素,落到 Nginx 和高分辨率图像的在线展示上,却是一整套工程问题。我这些年经手的几个馆藏影像项目,从书画长卷、碑帖拓片到器物环拍,最后都收敛到同一套思路:源文件归源文件,展示归展示,中间用瓦片金字塔隔开,前端只取屏幕需要的那一小块,Nginx 只负责把静态瓦片以最低成本吐出去。这篇内容就是把这套做法从头拆到尾,包括参数怎么算、配置怎么写、坑在哪里。适合正在做数字展厅、藏品数据库、学术影像比对平台的技术人员看,也适合刚接触大图展示、被“图片一放大就糊”折磨过的前端同学。
1.1 一张馆藏原图的真实体量,以及浏览器的硬上限
先算账,不然后面所有决策都是拍脑袋。假设一幅横卷,物理尺寸 3 米×1 米,用 600dpi 扫描,像素就是 70866×23622。这个数字很多同行的第一反应是“是不是算错了”,没错,600dpi 下每英寸 600 个点,3 米约 118 英寸,乘出来就是七万像素宽。未压缩的 24 位 RGB 数据量是 70866×23622×3 字节,约 5.0 GB。就算降规格到 300dpi,也是 35433×11811,未压缩约 1.25 GB。
关键在于浏览器的解码方式。JPEG 文件在磁盘上是压缩的,一张高质量 JPEG 可能是 300~600MB,但浏览器要把它解成位图才能画到 canvas 或 img 上,解码后的内存是 宽×高×4 字节。七万×两万三千的图,解码后约 6.7 GB。别说手机,台式机也会直接崩掉标签页。即便是一张 8000×6000 的“中等图”,解码后也要 192MB,十个标签页就是 2GB。
所以结论很硬:高分辨率图像在线展示的本质,是让客户端永远不要解码整张图。用户看到的永远是一块屏幕大小的区域,屏幕 1920×1080,按 2 倍像素密度算 3840×2160,也就 800 万像素,对应内存约 33MB。从 6.7GB 压到 33MB,这就是瓦片化存在的全部理由。
1.2 三条技术路线的取舍,以及我为什么主推“离线预切 + Nginx 静态分发”
行业里常见三条路:
| 路线 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 直接下发整图 | 上传大 JPEG,前端缩放 | 实现最快,一天上线 | 首屏几十秒,内存爆炸,移动端不可用 |
| 动态按需切图 | IIIF 服务端按 region/size 实时裁切 | 参数任意,接标准接口 | CPU 密集,单次 80~300ms,需要缓存层 |
| 离线预切瓦片 | 预先切好金字塔,Nginx 直接发静态文件 | 零拷贝、可上 CDN、进程无内存压力 | 存储放大,重切成本高 |
| 混合 | 热图预切,长尾动态 | 兼顾成本与灵活性 | 两套链路要维护 |
我主推第三条为主、第二条为辅,理由不是“静态文件简单”,而是三个很实际的考量。
第一,Nginx 发静态小文件是它最擅长的事。sendfile on走的是内核零拷贝,文件内容不经过用户态,worker 进程几乎不占内存。一台 4 核 8G 的机器,发 256px 的 JPEG 瓦片,几千 QPS 是常态,这个性价比任何应用层切图服务都比不了。
第二,瓦片是天然可缓存的不可变资源。第 8 层第 37 列第 12 行的瓦片,内容永远不变,可以放心设一年缓存,可以扔到任何 CDN 边缘节点。动态切图服务的 URL 里带 region、size、rotation、quality 一堆参数,缓存键管理起来麻烦得多。
第三,故障面小。预切是离线任务,切坏了重跑就行,线上只有 Nginx 一个组件。动态切图一旦服务挂了或者内存被大图打爆,整个展厅白屏。做文化机构项目,稳定性的权重往往高于灵活性。
那什么时候必须上动态?研究型用户要“任意区域导出 3000px 宽的高清局部做比对”,或者藏品库每天新增几十件、每件要出缩略图/水印图/灰度图/局部特写,预切跟不上节奏,这时候就静态与动态并存,热的走静态,长尾走 IIIF 反代。
1.3 目录结构与命名:既要快,也要防止被整站抓走
瓦片目录我一般这么组织:
/data/tiles/{assetKey}/{level}/{col}_{row}.jpgassetKey用一个不可枚举的短哈希(比如 12 位 base62),不要用藏品编号。原因是藏品编号往往有规律,A0001、A0002,被人写个循环就能把整个馆藏镜像走。哈希虽然挡不住决心很大的人,但至少把成本从“改一个数字”提高到“先拿到清单”。
层级目录不要拍平。一张 40000×30000 的图,第 8 层就有约 150×120 个文件,如果全放一个目录,某些文件系统在目录项过万后readdir和查找性能会下降。分成level/col_row的扁平结构一般够用,因为单层文件数通常在几万以内;如果藏品特别大,可以再加一级level/col/row。
还有个小细节:扩展名和 Content-Type 必须对齐。我见过因为目录里混了.jpg实际是 WebP 内容的文件,浏览器按 image/jpeg 解析失败,前端表现为“某些层级能看,某些层级白块”,查了半天以为是切图脚本的 bug。
2. 预处理:把 5GB 的母版变成一套能上线的瓦片
这一步决定了后面所有环节的上限。母版处理错了,Nginx 配得再漂亮也救不回来。
2.1 母版与派生分离,色彩空间必须先落地
第一条铁律:原始 TIFF 是母版,永久保存,永不在母版上做任何色调调整。展示用的所有文件都是从母版派生的副本,派生链要有记录。我见过项目为了“看起来更亮”,直接在母版上拉曲线另存,两年后要做学术级的色彩比对,母版已经不可信了,这是无法挽回的损失。
第二条:色彩空间。扫描设备出来的文件常带 AdobeRGB 或者厂商自定义 profile,浏览器对未标记图片一律按 sRGB 处理。你在一台校过色的显示器上看是温润的绢色,用户在看是发灰发绿的,这不是浏览器的问题,是 ICC 没转。派生流程里必须做一次到 sRGB 的转换,用 libvips:
vips icc_transform input.tif output_srgb.tif /usr/share/color/icc/sRGB.icc \ --intent=relative --depth=8--intent=relative是相对比色意图,比感知意图更能保住色相;--depth=8是降到 8 位,Web 展示用 8 位足够。如果是墨迹浓重的碑帖、拓片,深色区域层次容易在 8 位量化时丢掉,我的做法是派生两套:一套 8 位给浏览,一套保留 16 位给学术导出。
第三条:锐化要分层做,不要全局 USM。缩略层本身是下采样出来的,本来就偏软,如果只对最大层锐化,用户缩小看的时候会觉得“糊”;如果对每一层都用同一套参数强锐化,小层会出现明显的光晕和白边。我的做法是层级越小的锐化半径越小、强度越低,用脚本按 level 传不同参数,这个细节在文档里基本没人写,但实际观感差异很大。
2.2 用 libvips 切 DZI 金字塔:命令、参数与内存账
工具选型上,libvips 是我目前的首选,不是因为它时髦,而是内存模型不同。ImageMagick 处理大图倾向于把整幅图读进内存再操作,5GB 的 TIFF 在 8GB 机器上大概率 OOM;libvips 是基于需求的流式处理,按条带/瓦片处理,同样的任务内存占用常常只有前者的十分之一到五分之一。切一张 4 万像素的图,libvips 峰值内存两三个 GB 就够,这个差距直接决定你需不需要买一台 64G 的机器。
先看尺寸,别急着切:
vipsheader -a input_srgb.tif | head -20确认width、height、bands、interpretation。如果bands是 4 而实际是 CMYK 或者带 alpha,切之前要处理掉,否则瓦片颜色会怪。
切 DZI:
vips dzsave input_srgb.tif /data/tiles/8f3a2c1b9d \ --layout=dz \ --suffix='.jpg[Q=82,optimize-coding,strip]' \ --tile-size=512 \ --overlap=1 \ --depth=onepixel \ --background='255 255 255' \ --vips-concurrency=4逐项说清楚:
--layout=dz生成 Deep Zoom 结构,{level}/{col}_{row}.jpg,前端 OpenSeadragon 可以直接吃。--tile-size=512,为什么不是 256,下面单独讲。--overlap=1,相邻瓦片重叠 1 像素,用来消除缩放时的接缝白线,代价是体积增加不到 1%。--suffix里的Q=82是质量,optimize-coding让 libjpeg 生成更优的霍夫曼表(体积能省 3%~8%,几乎零成本),strip去掉 EXIF 和 ICC,因为我们已经统一转 sRGB,且不希望把扫描设备的元数据暴露出去。--vips-concurrency=4要和机器核数匹配,设太高反而因为内存带宽争抢变慢。
跑之前先估算磁盘。层级数公式是ceil(log2(max(w,h)/tile))。40000×30000、tile=512:40000/512=78.1,log2(78.1)≈6.29,向上取整是 7,也就是 level 0 到 level 7 共 8 层。总瓦片数约等于最大层瓦片数的 4/3,最大层是 ceil(40000/512)×ceil(30000/512)=79×59=4661,乘 4/3 约 6200 张。平均每张 110KB,总体积约 680MB。原始 5GB 的母版,展示用 680MB,这个压缩比是合理的。如果用 256px,瓦片数会涨到约 24600 张,平均 40KB,总体积约 1GB。也就是说 512 在体积和请求数上都更优。
2.3 瓦片尺寸、重叠与压缩质量:三者的实际取舍
| 参数 | 256px | 512px | 我的建议 |
|---|---|---|---|
| 单图瓦片总数(4 万像素) | 约 24600 | 约 6200 | 512 更省请求、省存储 |
| 单瓦片大小 | 30~50KB | 90~130KB | 512 单次下载更重,但请求少 |
| HTTP/2 下表现 | 请求多,头部开销占比高 | 明显更好 | 开了 HTTP/2 就用 512 |
| 移动端弱网 | 首块更快 | 首块稍慢 | 可对移动端降级到 256 一套 |
| 接缝风险 | 高 | 低 | 都加 overlap=1 |
还有一个容易忽略的点:JPEG 的色度抽样。默认 often 是 4:2:0,即色度分辨率减半,对风景照没影响,但碑帖的朱红印文、书画的细线描边,4:2:0 会出现彩色边缘溢出,肉眼可见的“糊边”。这类素材我统一用4:4:4:
--suffix='.jpg[Q=80,subsample=on,optimize-coding]'libvips 里subsample=on表示不做色度抽样,即 4:4:4;off是 4:2:0。体积会涨 15% 左右,但对线描、篆刻、拓片是值得的。照片类的器物、织物可以用 4:2:0 省体积。
格式上再补一句:WebP 体积能比 JPEG 小 25%~30%,但解码开销高,老设备上拖动会有轻微卡顿;AVIF 压缩率更好,但大面积解码在低端安卓上更吃力。我的策略是主链路用 JPEG 保兼容,同时对支持 WebP 的客户端提供一套并行目录,用 Nginx 的map按Accept头做内容协商,这个下文有配置。
3. Nginx 侧的核心配置:把静态分发压到极限
前面都是准备,这一段是真正决定用户体感的地方。同样一套瓦片,配置差一档,首屏时间能差一倍。
3.1 系统级调优:小文件海量读取的四个关键开关
worker_processes auto; worker_rlimit_nofile 65535; events { worker_connections 8192; multi_accept on; } http { sendfile on; tcp_nopush on; tcp_nodelay on; open_file_cache max=20000 inactive=60s; open_file_cache_valid 30s; open_file_cache_min_uses 2; open_file_cache_errors on; aio threads; directio 4m; }逐条解释为什么。
sendfile on是零拷贝,文件从页缓存直接到 socket,不经过用户态缓冲区。对瓦片这种“小文件、高并发”的场景,这一项能省掉大量内存拷贝和上下文切换。
tcp_nopush on配合 sendfile,把响应头和文件内容合并到尽量少的 TCP 包里发出,减少小包数量。注意它和tcp_nodelay看似矛盾,实际是不同阶段:nopush 管发送时的攒包,nodelay 管的是不延迟小包,Nginx 内部会正确处理,两个都开就行。
open_file_cache是整套配置里性价比最高的一项。默认每个请求都要open()+fstat()+close(),海量瓦片请求下这些系统调用占比很高。开了之后 Nginx 缓存文件描述符和 stat 结果,第二次访问同一张瓦片基本不碰磁盘元数据。max=20000是缓存的条目数,按你的热点瓦片量估;inactive=60s是 60 秒没被访问就淘汰;open_file_cache_valid 30s是多久重新校验一次;open_file_cache_errors on连“文件不存在”也缓存,这点很重要——被爬虫扫目录时,大量 404 不会反复打磁盘。
aio threads与directio 4m是针对大文件下载的。小于 4MB 的文件走页缓存,大于 4MB 的(比如用户点“下载全图”)绕过页缓存直接读,避免一次大文件把整个页缓存冲掉,影响瓦片命中率。这一对参数在纯瓦片场景作用不大,但有下载功能就必须配。
注意:
worker_rlimit_nofile要和系统ulimit -n一起改,只改 Nginx 配置不改系统限制,高并发下会出现Too many open files,报错日志里是accept4() failed (24: Too many open files),很多人误以为是连接数问题。
3.2 缓存头、CORS 与内容协商:让浏览器和 CDN 少问几次
瓦片是不可变资源,缓存头要敢给:
location ~ ^/tiles/(?<asset>[0-9a-z]{12})/(?<lvl>\d+)/(?<col>\d+)_(?<row>\d+)\.(?<ext>jpg|webp)$ { root /data/tiles; add_header Cache-Control "public, max-age=31536000, immutable" always; add_header X-Content-Type-Options "nosniff" always; add_header Access-Control-Allow-Origin "*" always; etag off; if_modified_since off; access_log off; }immutable告诉浏览器这一年内连条件请求都不用发。配合关掉etag和if_modified_since,可以显著减少 304 往返——很多项目保留了 ETag,结果每次拖动都产生几十个 304 请求,虽然响应体很小,但在高延迟网络下依然拖慢首屏。既然 URL 已经绑定了内容,条件请求就是纯浪费。
access_log off是必要的,一次深度浏览产生几十上百个瓦片请求,全记日志会让磁盘 IO 成为瓶颈。要统计的话在 CDN 侧或者采样记,别在源站全量记。如果确实需要审计(有些馆藏项目有访问留痕要求),那就用access_log ... buffer=64k flush=5s缓冲写入,别一条一条刷盘。
CORS 头不是给 img 标签用的,是给 canvas 用的。前端要做局部放大镜、叠加对比、客户端水印,都需要getImageData,图片没带 CORS 头会污染 canvas,取像素直接抛异常。这个坑很典型:开发环境同源没问题,一上 CDN 就炸。
内容协商用map:
map $http_accept $tile_ext { default jpg; "~*image/webp" webp; "~*image/avif" avif; } location ~ ^/tiles/(?<asset>[0-9a-z]{12})/(?<path>.+)\.jpg$ { root /data/tiles; try_files /$asset/$path.$tile_ext /$asset/$path.jpg =404; }先试 WebP/AVIF,没有再回落到 JPEG。注意try_files的回落必须有,否则不支持新格式的客户端全 404。
3.3 下载与导出场景:slice 让大文件不撑爆内存
浏览走瓦片,但总有人要“下载原图”或者“导出这块区域”。如果直接X-Accel-Redirect一个 600MB 的文件,单个 worker 的连接缓冲会吃掉不少内存,几十个人同时下就危险了。
location /download/ { internal; alias /data/exports/; slice 1m; proxy_set_header Range $slice_range; proxy_cache download_cache; proxy_cache_key $uri$slice_range; proxy_cache_valid 200 206 1d; limit_rate_after 8m; limit_rate 2m; }slice 1m把大文件切成 1MB 的片,配合缓存可以按片命中,多人下载同一文件时后半段直接走缓存。limit_rate_after 8m让前 8MB 全速(保证用户很快看到进度条在动),之后限速 2MB/s,一个人下 600MB 大约 5 分钟,既不堵死出口带宽,也不至于让用户等到放弃。
对于“导出指定区域”这种需求,就不能靠 slice 了,必须走动态切图,见下一节。
3.4 授权与防盗:Referer 挡不住,secure_link 才是正解
文化机构的影像往往有授权约束,不能让人随便批量拉走。valid_referers只能拦住最老实的一批人:Referer 可以伪造,可以直接置空,用curl一个都不带。真正可用的方案是签名 URL。
location /secure-tiles/ { secure_link $arg_s,$arg_e; secure_link_md5 "$secure_link_expires$uri$remote_addr our-shared-secret"; if ($secure_link = "") { return 403; } if ($secure_link = "0") { return 410; } alias /data/tiles/; add_header Cache-Control "private, max-age=300" always; }业务后端在用户打开详情页时,按“资源路径 + 过期时间 + 客户端 IP”算一个 MD5 签进去,有效期给 300 秒。前端拿到一批带签名的瓦片 URL 开始加载,过期后拖动新区域会 403,这时候前端静默续签一次再重试。这个机制的好处是:链接不能长期外传,换 IP 就失效,爬虫拿到的 URL 五分钟后就废了。
几个实操细节:
- 签名串里带
$remote_addr会影响 CDN,因为回源 IP 是 CDN 节点的而不是用户的。要么在 CDN 侧下发签名、源站不校验 IP,要么在边缘做校验。混合架构里这点必须先想清楚,否则上线当天全站 403。 - 服务器时间必须同步。签名是基于过期时间戳的,源站时钟偏 5 分钟,表现就是“刚签出来就 410”,而且只在部分机器上复现,极难查。
- 瓦片路径几百个,逐个签名会让后端压力大。做法是签“前缀 + 资产 ID”,服务端校验前缀,前端在路径后缀上自由拼接。安全性略降但可用性好很多。
配合internal+X-Accel-Redirect做鉴权后发文件:
location /protected/ { internal; alias /data/tiles/; } location /api/tile/ { proxy_pass http://backend; proxy_set_header X-Original-URI $request_uri; }后端校验通过后返回X-Accel-Redirect: /protected/8f3a2c1b9d/7/37_12.jpg,Nginx 接到这个头就自己发文件,应用进程一个字节都不用读,内存占用几乎为零。这是我认为必须掌握的一个模式,很多团队还在用后端readfile+echo的方式发图片,几百并发就把内存吃满了。
3.5 压缩策略:JPEG 瓦片别开 gzip,但别一刀切
这是个老生常谈但依然有项目配错的地方。JPEG、WebP、PNG 已经压缩过,再过一遍 gzip,输出体积基本不变(偶尔还变大),CPU 白烧。但反过来,把 gzip 全局关掉,info.json、前端 JS/CSS、SVG 图标就吃亏了。
gzip on; gzip_comp_level 5; gzip_min_length 1024; gzip_vary on; gzip_types text/plain text/css application/javascript application/json image/svg+xml;gzip_types里不写image/jpeg,问题解决。gzip_comp_level 5是性价比拐点,1 到 5 提升明显,5 到 9 收益很小但 CPU 翻倍。
如果编译了 brotli 模块,application/json的压缩率比 gzip 再高 15%~25%。IIIF 的info.json和 manifest 动辄几十 KB,量大时这个收益不小。静态资源用gzip_static on读预压缩的.gz文件,连压缩 CPU 都省了。
一个历史坑:
gzip开启时sendfile在某些版本上会退化为用户态拷贝发送。所以图片目录除了不开 gzip,还要确保响应不被 gzip 过滤器接管。用gzip off;显式写在瓦片 location 里,比只依赖 gzip_types 更保险。
4. 动态切片:什么情况下必须上 IIIF,以及怎么和 Nginx 配合
静态预切能覆盖 80% 的场景,剩下 20% 一定要交给动态。
4.1 静态瓦片搞不定的三类真实需求
第一类是任意区域导出。研究者要“这块印文区域,给我导一张 3000px 宽的图做比对”。固定金字塔里没有这个尺寸的切片,临时用 canvas 拼瓦片再导出,画质和裁切精度都不够。
第二类是持续增量。某馆藏库每月新增几百件,每件要出缩略图、水印图、灰度图、局部特写,还可能要按不同授权等级出不同清晰度的版本。全部预切会让离线任务队列永远追不上,而且大部分长尾资源没人看,切了是浪费。
第三类是标准化对接。第三方平台要用 IIIF Image API 拉图,你得提供符合规范的端点,region、size、rotation、quality、format五个参数齐全。
这三类需求下,动态服务是必须的。常用的是 Cantaloupe(基于 Java,配置走 JSON)或者 IIPImage(C++,性能好但配置偏老派)。我倾向 Cantaloupe,因为它的delegate和cache机制好用,且可以直接读预切好的金字塔做二级缓存。
4.2 反向代理的配置细节:超时、缓冲与参数白名单
upstream iiif_backend { server 127.0.0.1:8182; keepalive 32; } location /iiif/ { proxy_pass http://iiif_backend/; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_connect_timeout 5s; proxy_send_timeout 120s; proxy_read_timeout 120s; proxy_buffering on; proxy_buffers 16 64k; proxy_buffer_size 128k; proxy_max_temp_file_size 512m; client_max_body_size 1m; client_body_buffer_size 128k; add_header X-Cache-Status $upstream_cache_status always; }proxy_http_version 1.1+Connection ""是开长连接,动态切图服务每次请求都要读源图、解码、裁切、编码,建连开销占比不小,长连接能省一截。
超时给 120 秒是因为“导出全图 4000px 宽”这种请求真的会跑几十秒,特别是源图 5GB 时,光解码就要十几秒。给太短会 504,用户看到的是“导出失败”,但服务端其实还在算,日志里能看到请求被 abort。
proxy_buffering on加较大的缓冲区,是为了让上游尽快把结果交给 Nginx 然后释放线程。如果关掉缓冲(很多教程为了“流式”建议关),上游线程会被慢客户端拖住,几十个下载就能把切图服务的线程池占满。
参数白名单不能省。IIIF 规范里size=full表示全尺寸,一张 4 万像素的图请求full/full会瞬间吃掉几 GB 内存。要么在 Cantaloupe 侧配max-size,要么在 Nginx 层拦:
location /iiif/ { if ($arg_size ~* "^full$") { return 403; } if ($arg_region ~* "^full$") { return 403; } proxy_pass http://iiif_backend/; }注意,IIIF 3 的写法是max而不是full,两个都要拦。这类请求大概率来自爬虫或者误配置,不拦就是给自己埋雷。
4.3 给动态切片加缓存:参数怎么算,缓存键怎么定
proxy_cache_path /var/cache/nginx/iiif levels=1:2 keys_zone=iiif:100m max_size=200g inactive=30d use_temp_path=off; location /iiif/ { proxy_cache iiif; proxy_cache_key "$scheme$host$uri$is_args$args"; proxy_cache_valid 200 7d; proxy_cache_valid 404 1m; proxy_cache_lock on; proxy_cache_lock_timeout 60s; proxy_cache_lock_age 90s; proxy_cache_use_stale updating error timeout http_500 http_502; proxy_cache_background_update on; add_header X-Cache-Status $upstream_cache_status always; proxy_pass http://iiif_backend/; }keys_zone=iiif:100m大概能放 80 万个缓存键(Nginx 文档给的粗算是每 MB 约 8000 个键),对中型馆藏够用。max_size=200g是磁盘上限,inactive=30d是 30 天没访问就淘汰,配合levels=1:2打散目录,避免单目录文件过多。
proxy_cache_lock on是关键。冷启动时,一张热门图可能同时来 30 个相同请求,没有锁的话 30 个全部回源,把 CPU 打满,然后 30 个都超时。开了锁之后,第一个请求回源,其他 29 个等待结果,只算一次。
proxy_cache_use_stale updating配合proxy_cache_background_update on,意思是缓存过期时先返回旧内容,后台悄悄更新。对瓦片这种“旧一点无所谓”的资源,这能彻底消除缓存过期瞬间的抖动。
这里有个大坑必须点出来:缓存键不能包含签名参数。如果业务侧给 IIIF URL 加了?s=xxx&e=xxx的 secure_link 签名,而proxy_cache_key用了$args,那么每个用户的签名不同,缓存全部击穿,命中率永远是 0,你还以为是切图服务太慢。正确做法有两种:签名参数放在路径里(/iiif/signed/xxx/...),或者把签名参数从缓存键中排除:
set $clean_args $args; proxy_cache_key "$scheme$host$uri$clean_args";更干脆的做法是源站不校验签名,只在 CDN 边缘校验。架构上先想清楚,比事后调参有用得多。
4.4 并发与带宽估算:把预算算在前面
假设一个中型展厅,峰值 300 人同时深度浏览。
静态瓦片路径:每人一次加载约 40 个瓦片请求,10 秒内完成,请求速率约 300×40/10 = 1200 req/s。单瓦片按 100KB(512px)算,带宽 120MB/s,约 960Mbps。这个数字看着吓人,但瓦片请求是突发型的,实际平均下来只有峰值的 1/3 左右;而且一旦上了 CDN,边缘命中率通常能到 90% 以上,回源只有 30~100Mbps,一台普通云主机完全扛得住。
动态切图路径:单次切片(从 4 万像素源图裁 512 区域)实测 80~200ms,4 核机器按 8 并发算,每秒约 40~50 次。如果每天有 2 万次动态请求,平均到 8 小时工作时段是 0.7 次/秒,毫无压力;但如果有人集中导出,或者某个爬虫扫 IIIF 端点,瞬间就能打满。所以限流必须配:
limit_req_zone $binary_remote_addr zone=iiif_req:10m rate=10r/s; limit_conn_zone $binary_remote_addr zone=iiif_conn:10m; location /iiif/ { limit_req zone=iiif_req burst=20 nodelay; limit_conn iiif_conn 8; limit_req_status 429; limit_conn_status 429; }浏览时并发瓦片请求本来就多,limit_conn 8会不会误伤?静态瓦片路径不适用这条限制,只对/iiif/生效,正常用户导出是单次请求,8 个并发足够。真有人一次导 50 张,限到 8 个并发慢慢导,反而是合理的服务策略。
5. 排查实录:那些文档里不会写的现象与定位路径
这一段是纯经验,都是我或者同事真实踩过的。
5.1 常见故障速查表
| 现象 | 最可能原因 | 定位手段 | 处理 |
|---|---|---|---|
| 瓦片 404,但文件确实在 | root与alias路径拼接错,或 location 正则捕获组与目录不对应 | curl -I看实际请求路径,对照error.log里open() "..." failed的完整路径 | 改用alias并注意结尾斜杠;正则命名捕获后在 try_files 里显式拼 |
| 图片整体发糊 | 前端按 DPR 选了低一层级,或 CSS 尺寸大于瓦片原始像素 | 浏览器里看img.naturalWidth与 CSS 宽度之比 | 前端按devicePixelRatio选层;公式见 5.2 |
| 瓦片拼接处有白线/黑线 | overlap 缺失,或 CSS 缩放引入半像素 | 放大 800% 看接缝是否在 1px 内 | 重切加--overlap=1;CSS 用整数像素定位 |
| 颜色发灰发绿 | ICC 未转 sRGB,浏览器按 sRGB 解释 | 对比母版与派生图的色值 | 预处理加icc_transform |
| 403 但链接刚生成 | secure_link 时钟不同步,或 CDN 回源 IP 不在签名串里 | 看 Nginx error.log 的secure_link相关记录;对比服务器date | 同步 NTP;签名串去掉$remote_addr |
ERR_INCOMPLETE_CHUNKED_ENCODING | 上游中断 +proxy_buffering off,或 slice 与 gzip 同开 | 看上游日志是否有异常;看响应头有无Transfer-Encoding: chunked | 开 buffering;瓦片目录显式gzip off |
| 首屏很慢但带宽没跑满 | HTTP/1.1 队头阻塞,几十个瓦片串行 | 浏览器 Network 面板看并发数和协议列 | 开 HTTP/2(HTTPS);合并小瓦片到 512px |
偶发Too many open files | worker_rlimit_nofile或系统ulimit不够 | cat /proc/$(pidof nginx)/limits | 两处一起调大 |
| 拖动卡顿、掉帧 | 前端同时解码过多瓦片,或瓦片体积过大 | Performance 面板看 decode 耗时 | 限制同时加载数;降级到 256px;开img.decoding="async" |
| 内存缓慢上涨 | open_file_cache过大 + 大文件下载走页缓存 | ps看 RSS,free -m看 cache | 调max值;配directio 4m |
5.2 清晰度问题:按这个顺序查,别乱猜
“图片糊”是反馈最多的问题,但原因可能在前端、在 CSS、在切图、在源文件,四个层面。我的固定排查顺序是:
第一步,在浏览器里量naturalWidth。如果naturalWidth等于瓦片尺寸(比如 512),说明拿到的就是单张瓦片,正常;如果远小于屏幕显示尺寸,说明层选错了。
第二步,算应该用哪一层。瓦片层级和缩放的关系是:level = maxLevel - floor(log2(1/scale)),其中scale是显示尺寸与原图尺寸之比。换个算法更直观:视口宽度vw,DPR 为d,需要的图像宽度是vw * d,对应的 level 是ceil(log2(原图宽 / (vw*d)))的补数。前端库(OpenSeadragon、Leaflet)一般都封装了,但如果自己写,这里极易差一层。差一层的观感就是“整体发虚但不至于看不清”,最容易被误判为“源图质量不行”。
第三步,看 CSS。有没有transform: scale()、有没有width: 100%把 512 的瓦片拉到 800 显示。非整数缩放会触发浏览器的双线性插值,看起来就是糊的。瓦片容器用整数像素定位,image-rendering保持默认,不要为了“清晰”设成pixelated,那会让缩小时出现锯齿。
第四步,才回头怀疑源文件和切图。拿母版在本地用看图软件 1:1 对比,如果母版清晰而瓦片糊,问题在切图参数(多半是 Q 太低或者 subsample 开了)。
5.3 上线前的压测方法与验收清单
压测瓦片有个陷阱:只压同一个 URL,open_file_cache全命中,QPS 能到几万,结果毫无意义。必须随机路径。
wrk -t4 -c200 -d30s --latency \ -s random_tiles.lua \ http://tiles.example.internalrandom_tiles.lua里随机拼/{asset}/{level}/{col}_{row}.jpg,asset从一个小清单里取(模拟真实藏品分布),level按权重偏向前几层(用户大多数时间在低层级浏览)。这样压出来的数字才接近真实。
验收清单我一般固定这么几条:首屏(从打开到能看清主体)在 4G 网络下不超过 3 秒;连续拖到底再拖回来,没有超过 500ms 的白块;200 并发持续 5 分钟,5xx 为 0,P99 延迟低于 800ms;同一张图用手机和桌面看,色差在肉眼可接受范围内;CDN 回源率低于 15%;随机抽 10 件藏品,用母版做像素级对比,确认没有整体偏色或裁切错位。
6. 我在几个项目里攒下的几条硬经验
先说预切这件事。刚开始做的时候我总想着“一次切到位,以后不用动”,实际做下来,参数一定要留出重切的空间。瓦片目录里除了图,我还会放一个meta.json,记录母版路径、ICC、切图命令、libvips 版本、切图时间。原因很简单:两年后有人问“这张图为什么和母版颜色不一样”,你能立刻定位到是哪一步、用什么参数处理的,而不是对着一堆目录发懵。这个习惯帮我省过至少两次返工。
第二条关于磁盘。瓦片总量看着不大,但版本一多就撑不住。我的做法是瓦片目录按asset/v1、asset/v2分版本,新版本切完验证通过后,改 Nginx 的root或软链切换,旧版本保留 7 天再删。切换用软链是最省事的,ln -sfn一下,Nginx 不需要 reload,因为路径解析在请求时发生。但要注意open_file_cache会缓存旧路径的 fd,切换后头几十秒可能还是旧图,inactive到期就正常了,别慌。
第三条关于限流阈值。一开始我给 IIIF 端点设的是10r/s,结果有次学术团队来做批量比对,一次性提交了 300 个导出请求,全被 429 挡住,对方以为是系统故障。后来改成按账号维度限流,匿名用户严一点,登录用户宽一点,并且返回体里带上Retry-After,前端排队重试,体验立刻就正常了。纯按 IP 限流对机构用户不友好,因为整个机构可能共用一个出口 IP。
第四条关于日志。瓦片路径关掉 access_log 之后,出问题会失去线索。我的折中是:正常 200 不记,$status非 200 的记录到一个单独文件,同时把$upstream_cache_status、$request_time、$sent_http_content_length带上。这个文件一天通常只有几 MB,排查 404 风暴、403 集中爆发、缓存命中率骤降都非常好用。有一次 CDN 配置变更导致回源请求暴涨,就是靠这个日志里MISS比例从 8% 跳到 70% 发现的。
最后分享一个前端侧的小技巧,跟 Nginx 配合起来很值:瓦片请求失败时(签名过期、网络抖动),不要立刻重试,按指数退避重试两次,并且优先重试视口中心的瓦片。这样用户拖动时,中心区域先清晰,边缘慢一点没关系,观感上比“全部白块然后同时出现”好太多。这个策略加上 Nginx 侧的proxy_cache_lock,弱网下的体验提升比我预想的明显。