问题背景
智能广告机是无人值守设备。后端管理系统把广告排期通过 MQTT 下发,设备订阅本片区的主题拿到广告列表,再把视频和图片从资源服务器拉到本地,驱动大屏播放。实测一个资源目录约 78MB,单条广告资源最大 36MB。
如果每次下发都全量重拉,会同时撞上三堵墙:设备常年在 4G 或宽带计费环境下跑,带宽是真金白银;本地存储反复重写大文件,寿命和余量都吃不消;最致命的是下载中途断网会留下一个半截的 mp4,播放器要么打不开要么解码出错——大屏黑屏或花屏,这在商场里是商业事故。
所以同步策略的核心目标有两个:能不下就不下,下了就必须是对的。
设计思路
把"文件是不是最新的"这个判断,从"文件在不在本地"换成"本地内容哈希是否等于服务端签名"。服务端在下发广告列表时,为每个资源附带一个 sign 字段,即该资源文件的 SHA256 十六进制串。本地对已有文件算哈希,一致就说明内容一定没变,直接跳过下载——零流量、零磁盘写。
这不只是省资源,它把同步变成了一个可重入的纯函数:判断依据全部是"文件内容"和"数据库记录"这些可观测状态,而不是执行历史。同一份列表重复下发、下载失败重试、升级后首次同步,结果都收敛到同一个状态。
关键实现
链路入口
设备订阅 adsList/<area> 主题,收到 JSON 后解析成广告对象列表,每条广告挂两个资源:顶部视频(type 1)和底部图片(type 2),各自带下载地址和签名。接着按广告主键判断是新增还是更新,再逐资源处理。
判断树
对每个资源:
本地文件存在?
├─ 不存在 ──▶ 直接下载
└─ 存在 ───▶ 计算本地文件 SHA256
├─ == 服务端 sign ──▶ 内容未变,跳过下载
└─ != 服务端 sign ──▶ 重新下载下载后二次校验
下载完成落盘后,再算一次 SHA256 与下发的 sign 比对,不信任传输过程本身:
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。
小结
内容寻址把"要不要下载"变成一次哈希比对,二次校验把"下载成功"变成一次哈希比对,补偿逻辑把"库和文件不一致"变成一次哈希比对后的顺手修复。三步加起来,同步链路做到幂等、可重入、可回滚。对无人值守设备来说,这套设计的价值不在技术新颖,而在于它把最常见的两类线上事故——重复拉取和坏文件上屏——从根上消掉了。