做音乐类项目躲不开一个很具体的场景:你在搜索框里敲下歌名,然后开着三个浏览器标签页,分别去不同的地方再搜一遍,再把结果手动拼到一起。这件事我忍了很久,最后索性把“搜索”这一层单独抽出来,做成一个音乐聚合搜索平台——把多个来源的检索能力收敛到一个统一接口后面,前端只面对一个搜索框、一套数据结构、一种分页协议。它解决的问题很朴素:来源分散、元数据不一致、同名不同版本混在一起、找一个冷门曲目要来回切换五六个地方。适合谁来参考?自己搭私人曲库的音乐收藏者、做播客或视频需要找配乐的内容创作者、需要整理历史歌单的普通用户,以及想练手“多源数据聚合”这类架构的后端同学。下面按我实际落地的顺序讲,从需求拆解到架构、到代码、到踩过的坑,能直接抄的部分我都写清楚。
1. 先把“聚合”这件事想明白:需求、边界与方案选型
1.1 用户要的从来不是“全网”,而是“一次命中”
很多人做这类项目第一反应是“我要覆盖尽可能多的源”,这个方向一开始就会把项目带偏。真实使用场景里,用户输入的往往只是一个模糊的歌名,或者一句歌词碎片,他要的不是“看到 200 条结果”,而是“第一屏就有我要的那条,并且能确认它就是我想要的那个版本”。我观察过自己半年内的搜索记录,大概分布是这样:完整歌名占四成,歌名加歌手占三成,只有歌手的占一成半,剩下是歌词片段或者记错的歌名。这意味着聚合层的核心能力不是“连得多”,而是归一化和排序——把不同来源对同一首歌的不同写法,收敛成一条干净的记录。
把需求再往下拆一层,其实是四件事叠在一起:统一入口(一个搜索框)、统一结构(不管来源是什么,返回的字段一致)、来源可追溯(每条结果都要能说清它从哪来,方便二次确认)、结果可折叠(同一首歌的多个来源合并展示,而不是铺满屏幕)。这四条定下来,后面所有的技术选型都是围绕它们服务的。反过来,如果你想的是“我要把音频都抓下来存本地”,那这就是另一个项目了,架构、合规、存储成本完全是两套东西,别混在一起做。
1.2 三条技术路线的取舍:实时联邦、本地索引、冷热混合
聚合搜索的实现路径基本就三条,我最初踩的坑是想一步到位做本地全量索引,结果光同步任务就写了一周,还没等索引建完就放弃了。后来改成混合方案,两周跑通。
| 路线 | 实现方式 | 优势 | 明显短板 | 适合场景 |
|---|---|---|---|---|
| 实时联邦查询 | 每次搜索并发请求所有来源,现场合并 | 实现最快,数据最新,没有存储成本 | 受制于各来源响应速度和频率限制,长尾词延迟高 | 来源少于 5 个、日搜索量低于几千 |
| 本地全量索引 | 定时同步元数据入库,搜索走本地 | 响应极快,可离线,支持复杂检索 | 同步任务复杂,存储成本高,冷门内容覆盖差 | 曲库相对固定、需要高级筛选 |
| 冷热混合 | 热词走本地索引,长尾走实时联邦 | 兼顾速度与覆盖,成本可控 | 架构复杂度上升,需要缓存失效策略 | 有一定搜索量、来源较多 |
我最后选的是冷热混合,理由很实际:搜索请求是典型的长尾分布,前 2000 个热词大概能覆盖六成以上的查询量。把这批热词的结果缓存下来,命中率就足够撑起体验;剩下的长尾走实时联邦,慢一点但用户能接受。这里有个经验值——热词表不要按搜索次数取,要按“搜索次数 × 结果为空率”取,因为高频但结果丰富的词缓存价值高,高频但经常搜不到的词缓存了也没意义。
1.3 合规边界要在写第一行代码之前划清
这点必须放前面说,因为它直接决定架构。音乐聚合搜索里,“聚合”的合法边界是索引与跳转,不是内容分发。我给自己定的规则是三条:只接入公开可访问的元数据接口和开放授权曲库;只保留标题、艺人、专辑、时长、封面地址这类索引信息,不缓存音频本体;所有来源都做频率控制,单来源 QPS 上限设在对方公开文档建议值的一半以内,宁可慢也不要把人家接口打挂。
另外一类来源是自建库——自己的 NAS、本地音乐文件夹、买过的数字专辑。这类反而是聚合平台里价值最高的部分,因为它是完全可控的。我现在的做法是把自建库当作一个普通适配器接进去,和外部来源走同一套接口,搜索时一起返回,只是排序上给自建库一个固定加权,让“我确定有的”永远排在前面。这个设计很省心,用户搜一首歌,先看到自己库里已收藏的那条,直接就能用,不会再跑去外部来源点一遍。
还有一点容易被忽略:结果的二次分发。如果你的平台把外部来源的播放地址直接透传给前端,那就越界了。我的处理是外部来源只返回“详情页跳转地址”,自建来源才返回可播放地址,前端根据source_type字段决定按钮是“播放”还是“查看来源”。这个字段看着不起眼,但它是整个项目能不能长期跑下去的关键。
2. 架构拆解:把一个“搜索框”拆成五层
2.1 分层结构与一次请求的完整链路
整个服务我拆成五层,从上到下是接入层、编排层、适配层、归一化层、缓存层。这么拆不是为了好看,是为了让“新增一个来源”这件事的改动范围收敛到一个文件里。
一次搜索请求走完的链路是这样的:接入层收到GET /api/search?q=...&page=1,做参数校验和鉴权;编排层先查缓存,未命中则把查询分发给所有启用的适配器,用并发任务同时发起,每个适配器有独立的超时预算;适配层各自调用对应来源的接口,把原始响应转成中间结构;归一化层做字段清洗、版本标签抽取、去重折叠、打分排序;最后编排层把结果写回缓存并返回。整条链路里,编排层是唯一知道“有哪些来源”的地方,适配层之间互相不可见,这样任何一个来源挂掉或者改接口,都不会影响其他来源。
这里有个设计细节值得说:超时预算要分层给,不能统一给。我一开始给所有适配器统一 2 秒超时,结果一个慢来源拖垮了整个请求。后来改成总预算 1.2 秒,快的来源 400ms 超时,慢的来源 900ms,编排层用“等到齐或等到总预算耗尽”的策略,先到的先返回。这个改动让 p95 延迟从 1.9 秒降到 780ms,体感差别非常大。
2.2 统一数据模型:先定 schema 再写业务代码
我在这个项目上最大的一个教训是:先写了三个适配器,然后才发现字段对不上,回头重构了两天。正确顺序是先定死 schema,再写适配器。
| 字段 | 类型 | 是否必填 | 说明与踩坑点 |
|---|---|---|---|
id | string | 是 | 平台内部 ID,由source + source_id哈希生成 |
source | string | 是 | 来源标识,用于追溯和分组展示 |
source_id | string | 是 | 来源侧原始 ID,去重的第一道依据 |
title | string | 是 | 主标题,已剥离版本标记 |
title_raw | string | 否 | 原始标题,排查问题时必看 |
artists | string[] | 是 | 数组而非字符串,合唱曲目必须能拆分 |
album | string | 否 | 专辑名,可为空 |
duration_ms | int | 是 | 用毫秒整数,不要用03:45字符串 |
version_tags | string[] | 否 | live、remix、cover、instrumental 等 |
release_year | int | 否 | 年份,用于区分同名不同年代作品 |
cover_url | string | 否 | 封面地址,前端需做加载失败兜底 |
source_type | string | 是 | external或local,决定前端按钮形态 |
raw | object | 否 | 原始响应片段,仅调试期保留 |
duration_ms用整数是必须的,字符串格式在去重和排序时全靠正则拆,慢且容易错。artists用数组是因为我遇到过一首歌的艺人字段被塞进“某某 / 某某 / 编曲:某某”这种内容,不拆开就没法做艺人维度的匹配。title_raw这个字段看着冗余,但它救过我很多次——归一化规则太激进把有效信息洗掉了,靠原始标题才能定位是哪条规则出了问题。
2.3 适配器插件机制:新增一个来源只改一个文件
适配器的约定我压在三个方法上:search(keyword, limit)返回原始列表,normalize(item)把单条转成统一结构,health()返回可用状态。注册用装饰器,框架启动时自动扫描。
# adapters/registry.py _ADAPTERS = {} def register(name, source_type="external", weight=1.0): def wrapper(cls): _ADAPTERS[name] = { "cls": cls, "source_type": source_type, "weight": weight, } return cls return wrapper def all_adapters(): return _ADAPTERS新增一个来源就变成了写一个类、加一个装饰器,不用动编排层的任何代码。这里加weight字段是为了后面打分排序用,自建库给 1.0,外部来源给 0.85 到 0.95 不等,具体值按实际命中质量调,这个后面排序那节细说。
注意:适配器内部不要做任何跨来源的逻辑,比如去重、排序、合并,这些统统丢给归一化层。我早期图省事在适配器里做了一次本地去重,后来加缓存层时发现两边规则不一致,排查了很久。职责单一这一条,在这种多源项目里比在单体项目里重要得多。
3. 核心细节:元数据归一化与去重才是真功夫
3.1 脏数据的四种典型形态
接入第一个外部来源的时候我以为洗数据是个小事,接完三个之后我发现,聚合搜索项目 70% 的工作量在归一化。脏数据基本集中在四类:
第一类是标题污染。很多来源的标题会带(Live)、【高音质】、[官方版]、- Single、末尾一串推广文案。这些不剥离,去重会全军覆没。第二类是艺人字段混装,把合唱、feat.、编曲、制作人全塞进一个字符串,用/、&、、、feat.各种分隔符混着来。第三类是字符问题,繁体简体混用、全角半角混用、日文假名和罗马音并存、俄语西里尔字母和拉丁字母长得像。第四类是时长缺失或不准,有的来源干脆不返回时长,有的返回值跟实际差了十几秒。
这四类问题里,杀伤力最大的是第一类和第四类,因为它们直接决定了去重能不能成。第二类和第三类影响的是排序质量,属于“体验层面”的问题,可以后置处理。
3.2 归一化流水线:五步走,每步可回滚
我的归一化流水线固定五步,顺序不能换:
import re import unicodedata VERSION_PATTERN = re.compile( r"[\(\(\[\【]\s*(live|remix|cover|instrumental|acoustic|demo|edit|version)[^\)\)\]\】]*[\)\)\]\】]", re.IGNORECASE, ) def normalize_text(s: str) -> str: if not s: return "" # 1. Unicode 规范化,统一全角半角、兼容字符 s = unicodedata.normalize("NFKC", s) # 2. 去除首尾空白与不可见字符 s = re.sub(r"[\u200b-\u200f\ufeff]", "", s).strip() # 3. 统一分隔符 s = s.replace("—", "-").replace("–", "-") # 4. 压缩连续空白 s = re.sub(r"\s+", " ", s) return s.lower() def split_title(raw_title: str): t = normalize_text(raw_title) tags = [m.group(1).lower() for m in VERSION_PATTERN.finditer(t)] clean = VERSION_PATTERN.sub("", t).strip(" -_") return clean, tags def split_artists(raw_artist: str): if not raw_artist: return [] parts = re.split(r"\s*(?:/|&|、|,|;|feat\.?|ft\.?|with)\s*", raw_artist, flags=re.I) return [p.strip() for p in parts if p.strip()]第一步 Unicode 规范化(NFKC)能解决掉大部分全角半角和兼容字符问题,这一步不做,后面所有字符串比较都是错的。第二步清不可见字符,零宽空格这种东西肉眼看不见,但会让两个“看起来一样”的字符串比较失败,我被它坑过一次,排查了两小时。第三步统一破折号,因为不同来源用的横线字符不一样。第四步压空白、第五步转小写,都是为了让后续的精确匹配能命中。
版本标签抽取单独拎出来做成函数,是因为它同时服务两个目的:一是把(Live)从标题里剥掉让主标题能对齐,二是把live存进version_tags供前端展示和排序。这里有个坑——不要把所有括号内容都当垃圾扔掉。(Live)、(Remix)是版本标记该剥离,但(最终幻想)这种是作品名的一部分,剥了就错了。我的做法是维护一个版本关键词白名单,只剥离白名单里的,白名单之外的括号内容保留在标题里。
3.3 去重三级策略:从精确到模糊,逐级兜底
去重是这个项目技术含量最高的部分,我用了三级策略,逐级放宽:
| 级别 | 判断依据 | 阈值 | 覆盖场景 | 误合并风险 |
|---|---|---|---|---|
| L1 精确 | source + source_id完全相同 | 完全相等 | 同一来源重复返回 | 极低 |
| L2 规则 | 归一化title + 主艺人 + 时长桶 | 时长差 ≤ 2s | 跨来源同一录音 | 低 |
| L3 模糊 | 标题 token 集合相似度 + 艺人相似 + 时长容差 | 相似度 ≥ 0.86 且时长差 ≤ 5s | 标题有细微差异 | 中,需人工抽检 |
L2 的“时长桶”是个小技巧:把duration_ms除以 2000 取整,得到 2 秒粒度的桶号,两个结果桶号相同就认为时长接近。这样比直接算差值快,也避免了边界抖动。L3 的相似度我用的是 token 集合的 Jaccard 系数配合编辑距离加权,标题先去停用词、去标点、切成 token。
def jaccard(a: str, b: str) -> float: sa, sb = set(a.split()), set(b.split()) if not sa or not sb: return 0.0 return len(sa & sb) / len(sa | sb) def similarity(t1, a1, d1, t2, a2, d2) -> float: title_sim = jaccard(t1, t2) artist_sim = 1.0 if set(a1) & set(a2) else 0.0 duration_ok = 1.0 if abs(d1 - d2) <= 5000 else 0.0 return 0.6 * title_sim + 0.25 * artist_sim + 0.15 * duration_ok0.86 这个阈值是拿 500 组样本调出来的。低于 0.82 会误合并(把翻唱和原唱合到一起),高于 0.90 会漏合并(同一首歌因为标题里多了个“官方版”就没合上)。这个值一定要用你自己的数据调,不同来源的标题风格差异很大,别人的阈值搬过来大概率不准。
提示:L3 模糊去重的结果建议只做“折叠展示”,不要做“物理删除”。前端把合并后的多条来源展开成一个可点开的列表,用户可以自己判断哪个是他要的。物理删除一旦合错了,用户根本没法纠正,体验反而更差。
如果曲库规模上去了,L3 还可以再加一层音频指纹做最终确认,但这属于锦上添花——元数据层面的三级去重已经能覆盖九成以上的场景,指纹那一层的投入产出比不高,我放到扩展方向里了。
4. 实操过程:从零跑通一个最小可用版本
4.1 环境与目录结构
整个服务我用 Python 写的,选它没别的原因——写异步并发和字符串处理都比较顺手,而且部署简单。目录结构是这样的:
music-aggregator/ ├── app/ │ ├── main.py # 入口,路由注册 │ ├── config.py # 来源配置、超时预算、权重 │ ├── orchestrator.py # 编排层:并发、超时、合并、排序 │ ├── adapters/ │ │ ├── registry.py │ │ ├── local_library.py # 自建库适配器 │ │ └── source_a.py # 外部来源适配器 │ ├── normalize/ │ │ ├── text.py # 文本归一化 │ │ └── dedupe.py # 去重与折叠 │ ├── cache/ │ │ └── layered.py # 两级缓存 │ └── models.py # 统一数据模型 ├── tests/ └── deploy/依赖很少,一个轻量 HTTP 框架、一个异步 HTTP 客户端、一个键值缓存客户端,就这些。不要一开始就上消息队列、搜索引擎、图数据库,这些东西在你日搜索量到十万级之前都是负担。我见过太多同类项目死在“先把基础设施搭豪华”上。
4.2 并发编排与超时预算的计算过程
编排层是整个服务的心脏,我的实现逻辑是:给每个适配器分配独立超时,总预算兜底,先到先得。
import asyncio async def fan_out(keyword: str, limit: int, total_budget=1.2): tasks = {} for name, meta in all_adapters().items(): adapter = meta["cls"]() if not await adapter.health(): continue timeout = meta.get("timeout", 0.8) tasks[name] = asyncio.create_task( asyncio.wait_for(adapter.search(keyword, limit), timeout=timeout) ) done, pending = await asyncio.wait( tasks.values(), timeout=total_budget, return_when=asyncio.ALL_COMPLETED ) for t in pending: t.cancel() results, failed = [], [] for name, task in tasks.items(): if task in done and not task.cancelled() and task.exception() is None: results.extend(task.result()) else: failed.append(name) return results, failed超时预算的计算我是这么推的:先跑一轮压测拿到每个来源的 p95 响应时间,假设快的来源 280ms、中等 520ms、慢的 1100ms。总预算设成 1.2 秒是因为用户对搜索的耐心阈值大概在 1 到 1.5 秒之间,超过 1.5 秒体感就明显卡了。单个适配器的超时设成min(来源 p95 × 1.8, 总预算 × 0.75),乘 1.8 是为了留出网络抖动空间,乘 0.75 是保证单个慢来源不会吃光整个预算。按这个公式,p95 是 520ms 的来源超时就是 936ms,p95 是 1100ms 的来源超时就被压到 900ms——它大概率超时,但这是刻意的,宁可少一个来源的结果,也不要让用户等。
失败来源要记录但不能阻塞返回。我的做法是把failed列表一起返回给前端,前端在结果区顶部显示一行“当前有 1 个来源响应超时”,让用户知道结果可能不全。这个小细节能显著降低“怎么搜不到”的投诉,因为用户知道了原因是暂时的。
4.3 两级缓存与限流的参数设定
缓存分两级:进程内 LRU 存热词,TTL 5 分钟,容量 2000 条;分布式缓存存完整结果,TTL 30 分钟。为什么两级?进程内那层是为了挡住突发的重复查询——热搜榜上一首歌火了,短时间内会有大量相同关键词,进程内缓存能把这类请求直接吃掉,几乎零延迟。分布式那层是为了多实例共享,避免每个实例都去请求外部来源。
TTL 的设定有个权衡:太短缓存没意义,太长结果会过期(外部来源下架了你还返回)。30 分钟是我实测下来比较平衡的值,配合一个手动刷新入口,用户看到过期结果可以自己点一下重查。
限流用的是令牌桶,单来源配额按对方公开建议值的 50% 设,桶容量等于每秒速率的 3 倍,允许短时突发。假设某个来源建议每秒 10 次,我就设速率 5、容量 15。突发容量的作用是应对用户连续输入——用户打字时前端会发多次请求(虽然有防抖),留点余量体验会好很多。
4.4 前端搜索体验的三个细节
后端做得再好,前端三个细节没做好,体感照样差。
第一个是防抖。输入框防抖设 300ms,这个值是反复试出来的:低于 200ms 用户还没打完字就发请求,浪费配额;高于 500ms 用户会觉得“我打完字了怎么没反应”。
第二个是分段渲染。不要等所有来源都回来再渲染,哪个来源先回来就先渲染哪一组。我在结果区按来源分组,先回来的组先出,后回来的组用占位骨架补上。用户看到东西在动,等待感会弱很多,实测同样的 780ms 延迟,分段渲染的体感比一次性渲染快将近一半。
第三个是空结果兜底。搜索无结果时,不要只显示“未找到”,要给出三个动作:一是提示“试试去掉括号里的版本词”,二是展示该关键词在热词表里的相近词,三是给一个“只搜我的本地库”的快捷入口。这三条能救回相当一部分原本会流失的查询。
4.5 部署与可观测性
部署用容器化,单实例起步,配置全部走环境变量,方便横向扩容。健康检查分两级:一个/healthz探活,一个/readyz检查各适配器的可用状态。后者很关键,某个来源挂了的时候,/readyz会返回降级状态而不是直接失败,负载均衡器会继续把流量导进来,只是那个来源的结果缺失。
指标我只埋了四个,但都是必需的:search_qps、adapter_success_rate(分来源)、search_p95_latency、cache_hit_rate。adapter_success_rate分来源打标签这点特别重要,某个来源的成功率从 99% 掉到 70%,一定是它改接口了或者开始限流了,早发现早处理。cache_hit_rate用来验证热词表选得对不对,低于 35% 就说明热词表该重新生成了。
5. 排序与结果呈现:让“最想要的那条”排第一
5.1 打分模型:五个因子加权,权重靠数据调
去重之后的结果要排序,我用的线性加权模型:
score = 0.45 × 标题相似度 + 0.25 × 艺人匹配度 + 0.15 × 时长可信度 + 0.10 × 来源权重 + 0.05 × 元数据完整度标题相似度用归一化后的编辑距离算,艺人匹配度按“主艺人完全命中 1.0、部分命中 0.6、完全不命中 0”给分,时长可信度是“有时长且非零 1.0,缺失 0.3”,来源权重就是适配器注册时给的那个值,元数据完整度按专辑、年份、封面三个字段的填充率算。
举个例子:搜索“某首歌”,A 来源返回标题完全一致、艺人完全命中、有时长、来源权重 0.9、三个元数据字段都全;B 来源标题多了个“(Live)”、艺人命中、有时长、来源权重 0.9、缺年份。归一化后 A 的标题相似度 1.0,B 因为版本标签被剥离了标题也接近 1.0,但由于version_tags非空,我会在最终分数上乘一个 0.92 的版本惩罚系数,让原始版本排在 Live 版前面。这个惩罚系数是必要的,用户搜歌名默认想找的是录音室版,不是现场版。
权重这五个数字不是拍脑袋定的,是我拿 300 组标注数据做网格搜索调出来的。如果你要复用,建议先用我这组当起点,然后拿你自己的点击日志做一轮微调,因为不同来源的质量差异很大,权重的最优值一定不一样。
5.2 同曲多版本折叠:折叠策略比排序更影响观感
折叠策略我改过三次。第一版是把去重后的所有来源合成一条,展示时只显示“3 个来源有这首歌”,点开才展开。问题是一旦合错,用户完全没法发现。第二版是全部平铺,一条来源一条结果,结果是同一个歌名在屏幕上出现七八次,观感极差。
最后的方案是两段式:主结果区只显示每个折叠组的最佳一条,组内其他来源在右侧显示为小徽标(来源名首字母 + 数字),鼠标悬停或点击展开一个抽屉,里面列出所有来源、各自的时长和元数据、以及跳转入口。这样既保证了第一屏干净,又保留了完整可追溯性。
折叠组的命名用“组内元数据最完整的那个来源的标题”作为展示标题,而不是自己拼。因为自己拼的标题往往和任何一个来源都不一样,用户看着会陌生。这点小事,但很影响信任感。
5.3 结果的可追溯性:每条都能说清从哪来
这是我在 1.3 节强调过的东西在 UI 层的落地。每一条结果,不管是折叠后的主条目还是抽屉里的子条目,都必须带来源标识,并且外部来源只给“查看来源”按钮,自建库才给“播放”按钮。这个设计一开始被我自己嫌弃过,觉得点两下麻烦,但用久了发现它反而更好——因为用户对“这个结果可不可信”是有判断需求的,标了来源他才能判断。
另外我在抽屉里加了一行小字,显示该结果的元数据完整度和最后更新时间。元数据完整度低的条目会显示一个灰色的小提示“信息可能不全”。这个提示看着不起眼,但它把“为什么这条信息这么少”的解释前置了,省掉了大量的困惑。
6. 常见问题与排查实录
6.1 问题速查表
| 现象 | 最可能的原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 某个来源结果一直为空 | 接口改了字段名,解析报错被吞 | 看该来源的adapter_success_rate和错误日志 | 加字段兼容层,解析失败打 warn 不抛异常 |
| 同一首歌出现两次没折叠 | 标题版本词不在白名单里 | 取两条结果的title_raw对比 | 补白名单,重跑历史数据的归一化 |
| 搜索结果延迟突然涨到 3 秒 | 某个来源 p95 恶化,拖到总预算上限 | 看分来源耗时直方图 | 临时把该来源超时压到 400ms 或直接降级 |
| 缓存命中率持续低于 20% | 热词表过期或选取规则不对 | 导出近 7 天查询日志看分布 | 按“次数 × 结果非空率”重新生成热词表 |
| 繁体关键词搜不到简体内容 | 归一化没做繁简转换 | 手动输入繁简体各测一次 | 在归一化第二步加繁简映射表 |
| 前端偶发显示重复结果 | 分段渲染没做 key 去重 | 看前端控制台的重复 key 警告 | 渲染前按id做一次 Map 去重 |
| 时长全是 0 | 来源返回秒为单位,代码按毫秒解析 | 抽查一条原始响应 | 加单位探测:值小于 3600 按分钟处理 |
6.2 三个我印象最深的坑
第一个坑是“异常被静默吞掉”。早期我在适配器里写try/except直接pass,结果某个来源改了字段名,整整三天没人发现,因为接口一直返回 200,只是那个来源的结果一直是空的。后来改成解析异常必须打日志、必须计入adapter_success_rate,任何一个来源成功率低于 90% 就触发告警。这个改动之后,同类问题基本能在半小时内发现。
第二个坑是“防抖和并发叠在一起导致请求风暴”。前端做了 300ms 防抖,后端做了并发扇出,看起来没问题。但测试时发现了这么个情况:用户快速输入又快速删除,前端取消了请求但后端还在跑,取消的请求并不会终止后端已经发起的扇出。高峰期这个叠加效应让外部来源的调用量翻了三倍。解决方式是给每个请求带一个request_id,编排层在扇出前检查该request_id是否已被标记取消,已取消就直接返回空,不发请求。这个 trick 很土但很有效。
第三个坑是“自建库的文件名太乱”。自建库适配器我一开始是直接扫文件名解析的,结果发现文件名格式千奇百怪:有的用艺人 - 歌名,有的用歌名 - 艺人,有的带序号前缀,有的干脆就是track01。后来改成优先读音频文件的元数据标签,标签缺失才回落文件名解析,并且把解析失败的文件单独收进一个待整理清单。这个清单反而成了我整理曲库的工具,算是意外收获。
注意:排查这类多源问题时,一定要保留
raw原始响应。我现在遇到任何“数据不对”的问题,第一反应都是把原始响应打出来看,九成的问题在原始数据里就能看出来,比猜测快得多。调试期保留raw字段的存储成本几乎为零,收益极高。
7. 这个项目后续还能怎么长
跑通之后我陆续加了几样东西,都是低成本高收益的。一是搜索历史与收藏夹,用户搜过的词本地存着,下次打开直接点,这个功能实现成本很低但使用频率极高。二是批量整理模式,把整个收藏列表贴进去,平台逐条搜索并返回匹配结果和来源,用来做歌单迁移特别方便——这也是我自己用得最多的功能。三是结果导出,把搜索结果导出成结构化文件,方便导入到其他工具里。
真正想深挖的方向有两个。一个是音频指纹去重,前面提到的 L3 模糊匹配在标题风格差异极大的场景下还是会有误判,最终解法一定是拿音频本身比对,取前 10 秒做特征提取,存一份指纹库,跨来源比对。这个方案准确率高很多,代价是建库阶段要跑一遍全量音频,成本不小,适合曲库已经稳定的场景。另一个是个性化排序,把用户的点击行为记录下来,按“他常点哪个来源”调整来源权重,这个改动很小,但对体感的提升比调参数明显得多。
我在这个项目上折腾了小半年,最大的体会只有一句:聚合类项目的核心竞争力从来不是“接了多少个源”,而是“接进来的东西有多干净”。前两个月我一直在加来源,结果越加越乱;后两个月我一行新来源都没加,全部时间花在归一化和去重上,体验反而提升了好几倍。如果你也要做类似的东西,我建议你先只接两个来源,把归一化和去重打磨到能用的程度,再考虑第三个——顺序反过来,返工量会让你怀疑人生。