教师档案到搜索服务的同步
profile 服务持有教师的权威数据。talent 服务持有学校端查询的可检索索引。这是两个各自独立、各带自己存储的服务,所以档案的一次改动必须传播出去——当老师改了自己的标题、技能、或对谁可见时,这个改动得抵达搜索索引。本节讲的就是承载这次传播的那道接缝。
一个 port,两条路
应用层从不直接跟队列或 HTTP 客户端打交道。它只依赖一个 port——TalentSyncPort,整个接口面只有一个方法:sync(data)。这个 port 自己的注释把意图讲得很直白——具体实现可以是同步的(HTTP)也可以是异步的(队列)。所以领域层只说「这位老师的数据变了,把它送到搜索去」;至于走哪条路,是基础设施层的选择,可替换,且不必碰任何 use case。
它承载的载荷 TalentSyncData,是一份档案里与搜索相关的投影:身份信息(id、username、标题、简介、头像)、学校端用来筛选的各个 facet(技能、语言、城市/国家与期望地点、可入职状态、经验级别与年限、期望薪资区间、资质),以及——很关键的——可见性标志:一个 public/limited/hidden 级别,外加三个布尔值,分别表示该老师是否对学校、对雇主、对招聘方可见。可见性是随着同步一起走的,这样索引才能强制执行「谁被允许看见谁」。
队列这条路:跑在 Redis 上的 BullMQ
BullTalentSyncAdapter 是异步实现。它把消息发布到一个由 Redis 支撑、名为 talent-sync 的 BullMQ 队列,而它的任务选项才是有意思的地方——那是一份「持久性契约」:
- 重试 3 次,采用指数退避,起始延迟一秒,这样下游的一次瞬时故障能自行重试,而不是把这次更新丢掉。
- 完成即移除,健康的队列不会堆积已完成的任务。
- 失败保留——一个把重试次数耗尽的任务不会被丢弃。它留下来供检查,让真正失败的同步是可见的,而不是悄无声息地消失。
每个任务拿到一个形如 talent-<id>-<时间戳> 的 id,发布时也会记日志。队列另一端的消费者是 talent 服务;profile 服务的职责只是入队。
Redis 连接本身是一个惰性创建的单例客户端,由环境变量配置,带有自己一层有上限的重试:若干次尝试、延迟递增且封顶,之后就对这一次请求放弃。于是这里叠了两层重试——连接层重试传输,队列层重试这份工作。
HTTP 这条路:尽力而为,不阻塞
TalentServiceClient 是同步的另一种实现——一次直接的对外调用,把教师记录 POST 到搜索服务的 upsert 端点,以一次经过认证的服务间请求发出。它最具定性的特征是它在失败时的做法:不做任何会让调用方停下的事。 4xx/5xx 响应会记日志;抛出的网络错误会被捕获并记日志。两者都不会被重新抛出。代码里把原因写得清清楚楚——同步失败不应阻塞主操作。保存档案才是那件必须成功的事;把它送进搜索索引固然可取,但地位次之,允许它在日志里大声地失败、对用户则悄无声息。
底下的设计
这两条路表达的是同一个一致的立场:档案是真相之源,索引是派生视图,让二者保持同步这件事绝不能把档案扣为人质。 队列这条路,为那次「追上进度」买来了持久性和自动重试;HTTP 这条路买来了即时性,但会退化为尽力而为。因为两者都躲在同一个单方法 port 之后,profile 服务在接线时挑选自己的取舍,而领域层始终不知道当下跑的是哪一个。