一个生词服务,让我重新想清楚了"什么服务能对外"

最近在给 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 留给它。

最后

一个很小的生词功能,逼着我把整个系统的每个服务重新审视了一遍。回头看,每个服务放在哪、用什么协议,背后都有一条具体的理由,而不是"微服务就该这样"。能把这些理由讲清楚,比服务拆了多少个重要得多。