智能客服系统实战:基于历史记录压缩的高效存储与检索方案
“客服历史记录又飙到 3 TB,老板只给 1 TB 预算,检索还要 200 ms 内返回?”——如果你也在智能客服团队踩过这个坑,下面的踩坑-填坑笔记或许能帮你把硬盘和头发都省下来。
1. 背景:对话历史的三座大山
存储膨胀
一条对话平均 1.2 KB,日活 100 w 次就是 1.2 GB/天,半年就 200 GB,再加上灰度备份,磁盘像吹气球。检索延迟
运营要“昨天谁投诉了退款关键词”,模糊搜索一跑 3 s,页面直接 504。并发冲突
高并发写场景下,InnoDB 页锁+长文本大字段,CPU 飙绿,MySQL 线程数飙红,客服后台一起崩。
2. 技术选型:压缩算法与索引的“相亲现场”
2.1 压缩算法 PK
| 指标 | Snappy | Zstandard (zstd) |
|---|---|---|
| 压缩率 | 2.1× | 3.3×(最大模式) |
| 压缩吞吐 | 380 MB/s | 200 MB/s |
| 解压吞吐 | 1.8 GB/s | 1.2 GB/s |
| 字典训练 | 不支持 | 支持(小数据神器) |
Facebook 在 Zstd 白皮书(https://facebook.github.io/zstd )里给出 3.3× 的文本压缩中位数,正好契合“客服对话”这种重复度极高的场景;字典训练还能把“您好,很高兴为您服务”这类高频句压到几十字节。Snappy 胜在极致速度,但省盘效果一般,最终我们选了 zstd,训练 100 w 条对话做 16 KB 字典,压缩率再提 8%。
2.2 索引结构:B+ 树 vs 倒排
倒排索引对全文关键词很香,可客服场景 80% 查询是“按会话 ID 拉取最近 50 条”,属于范围扫描;B+ 树顺序写+顺序读,磁盘预读友好,页分裂可控。再叠加内存映射(mmap),查询基本不落盘,延迟稳在 5 ms 内。
3. 核心实现:代码+图解
3.1 压缩存储层(Go,含 error wrap)
package history import ( "github.com/klauspost/compress/zstd" "os" ) type Compressor struct { enc *zstd.Encoder dec *zstd.Decoder } // NewCompressor 初始化带字典的编解码器 func NewCompressor(dict []byte) (*Compressor, error) { enc, err := zstd.NewWriter(nil, zstd.WithEncoderDict(dict)) if err != nil { return nil, fmt.Errorf("new encoder: %w", err) } dec, err := zstd.NewReader(nil, zstd.WithDecoderDicts(dict)) if err != nil { return nil, fmt.Errorf("new decoder: %w", err) } return &Compressor{enc: enc, dec: dec}, nil } // Compress 返回压缩后切片,失败直接抛上层处理 func (c *Compressor) Compress(src []byte) ([]byte, error) { return c.enc.EncodeAll(src, nil), nil } // Decompress 解压,带边界保护 func (c *Compressor) Decompress(src []byte) ([]byte, error) { return c.dec.DecodeAll(src, nil) }3.2 索引+存储合并(Python,带类型注解)
import mmap, zstandard as zstd, pathlib, struct from typing import List, Tuple class ZstdBTreeStore: """B+树节点 ID -> (offset, size) 映射,真实对话存在 zstd 压缩文件""" def __init__(self, index_path: pathlib.Path, data_path: pathlib.Path, dict_data: bytes): self.dict = zstd.ZstdCompressionDict(dict_data) self.cctx = zstd.ZstdCompressor(dict_data=self.dict) self.dctx = zstd.ZstdDecompressor(dict_data=self.dict) self.index = self._load_index(index_path) # 内存 B+ 树 self.fp = data_path.open("r+b") self.mmap = mmap.mmap(self.fp.fileno(), 0) def put(self, key: int, raw: bytes) -> None: comp = self.cctx.compress(raw) offset = self.mmap.size() size = len(comp) # 追加写 self.mmap.resize(offset + size) self.mmap[offset:offset+size] = comp # 更新 B+ 树 self.index[key] = (offset, size) def get(self, key: int) -> bytes: offset, size = self.index[key] comp = self.mmap[offset:offset+size] return self.dctx.decompress(comp)3.3 内存映射原理(ASCII 流程图)
用户空间 buffer +-----------------------------+ | 直接访问,缺页异常→内核页缓存 | +-----------------------------+ ▲ mmap ▏ 内核空间 ▏ +-----------------------------+ | 页缓存 PageCache | +-----------------------------+ ▏ ▏ DMA ▼ 磁盘文件 history.zst关键点:只读查询不走系统调用 read(),缺页异常后由内核异步回写,CPU 占用 < 5%。
4. 性能基准:压缩率 vs 延迟的“跷跷板”
测试机:16 vCPU / 32 GB / NVMe,1000 w 条对话,单条 1.2 KB。
- 纯原始:磁盘 11.2 GB,随机读 2.3 ms
- Snappy:4.8 GB,随机读 2.5 ms
- zstd L3:3.3 GB,随机读 2.7 ms
- zstd L9 + 字典:2.9 GB,随机读 2.9 ms
- zstd L12:2.8 GB,随机读 4.1 ms ← 收益拐点
结论:L9 + 字典是甜蜜点,存储降 70%,读延迟仍 < 3 ms;再高压缩率得不偿失。
5. 生产避坑指南
字典过热
现象:压缩率突然掉到 2.2×。
根因:业务上新“双 11 话术”导致词频漂移。
对策:每周抽样 50 w 条新对话,增量重训字典,双缓冲切换,灰度 10% 流量验证压缩率。mmap 内存泄漏
现象:RES 占用只增不降。
根因:Python 层调用resize()频繁,内核脏页累积。
对策:固定文件大小池,写满后 rotate;读侧 madvise(MADV_DONTNEED) 定期释放冷页。并发写冲突
现象:多实例同时 put,文件尾损坏。
根因:append 写无锁保护。
对策:把“写”拆成独立日志流(Kafka -> 单实例 consumer),读侧仍多实例 mmap,读写分离后 CPU 降 40%。
6. 小结 & 开放问题
把 zstd 字典压缩、B+ 树索引、内存映射三件事拼在一起,我们让 3 TB 对话历史瘦身到 900 GB,查询 P99 从 2.3 s 跌到 5 ms,MySQL 线程数从 2 k 降到 200。代码已开源在内部 GitLab,可直接镜像跑。
但当对话里开始夹杂语音转文字、图片 OCR、甚至用户上传的短视频,压缩字典和纯文本 B+ 树都显得力不从心。多媒体字段要不要走对象存储?能否把向量检索融合进来?——如果你也踩过“多媒体+压缩”的坑,或者有更巧妙的扩展思路,欢迎留言一起拆坑。