第一章:直播录像golang
在实时音视频场景中,将直播流持久化为本地录像文件是常见需求。Go 语言凭借其高并发模型、轻量级 Goroutine 和丰富的生态库(如 pion/webrtc、gortsplib、goav),成为构建低延迟、高稳定录像服务的理想选择。
录像架构设计思路
典型方案分为三类:
- 拉流录制:主动连接 RTMP/HTTP-FLV/HLS 源,解析协议并转存为 MP4 或 MKV;
- 推流录制:接收客户端推送的流(如通过 WebRTC DataChannel 或自定义 TCP 协议),按帧写入容器;
- 中间代理录制:在流媒体服务器(如 LiveKit、Ant Media Server)与播放端之间插入 Go 编写的代理层,旁路复制音视频包。
使用 goav 实现 MP4 录制示例
需先安装 FFmpeg 开发库及绑定:
# Ubuntu 示例
sudo apt install libavcodec-dev libavformat-dev libswscale-dev libavutil-dev
go get github.com/giorgisio/goav/avformat
核心逻辑如下(简化版):
// 初始化输出上下文,自动探测 MP4 封装格式
oc := avformat.AvformatAllocOutputContext2(nil, nil, "mp4", "output.mp4")
// 创建视频流并设置编码参数(H.264, 1280x720, 30fps)
vs := oc.AddStream(avcodec.AvcodecFindEncoder(avcodec.AV_CODEC_ID_H264))
vs.CodecCtx().SetWidth(1280)
vs.CodecCtx().SetHeight(720)
vs.CodecCtx().SetFps(30)
// 打开输出文件并写入头信息
oc.AvformatWriteHeader(nil)
// 循环写入 AVPacket(需从输入流解复用获得)
oc.AvInterleavedWriteFrame(&pkt) // pkt 含时间戳、数据、流索引
oc.AvWriteTrailer() // 写入尾部元数据
注意:实际项目中需处理时间基转换(AV_TIME_BASE)、关键帧对齐、音频同步及错误重试。
关键注意事项
- 时间戳必须严格单调递增,否则 MP4 muxer 可能拒绝写入;
- 推荐使用
avformat.AvInterleavedWriteFrame而非AvWriteFrame,以保障音视频包交错写入; - 若源流无音频,需手动创建静音 AAC 包或禁用音频流,避免容器校验失败。
第二章:HLS/DASH双协议实时分片核心机制解析与Go实现
2.1 HLS协议切片逻辑与Go中m3u8动态生成实践
HLS(HTTP Live Streaming)依赖分段传输:媒体流被切分为固定时长的TS片段,并通过m3u8索引文件动态描述播放顺序与元信息。
切片核心逻辑
- 每个TS片段时长通常为2–10秒(推荐6秒)
m3u8需实时更新,支持#EXT-X-TARGETDURATION、#EXT-X-MEDIA-SEQUENCE等关键标签- 支持
#EXT-X-DISCONTINUITY处理编码参数突变
Go动态生成m3u8示例
func generateM3U8(segments []Segment, baseURL string) string {
var b strings.Builder
b.WriteString("#EXTM3U\n")
b.WriteString("#EXT-X-VERSION:3\n")
b.WriteString("#EXT-X-TARGETDURATION:6\n")
b.WriteString("#EXT-X-MEDIA-SEQUENCE:0\n")
for _, s := range segments {
b.WriteString(fmt.Sprintf("#EXTINF:%.3f,\n%s%s\n", s.Duration, baseURL, s.Filename))
}
b.WriteString("#EXT-X-ENDLIST\n")
return b.String()
}
该函数按HLS v3规范构造播放列表:#EXTINF后接精确到毫秒的持续时间(s.Duration),baseURL确保路径可解析;#EXT-X-ENDLIST标识静态流结束——若为直播,则需移除此行并维护序列号递增。
关键字段对照表
| 标签 | 含义 | 典型值 |
|---|---|---|
#EXT-X-TARGETDURATION |
最大片段时长(秒) | 6 |
#EXT-X-MEDIA-SEQUENCE |
起始序号(直播中持续递增) | 12345 |
#EXTINF |
单片段实际时长(秒) | 5.987 |
graph TD
A[原始音视频流] --> B[FFmpeg切片为TS]
B --> C[Go服务监听切片目录]
C --> D[构建Segment结构体列表]
D --> E[调用generateM3U8生成响应]
E --> F[HTTP返回text/plain; charset=utf-8]
2.2 DASH协议MPD构建与SegmentTemplate毫秒级时间对齐实现
MPD(Media Presentation Description)是DASH流媒体的核心描述文件,其时间精度直接决定客户端播放的同步质量。
SegmentTemplate时间对齐关键参数
<SegmentTemplate> 中需精确配置以下属性:
timescale="1000":以毫秒为单位的时间刻度基准duration="4000":每个segment时长4000毫秒(4秒)startNumber="1":起始序号initialization="$RepresentationID$/init.mp4"
毫秒级时间戳生成逻辑
<SegmentTemplate
timescale="1000"
duration="4000"
startNumber="1"
initialization="$RepresentationID$/init.mp4"
media="$RepresentationID$/seg-$Number$.m4s" />
逻辑分析:
timescale="1000"表示每单位时间 = 1ms;duration="4000"即每个segment覆盖4000个时间单位 → 精确对应4000ms。客户端据此计算$Time$或$Number$映射关系,避免累积漂移。
时间对齐验证指标
| 指标 | 合格阈值 | 测量方式 |
|---|---|---|
| Segment边界抖动 | ≤ ±5ms | 抓包分析moof中tfdt.baseMediaDecodeTime |
| MPD更新延迟 | 监控availabilityStartTime动态偏移 |
graph TD
A[MPD生成器] -->|注入timescale=1000| B[SegmentTimeline计算]
B --> C[毫秒级$Number$→$Time$映射]
C --> D[客户端精准seek与buffer对齐]
2.3 音视频流时钟同步模型:PTS/DTS校准与Go time.Ticker精准调度
音视频同步的核心在于统一时间基(timebase)下的PTS(Presentation Timestamp)与DTS(Decoding Timestamp)协同调度。
数据同步机制
PTS决定帧何时呈现,DTS决定何时解码。当音视频采样率不一致(如音频48kHz、视频30fps),需以主时钟(通常为音频)为参考进行差值补偿。
Go调度实践
ticker := time.NewTicker(time.Duration(1e9 / 30)) // 30fps → ~33.33ms
for {
select {
case t := <-ticker.C:
pts := int64(t.UnixNano() / 1e6) // 毫秒级PTS基准
renderFrame(pts) // 同步渲染
}
}
time.Ticker 提供纳秒级精度的周期触发;1e9/30 计算理论帧间隔(纳秒),避免浮点累积误差。注意:实际需结合音频时钟做动态漂移校正。
校准关键参数对比
| 参数 | 作用 | 典型值 | 精度要求 |
|---|---|---|---|
| PTS | 呈现时刻 | 以毫秒为单位递增 | ±2ms内 |
| DTS | 解码时刻 | ≤ PTS,可乱序 | 依赖编解码器 |
| Ticker周期 | 调度粒度 | time.Duration(33333333) |
graph TD
A[音视频包入队] --> B{解析PTS/DTS}
B --> C[主时钟校准]
C --> D[计算播放偏移]
D --> E[time.Ticker触发渲染]
E --> F[动态补偿音频时钟漂移]
2.4 多路GOP对齐切片策略:基于FFmpeg Libavcodec解封装的Go绑定设计
多路GOP对齐切片需在解封装阶段即捕获关键帧时序与PTS/DTS一致性,避免后续转封装引入漂移。
核心约束条件
- 所有输入流必须共享同一时间基(
AV_TIME_BASE_Q归一化) - GOP起始必须为IDR帧且PTS严格单调递增
- 切片边界需对齐各流最近共同IDR(min-max PTS窗口内求交集)
Go绑定关键接口设计
type Demuxer struct {
fmtCtx *C.AVFormatContext
streams []*Stream // 封装C.AVStream + 同步时钟偏移
}
func (d *Demuxer) AlignNextGOP() (int64, error) {
// 返回对齐后全局最小IDR PTS(单位:微秒)
return C.av_rescale_q(
C.find_min_idr_pts(d.fmtCtx), // C层实现:遍历所有流AVPacket->flags & AV_PKT_FLAG_KEY
C.AV_TIME_BASE_Q, // 源时间基(1/1000000)
C.AV_TIME_BASE_Q, // 目标时间基(保持微秒精度)
), nil
}
av_rescale_q确保跨流时间戳无损换算;find_min_idr_pts在C层原子遍历,规避Go GC导致的AVPacket生命周期风险。
对齐决策流程
graph TD
A[读取各流首帧] --> B{是否均为IDR?}
B -->|否| C[丢弃非IDR帧直至全IDR]
B -->|是| D[提取各流IDR PTS]
D --> E[计算PTS交集窗口]
E --> F[选取最大下界作为切片起点]
2.5 切片元数据一致性保障:原子写入、CRC32校验与跨协议索引映射
原子写入机制
采用“先写日志后更新索引”两阶段提交策略,确保元数据变更的不可分割性:
def atomic_update_slice_meta(slice_id: str, new_meta: dict):
# 1. 持久化WAL日志(fsync保证落盘)
wal_log = {"op": "update", "slice_id": slice_id, "meta": new_meta, "ts": time.time_ns()}
with open(f"{WAL_DIR}/{uuid4()}.log", "wb") as f:
f.write(pickle.dumps(wal_log))
os.fsync(f.fileno()) # 强制刷盘,避免缓存丢失
# 2. 更新内存索引并刷入只读快照文件(mmap+copy-on-write)
index_map[slice_id] = new_meta
persist_snapshot(index_map) # 原子替换 snapshot.idx.tmp → snapshot.idx
os.fsync() 是原子性关键:绕过页缓存,确保日志物理写入磁盘;persist_snapshot() 通过 os.replace() 实现快照文件的原子切换。
CRC32校验集成
每个切片元数据块附加校验字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
crc32_checksum |
uint32 | crc32(meta_bytes + salt) |
salt |
bytes | 随机8字节,防彩虹表攻击 |
跨协议索引映射
统一抽象为三元组 (protocol, namespace, logical_key) → slice_id,支持 S3/POSIX/WebDAV 协议无缝寻址。
graph TD
A[HTTP PUT /v1/blobs/foo] --> B{Protocol Router}
B -->|S3| C[S3 Adapter → s3://bucket/ns/foo]
B -->|POSIX| D[FS Adapter → /data/ns/foo]
C & D --> E[Map to slice_id via index_map]
E --> F[Validate CRC32 + Atomic Read]
第三章:断点续录高可用架构设计
3.1 录制状态持久化:SQLite嵌入式元数据库与WAL模式事务管理
为保障录制任务在崩溃或热更新后状态可恢复,系统采用 SQLite 作为轻量级嵌入式元数据库,启用 WAL(Write-Ahead Logging)模式提升并发写入可靠性。
WAL 模式核心优势
- ✅ 支持读写并行(读不阻塞写,写不阻塞读)
- ✅ 崩溃后自动回滚未提交事务,保证 ACID
- ❌ 不适用于 NFS 等不支持原子 rename 的文件系统
启用 WAL 的初始化代码
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL; -- 平衡性能与持久性
PRAGMA wal_autocheckpoint = 1000; -- 每1000页触发检查点
synchronous = NORMAL表示仅确保 WAL 文件头落盘,避免FULL模式带来的 I/O 延迟;wal_autocheckpoint防止 WAL 文件无限增长,由后台线程自动归并。
| 参数 | 推荐值 | 说明 |
|---|---|---|
journal_mode |
WAL |
启用预写日志 |
synchronous |
NORMAL |
兼顾性能与数据安全 |
busy_timeout |
5000 |
避免锁冲突时立即报错 |
graph TD
A[开始录制] --> B[BEGIN IMMEDIATE]
B --> C[INSERT/UPDATE 状态表]
C --> D[COMMIT]
D --> E[WAL 日志追加]
E --> F[定期 autocheckpoint → 主库合并]
3.2 断点恢复状态机:从崩溃现场重建GOP边界与序列号连续性
断点恢复的核心挑战在于:崩溃时 GOP 头可能未完整写入,且 RTP 序列号存在非单调跳变。状态机需基于残留帧头、时间戳差分与序列号模运算三重线索协同判定。
数据同步机制
状态机定义五种状态:IDLE → SYNCING → GOP_HEAD_FOUND → SEQUENCE_VALIDATING → RECOVERED。迁移依赖以下条件:
| 状态迁移触发条件 | 判定逻辑 |
|---|---|
SYNCING → GOP_HEAD_FOUND |
检测到 0x00000167(SPS)且 PTS 差值 > 500ms |
SEQUENCE_VALIDATING → RECOVERED |
连续3帧满足 (seq[i] - seq[i-1]) % 65536 == 1 |
def validate_gop_boundary(last_pts, curr_pts, last_seq, curr_seq):
# last_pts/curr_pts: uint64, 单位为90kHz时钟滴答
# last_seq/curr_seq: uint16, RTP序列号
pts_gap = (curr_pts - last_pts) & 0xFFFFFFFFFFFFFFFF
seq_delta = (curr_seq - last_seq) % 65536
return pts_gap > 45000 and seq_delta == 1 # 500ms ≈ 45000 ticks
该函数通过双阈值约束排除PTS回绕误判,并强制序列号严格递增,确保 GOP 起始帧的时空一致性。
graph TD
A[IDLE] -->|收到首个NALU| B[SYNCING]
B -->|检测SPS+大PTS跳变| C[GOP_HEAD_FOUND]
C -->|连续验证seq delta==1| D[RECOVERED]
3.3 并发安全的录制会话管理:Go sync.Map与context.Context生命周期协同
数据同步机制
sync.Map 适用于读多写少的会话元数据场景,避免全局锁竞争。配合 context.Context 可实现会话自动清理:
type SessionManager struct {
sessions *sync.Map // key: sessionID (string), value: *RecordingSession
}
func (sm *SessionManager) Start(ctx context.Context, id string) {
session := &RecordingSession{ID: id, Cancel: ctx.Done()}
sm.sessions.Store(id, session)
// 关联上下文取消事件
go func() {
<-ctx.Done()
sm.sessions.Delete(id) // 自动回收
}()
}
逻辑分析:
Store()线程安全写入;Delete()在ctx.Done()触发后执行,确保会话对象不泄漏。context.WithTimeout()可精确控制单次录制生命周期。
生命周期协同要点
- ✅
sync.Map提供无锁读取,高并发查询性能稳定 - ✅
context.Context的Done()通道天然适配 goroutine 退出通知 - ❌ 不可直接将
*context.Context作为 map value(应存取消函数或状态)
| 协同维度 | sync.Map 优势 | Context 协同价值 |
|---|---|---|
| 安全性 | 原生并发安全 | 取消信号线程安全广播 |
| 资源释放 | 无 GC 压力 | 自动触发 defer/cleanup |
| 扩展性 | 支持动态增删 | 可嵌套传递(WithCancel/WithValue) |
graph TD
A[Start Recording] --> B{Context Active?}
B -->|Yes| C[Store session in sync.Map]
B -->|No| D[Skip store & return error]
C --> E[Background cleanup on Done()]
E --> F[Delete from sync.Map]
第四章:S3自动归档与生产级运维集成
4.1 分片异步上传管道:Go Worker Pool + AWS SDK v2流式分段上传
为应对大文件(>5GB)高并发上传场景,我们构建了基于固定容量 Worker Pool 的分片异步上传管道,结合 AWS SDK for Go v2 的 s3manager.Uploader 流式分段能力。
核心设计原则
- 每个分片独立 goroutine 处理,避免阻塞主上传流
- 分片大小动态适配(默认 5MB,最小 5MB,最大 5GB)
- 错误隔离:单分片失败不中断整体流程,支持重试队列
分片上传流程(Mermaid)
graph TD
A[Reader → 分片切片] --> B[Worker Pool 分发]
B --> C{S3 UploadPartAsync}
C --> D[成功:记录 ETag]
C --> E[失败:入重试队列]
D & E --> F[CompleteMultipartUpload]
关键参数配置表
| 参数 | 值 | 说明 |
|---|---|---|
Concurrency |
8 | Worker 数量,平衡 CPU 与 S3 并发限制 |
PartSize |
5_242_880 | 5MB,满足 S3 最小分片要求 |
MaxRetries |
3 | 单分片最大重试次数 |
示例:分片上传初始化
uploader := s3manager.NewUploader(cfg, func(u *s3manager.Uploader) {
u.Concurrency = 8
u.PartSize = 5 * 1024 * 1024 // 必须 ≥5MB
})
// 注意:PartSize 需为 5MB 整数倍,否则 SDK panic
该配置确保每个 worker 独立调用 UploadPart,底层复用 HTTP 连接池,并自动处理签名时效与重试退避。
4.2 S3生命周期策略联动:通过Go SDK动态配置Transition与Expiration规则
S3生命周期策略是成本优化的核心机制,Transition用于降级存储类别(如 STANDARD → STANDARD_IA),Expiration控制对象自动删除时间。
配置Transition与Expiration的协同逻辑
当对象创建满30天后转为IA,再满365天后自动删除:
lifecycleConfig := &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String("my-bucket"),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.Rule{
{
ID: aws.String("transition-and-expire"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilterMemberPrefix{
Prefix: aws.String("logs/"),
},
Transitions: []types.Transition{
{
Days: aws.Int32(30),
StorageClass: types.StorageClassStandardIa,
},
},
Expiration: &types.Expiration{
Days: aws.Int32(395), // 30 + 365 = 395天总生命周期
},
},
},
},
}
逻辑分析:
Transitions.Days是相对对象创建时间的偏移量;Expiration.Days是同一基准的绝对过期点。SDK自动校验Expiration.Days ≥ Transitions.Days,否则返回InvalidArgument错误。
策略生效依赖项
- ✅ 桶版本控制可选(非必需)
- ✅ IAM权限需包含
s3:PutLifecycleConfiguration - ❌ 不支持对已存在对象立即触发Transition(仅新写入或更新后生效)
| 触发时机 | 是否实时生效 | 说明 |
|---|---|---|
| 新对象上传 | 是 | 创建时间即策略计时起点 |
| 已有对象覆盖写入 | 是 | 时间戳刷新,重新计时 |
| 手动修改策略 | 否 | 最多24小时内异步应用 |
4.3 归档完整性验证:ETag比对、Manifest清单生成与SHA256远程校验
核心验证三重保障
归档完整性需协同验证:对象存储层(ETag)、元数据层(Manifest)与内容层(SHA256)。
ETag比对(服务端校验)
# AWS S3 ETag 通常为MD5,但分段上传时为"md5-<part_count>"格式
aws s3api head-object --bucket my-bucket --key archive.tar.gz --query 'ETag' --output text
# 输出示例: "1a2b3c4d5e6f78901234567890abcdef"
逻辑分析:ETag是对象存储服务自动生成的摘要标识。单部件上传时等价于MD5;多部件上传则为各part MD5拼接后取MD5(非原始文件MD5),故不可直接用于端到端校验。
Manifest清单生成
| 文件路径 | 文件大小(字节) | 本地SHA256 |
|---|---|---|
| /data/logs/app1.log | 1048576 | a1b2…f0 |
| /data/logs/app2.log | 2097152 | c3d4…e8 |
远程SHA256校验流程
graph TD
A[下载归档文件] --> B[计算本地SHA256]
C[从OSS获取预存SHA256] --> D[比对一致性]
B --> D
自动化校验脚本片段
import hashlib
def calc_sha256(file_path):
h = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest() # 分块读取避免内存溢出
参数说明:
8192为I/O缓冲区大小,在吞吐与内存间取得平衡;iter(..., b"")确保流式处理大文件。
4.4 多租户隔离与权限最小化:IAM Role Assume与S3 Access Point Go集成
为实现严格多租户数据隔离,推荐组合使用 sts.AssumeRole 与 S3 Access Point ARN,避免共享主账户凭证。
权限最小化实践原则
- 每租户绑定唯一 Access Point(带
--public-access-block-configuration) - IAM 角色策略仅允许
s3:GetObject+ 显式Resource限定至对应 Access Point ARN - 假设角色时通过
PolicyArns动态附加租户专属权限边界
Go 客户端集成示例
cfg, _ := config.LoadDefaultConfig(context.TODO(),
config.WithCredentialsProvider(
credentials.NewAssumeRoleProvider(
sts.NewFromConfig(cfg),
"arn:aws:iam::123456789012:role/tenant-a-reader",
func(o *stscreds.AssumeRoleOptions) {
o.RoleSessionName = "tenant-a-session"
o.PolicyArns = []string{
"arn:aws:iam::123456789012:policy/tenant-a-ap-read",
}
},
),
),
)
此配置动态注入租户级访问策略,确保每次 AssumeRole 会话仅获得该租户 S3 Access Point 的只读权限。
PolicyArns替代内联策略,便于集中审计与轮换。
关键参数说明
| 参数 | 作用 |
|---|---|
RoleSessionName |
唯一会话标识,用于 CloudTrail 追踪租户行为 |
PolicyArns |
精确限定本次会话的最小权限集,覆盖角色原始策略 |
graph TD
A[租户A请求] --> B[AssumeRole with PolicyArns]
B --> C[S3 Access Point ARN]
C --> D[仅返回租户A前缀对象]
第五章:直播录像golang
在高并发直播平台中,实时录像功能是保障内容可追溯、合规审计与用户回看体验的核心能力。本章基于某千万级DAU教育直播平台的真实演进路径,详解如何使用 Go 语言构建稳定、低延迟、可伸缩的直播录像服务。
录像架构设计原则
系统采用“推流分离+异步转存”模式:OBS/移动端推流至 SRS(Simple Realtime Server)后,通过 HTTP 回调触发 Go 录像服务;服务不直接处理音视频流,而是监听 RTMP 流元数据事件,动态生成 FFmpeg 转码命令并交由工作池执行。所有任务均带 context.WithTimeout(30 * time.Second),避免僵尸进程堆积。
核心组件实现
Recorder 结构体封装流元信息与存储策略:
type Recorder struct {
StreamKey string
AppName string
SegmentDur time.Duration // 10s 分片
OutputPath string // /recordings/{app}/{stream}/2024/06/15/
Bucket *minio.Client
}
配合 sync.Pool 复用 *exec.Cmd 实例,单节点 QPS 提升 37%;FFmpeg 命令强制指定 -vsync 0 -copyts 保证时间戳连续性,规避 HLS 播放跳帧。
存储与分片策略
录像文件按时间维度分层存储,支持快速定位与 TTL 清理:
| 分片类型 | 文件名格式 | 保留周期 | 用途 |
|---|---|---|---|
| TS切片 | 20240615_142300_001.ts |
72h | 实时 HLS 回看 |
| MP4合集 | 20240615_142300-143300.mp4 |
90d | 合规存档与点播 |
| JSON元数据 | 20240615_142300.json |
90d | 包含分辨率、码率、关键帧位置 |
所有对象写入 MinIO 时启用 PutObjectOptions{ServerSideEncryption: true},符合等保三级加密要求。
异常熔断与重试机制
当 MinIO 写入失败或 FFmpeg 进程异常退出(exit code ≠ 0),服务自动触发三级降级:
- 切换至本地磁盘临时缓存(
/tmp/recorder_fallback/); - 启动后台 goroutine 每 30s 扫描并重传;
- 累计失败达 5 次,向 Prometheus 上报
recorder_failure_total{reason="storage_timeout"}并触发企业微信告警。
性能压测结果
在 8C16G 容器实例上,并发录制 120 路 720p@25fps 流:
- 平均启动延迟 ≤ 800ms(从回调到首个 TS 生成)
- CPU 使用率稳定在 62%±5%,无内存泄漏(pprof profile 验证 24h 内 heap 不增长)
- 单日处理录像时长超 210 万分钟,失败率 0.017%
日志追踪与调试支持
每路录像生成唯一 traceID,贯穿 FFmpeg 日志、MinIO 请求、HTTP 回调链路;集成 OpenTelemetry,支持 Jaeger 中按 stream_key 或 trace_id 全链路检索。
flowchart LR
A[RTMP 推流] --> B[SRS 触发 HTTP 回调]
B --> C[Go Recorder 创建任务]
C --> D{FFmpeg 进程启动}
D --> E[TS 切片写入 MinIO]
D --> F[JSON 元数据落库]
E --> G[HLS Playlist 动态更新]
F --> H[ES 存储供搜索]
所有录像文件在写入完成后立即触发 SHA256 校验并写入 Redis 缓存,供下游 DRM 加密服务调用。
