一个生词服务,让我重新想清楚了"什么服务能对外"
最近在给 vox 加生词提取功能。一开始的想法很简单:给它一段文字,它返回一个生词列表,每个词带上考试等级和中文释义。写着写着我冒出一个念头——这东西不应该只给 vox 自己用,它应该是一个通用能力,谁都能调。
然后问题就来了。如果生词服务可以直接对外,那转写为什么是内部的?文件服务和任务服务为什么只走内部的 gRPC?它们之间到底差在哪?
这个问题我想了一阵,发现是我把两件不相干的事搅在了一起。
我混淆了两个维度
“这个服务是 gRPC 还是 HTTP"和"这个服务在内部还是对外”,是两个独立的问题。
我的文件服务(02)和任务服务(03)恰好既是 gRPC、又在内部,于是我下意识觉得"内部服务 = gRPC"。其实它们用 gRPC 和它们必须在内部,是两个完全不同的原因。
文件服务为什么必须在内部:它相信调用方
之前做文件所有权检查时,我给下载请求加了一个 user_id 字段。文件服务拿到这个 user_id,就去判断"这个文件是不是他的"。
但文件服务从来不验证这个 user_id 是真是假。真正验证身份的是网关(01):它解析 JWT,确认你是谁,再把 user_id 填进去转给文件服务。
浏览器 ──带 JWT──→ 01(确认你是张三)──user_id=张三──→ 02(相信 01)
如果文件服务直接暴露在公网上,任何人发一个 user_id=张三,就能下载张三的文件。任务服务更直接——它更新任务状态的接口根本没有认证,谁都能把一个任务标记成"已完成"。
它们的安全,完全建立在"只有网关能调我"这个前提上。所以它们必须在内部,这和用不用 gRPC 没有关系。
转写 worker 为什么是"内部":它们根本没有门
转码和转写这两个 worker 不监听任何端口,它们是主动去 RabbitMQ 队列里取任务的。外面没法调它们——这不是我选择了"内部",是它们压根没有接口可以暴露。
而且想清楚之后我才意识到,转写能力其实早就对外了。POST /api/v1/tasks 就是 vox 的转写接口,我写的 Mac 客户端 VoxBoard 就是一个外部调用者。只不过它必须经过网关,因为转写:
- 需要认证:谁提交的、文件归谁
- 很贵:whisper 转一段音频要占几十秒 GPU 或 CPU,不能让人随便刷
- 是异步的:提交、排队、轮询结果
生词服务到底哪里不一样
把几个服务放在一起比,差别就很清楚了:
| 文件 / 任务服务 | 转写 | 生词服务 | |
|---|---|---|---|
| 有用户数据吗 | 有 | 间接有 | 没有 |
| 相信调用方的身份吗 | 相信,所以不能外露 | — | 不关心谁在调 |
| 贵吗 | 不贵 | 很贵 | 很便宜 |
| 同样输入永远同样输出吗 | 不是 | 基本是 | 是 |
生词服务是一个纯函数:给它文字,还你列表。没有状态,没有秘密,不认识任何用户。这种东西暴露出去,不会泄露任何东西。
所以决定一个服务能不能直接对外的,不是它快不快,而是三件事:有没有状态、相不相信调用方、贵不贵。
协议按调用方选,不按"统一"选
想清楚能不能对外之后,协议就好选了。看谁会来调它:
- 外部开发者:HTTP 一行 curl 就能试,gRPC 得先拿到
.proto、装代码生成工具 - 浏览器:
fetch直接用,gRPC 浏览器原生调不了 - VoxBoard(Swift):
URLSession直接用,gRPC 要引入一整套库 - vox 自己的 Go 服务:都行
四类调用方里三类用 gRPC 都很别扭,而 gRPC 的长处——方法多、要严格契约、有流式、对性能极度敏感——生词服务一样都不占。所以选 HTTP。
有人会说这和项目里其他服务不统一。但我的网关本来就是对外 HTTP、对内 gRPC 了。服务之间方法多、只被自己人调用,用 gRPC;面对五花八门的调用方,用 HTTP。不统一恰恰是对的。
同步接口还是队列
还有一个收获。转写、转码走队列,是因为它们要跑几秒到几分钟。生词提取本质是查表:切词、还原词形、查等级,一段十分钟的文稿一千多个词,毫秒级就算完。这种计算没必要排队,直接一问一答就行。
慢的、重的活走队列;快的、确定的计算做成同步接口。
这个判断还顺手帮我省掉一大块工作:既然它毫秒级返回,就不需要在处理管线里提前算好存起来,用户打开页面时现场调一次就行。原本计划为它改的编排逻辑,直接不用做了。
认证放在哪
那生词服务要不要加 API 认证?
我的结论是:认证是入口的属性,不是服务本身的属性。本地开发不用;vox 内部调用也不用,因为网关已经验证过用户了。真正对外开放的那天需要加,但理由和文件服务完全不同——不是防泄露,它本来就没东西可漏,而是防滥用:有人拿超长文本循环刷我的 CPU。所以要的是 API key、输入长度上限和限流,而且放在入口那一层,服务本身继续保持干净。
顺便放弃了一个缓存
设计时我还想用 Redis 做缓存加速。后来算了一下,放错地方了。
词表已经编进进程内存里,查一次是纳秒级;放进 Redis,每次反而要多一次网络往返。整段结果缓存也省不下什么,因为算一次本来就只要毫秒级。
缓存应该放在"算得慢"或者"算得贵"的东西前面。在 vox 里,真正符合的是 AI 摘要:同一段文稿再调一次大模型,既要等十几秒,又要花 API 的钱。Redis 留给它。
最后
一个很小的生词功能,逼着我把整个系统的每个服务重新审视了一遍。回头看,每个服务放在哪、用什么协议,背后都有一条具体的理由,而不是"微服务就该这样"。能把这些理由讲清楚,比服务拆了多少个重要得多。