广告资源增量同步:用 SHA256 内容寻址保证幂等与可回滚

问题背景

智能广告机是无人值守设备。后端管理系统把广告排期通过 MQTT 下发,设备订阅本片区的主题拿到广告列表,再把视频和图片从资源服务器拉到本地,驱动大屏播放。实测一个资源目录约 78MB,单条广告资源最大 36MB。

如果每次下发都全量重拉,会同时撞上三堵墙:设备常年在 4G 或宽带计费环境下跑,带宽是真金白银;本地存储反复重写大文件,寿命和余量都吃不消;最致命的是下载中途断网会留下一个半截的 mp4,播放器要么打不开要么解码出错——大屏黑屏或花屏,这在商场里是商业事故。

所以同步策略的核心目标有两个:能不下就不下,下了就必须是对的。

设计思路

把"文件是不是最新的"这个判断,从"文件在不在本地"换成"本地内容哈希是否等于服务端签名"。服务端在下发广告列表时,为每个资源附带一个 sign 字段,即该资源文件的 SHA256 十六进制串。本地对已有文件算哈希,一致就说明内容一定没变,直接跳过下载——零流量、零磁盘写。

这不只是省资源,它把同步变成了一个可重入的纯函数:判断依据全部是"文件内容"和"数据库记录"这些可观测状态,而不是执行历史。同一份列表重复下发、下载失败重试、升级后首次同步,结果都收敛到同一个状态。

关键实现

链路入口

设备订阅 adsList/<area> 主题,收到 JSON 后解析成广告对象列表,每条广告挂两个资源:顶部视频(type 1)和底部图片(type 2),各自带下载地址和签名。接着按广告主键判断是新增还是更新,再逐资源处理。

判断树

text
对每个资源:
  本地文件存在?
  ├─ 不存在 ──▶ 直接下载
  └─ 存在 ───▶ 计算本地文件 SHA256
                ├─ == 服务端 sign ──▶ 内容未变,跳过下载
                └─ != 服务端 sign ──▶ 重新下载

下载后二次校验

下载完成落盘后,再算一次 SHA256 与下发的 sign 比对,不信任传输过程本身:

c
QString localSign = HashUtils::calculateFileSha256(localPath); /* 4KB 分块流式 */
if (localSign == res.sign) {
    /* 入库、更新资源路径,更新场景下删除旧文件 */
} else {
    QFile::remove(localPath);   /* 自清理,坏文件不进播放列表 */
}

这是端到端校验,比只信 HTTP 层的 Content-Length 更可靠——它防住的正是下载中断产生的半截文件。校验失败即删除临时文件,不留垃圾,也不会让坏文件进入播放列表。

文件与库记录不一致的补偿

还有一种更隐蔽的情况:文件在、哈希也对,但数据库资源表里没有对应记录,比如上次写库时正好断电。此时代码会补一条记录,避免"文件明明在却不播"这种诡异问题。补偿逻辑挂在"哈希一致"分支里,不需要额外触发条件。

为什么是 SHA256

CRC32 是线性校验,改动内容极易构造出相同值;MD5 也已被证明存在碰撞。这里虽然不是安全场景,但选 SHA256 的额外成本几乎为零:Qt 的 QCryptographicHash::Sha256 是一等公民,且按 4KB 分块流式读取,不会把 36MB 的视频整个读进内存。

踩坑与经验

  • 分块流式哈希是必须的。一次性读入大文件在嵌入设备上会直接吃掉可用内存。
  • 更新场景删除旧文件前,要先判断新旧路径不同,否则会误删刚下载的新文件。
  • 同步链路最初用 waitForFinished() 把异步下载同步化,而整条链路跑在 UI 线程上,下载 36MB 视频期间界面直接卡死,时间不刷新、天气请求也排队。正确做法是下载完成发信号,用信号槽把"下载 → 校验 → 入库 → 通知播放列表刷新"串成非阻塞链路,或者把整条链搬进工作线程,数据库每线程用独立连接。
  • 数据库操作为省事用了字符串拼接,既有注入面,URL 里带引号还会直接破坏语句;删除语句写成了 SQLite 不支持的 delete * from。应改为 prepare + bindValue。

小结

内容寻址把"要不要下载"变成一次哈希比对,二次校验把"下载成功"变成一次哈希比对,补偿逻辑把"库和文件不一致"变成一次哈希比对后的顺手修复。三步加起来,同步链路做到幂等、可重入、可回滚。对无人值守设备来说,这套设计的价值不在技术新颖,而在于它把最常见的两类线上事故——重复拉取和坏文件上屏——从根上消掉了。