ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从单体到独立引擎:HagiCode Soul平台架构演进之路

从单体到独立引擎:HagiCode Soul平台架构演进之路 写这篇文章是因为上周整理HagiCode Soul的架构文档时翻到了两年前第一次画下的系统草图——一张画在A4纸背面的模块图那时候它还叫“内网代码检索插件”连正式名字都没有。现在回头看HagiCode Soul能从一个小工具长成独立平台靠的不是某一项炫技的技术而是一连串被真实使用场景逼出来的决策。这篇文章我就按时间线把这套演进过程拆开讲一遍包括需求是怎么来的、架构为什么这么切、Soul引擎在里面到底扮演什么角色以及我们踩过的那些坑。如果你正在做内容平台、知识管理工具或者推荐系统的技术选型这里面的思路应该能直接拿来对照。1. 需求萌发被真实痛点逼出来的第一个版本1.1 内部知识库的“三不知”困境没人知道有什么、在哪、怎么用2019年那会儿团队内部的代码仓库和文档分散在各个地方有的沉淀在Wiki里有的躺在Gitea的README里还有相当一部分只存在于老员工的聊天记录里。我们当时最头疼的问题不是“没有内容”而是“没人知道有什么、在哪、怎么用”。新人入职第一周大概率会把前辈踩过的坑重新踩一遍因为那些经验根本搜不到。我尝试过优化Wiki的分类目录结果目录越建越深到后面连维护的人都找不到入口了。也试过靠“问人”来解决问题但老员工一天被拉进三个群问同样的问题很快就烦了。真正的转折点是一次线上事故有人因为没找到现成的限流组件自己花了两天重写了一个结果边界条件处理不到位上线后直接拖垮了依赖服务。当时复盘的时候所有人得出同一个结论我们缺的不是组件也不是文档而是一个能“用自然语言把现存知识与当下问题快速匹配起来”的检索通道。这个结论后来成了HagiCode的起点——所以第一版的核心需求非常朴素把所有散落的技术资料统一抓进一个索引里用户输入一句话系统返回最相关的代码片段和文档并且标注清楚来源、作者和上次更新时间。我们内部给它起的代号是“知识路由器”因为它的工作方式就像一台交换机把提问的人和沉淀的知识对接起来。1.2 需求变大的信号当其他团队开始主动要求接入最开始这个工具只在我们自己的后端组跑部署在一台2核4G的旧服务器上索引量不到10万条。坚持用了三个月之后隔壁中间件团队的人来问能不能开通账号因为他们也有一堆SDK文档和最佳实践想放进检索里。我没有直接答应而是先问他们三个问题权限上是否需要跟自己的团队绑定文档的更新频率是多少能不能接受最初一版粗糙的交互方式他们全都接受了。这个细节后来被我视为需求变大的第一个真正信号使用者愿意为“找得到”这个核心价值忍受各种不完美。从那时候起内容从纯代码片段扩展到了设计文档、故障复盘、技术方案评审记录用户角色也从五六个人扩展到二十多个团队。权限模型、内容的归属与审核、搜索结果的个性化排序这些需求开始浮出水面已经不是一个“检索插件”能扛住的量级了。现在回看需求萌发阶段最重要的一件事其实是把“谁会用、解决什么问题、在什么场景下觉得离不开它”这三个问题想清楚。很多平台类项目死于过早的通用化就是因为没有经历过“一小群人要得非常急”的阶段就直接去做大而全。HagiCode后来的架构演进每一步都能回溯到某个具体的真实使用场景这是我个人觉得它没有走偏的根本原因。2. 第一版架构的取舍为什么我们坚持从单体起步2.1 技术选型能快速验证问题的组合才是好组合第一版的开发周期是四周目标只有两个抓取内容是幂等的、搜索结果是可用的。我们完全可以一上来就搞微服务、上Kubernetes、把搜索引擎换成独立的Elasticsearch集群但当时团队连运维精力都不够所以技术栈选得很保守。后端采用Go语言主要是因为编译产物是单个二进制部署太方便了而且并发抓取文档和处理检索请求的性能足够。Web框架用了GinAPI设计遵循REST风格。前端用的Vue3加Element Plus没有单独的BFF层直接调后端接口。数据库选了PostgreSQL一套库同时存元数据、用户信息和操作日志全文检索第一版没有额外引入Elasticsearch而是直接用PostgreSQL自带的全文检索加上中文分词插件实现。存储上唯一比较早做的设计是给内容表预留了一个metadata JSONB字段用来存一些后续检索会用到的扩展属性例如依赖的第三方库版本、适用编程语言、代码片段里的关键符号等。JSONB的好处是前期不用把字段定死后面随着平台演进内容的元数据只会越来越丰富这种设计能避免频繁改表结构。Redis做的则是缓存和简单限流没有承担业务状态。这个组合谈不上多先进但有一个非常明确的优点出问题的面很小。四周之内我们就把端到端流程跑通了文档从采集入库到能在Web上检索出来整个过程没有引入超过两层的中间依赖。等到平台进入独立发展阶段我们才根据新的瓶颈去替换组件而不是在需求还没验证的时候就背着沉重的技术债前进。2.2 单体里的模块边界为后续拆分埋下的伏笔虽然第一版是标准的单体应用代码都放在同一个Git仓库、跑在同一个进程里但在写代码的时候我们刻意做了模块边界这是整段演进历史里我最庆幸的一个决定。代码按业务域划分成四个大模块ingestion负责内容的接入与解析支持从Git仓库、RSS、Markdown文件三种来源拉取数据search负责查询理解和结果召回auth负责登录、组织、权限控制platform负责Web端的页面渲染管道和基础配置管理。模块之间通过接口依赖不允许跨域直接操作对方的数据库表。即使是在单体阶段我们也坚持了依赖方向的一致性search可以依赖auth拿到当前用户的权限范围但auth绝对不反向依赖search。这部分约束靠的是Code Review纪律因为我们很清楚如果连单体内的模块边界都乱成一锅粥后面一到拆分阶段就必然付出沉重的重构代价。后来实践也证明正是这些边界让服务化拆分时我们能快速定位哪些函数必须迁走、哪些表需要从共享库中拆出来。2.3 Soul引擎的初版定位它负责理解内容与理解人不少朋友第一次听到“Soul”这个名字都以为是什么玄学的东西。其实在我们内部Soul就是平台的“灵魂引擎”专门负责两个问题的求解一是机器怎么理解内容本身二是机器怎么理解使用内容的人。第一版里Soul并没有独立部署它只是单体应用中的一个业务模块核心工作有三个。第一个是内容理解。对入库的文档做正文提取、代码块拆分、语言识别、关键符号抽取然后通过TF-IDF算法为每篇文档计算一组主题词向量。这套方案的技术门槛不高但胜在可控尤其是对代码类内容符号化处理后的精度比纯自然语言分词要高很多。第二个是搜索语义匹配。用户输入一个查询词之后除了做关键词匹配Soul还会把查询词映射到已有的主题词表上用余弦相似度召回那些“字面上没直接命中但主题相关的”文档。一开始这个逻辑极简却产生了非常直观的效果——搜“限流降级”时能返回讨论“熔断器”的文档这在旧Wiki里是做不到的。第三个是用户画像的雏形。我们不采集任何敏感个人信息只记录用户与内容的交互行为比如浏览了哪篇文档、收藏了哪个组件、在哪些搜索词上点击了哪些结果。这套行为数据被存进一张行为日志表为后续的个性化推荐埋下了伏笔。Soul这个名字也是那会儿定的平台有内容、有用户、有交互但真正让平台“活”起来并能持续进化下去的是对内容和对人的理解能力这就像一个人的灵魂一样贯穿始终。3. Soul引擎的独立演进从内部模块到独立服务的拆分实战3.1 触发拆分的三个信号性能、发布频率与团队协作很多人在做微服务拆分的时候喜欢从“架构先进性”出发但我们拆Soul纯属是被逼的。第一次明确出现拆分信号是在用户量突破一千之后。那段时间平台上线了实时热点统计功能全站的CPU和内存占用率开始出现周期性的尖峰。定位后发现罪魁祸首正是Soul模块里的离线定期任务和在线检索请求在共享进程资源每两小时一次的向量重算任务会把CPU打满导致同时段的检索响应时间从80毫秒涨到接近2秒。第二个信号是发布频率的冲突。平台主体功能基本稳定之后一周才发一次版但Soul模块几乎每两天就要迭代一次原因是分词词典、主题词库和相似度算法需要反复调优。单体部署意味着只要Soul有改动整个平台就必须陪跑一轮回归测试和灰度发布双方团队都觉得很痛苦。第三个信号来自团队分工。用户在量级上来之后算法方向的人开始加入他们希望用自己的模型替换原来的TF-IDF并且希望做到A/B实验。如果在单体里做实验就必须把对照组和实验组的逻辑都编译进同一个二进制里这既影响稳定性也让上线充满风险。当三个信号同时出现“把Soul从宿主进程中剥出去”就不再是选择题而是一道必做题了。3.2 拆分方案设计哪些能力留在宿主哪些能力必须独立拆分最重要的问题不是“拆不拆”而是“边界在哪里”。我们花了两周梳理Soul模块的所有依赖最后列出了一个明确的保留与剥离清单。留存在宿主进程里的能力包括用户认证与权限校验、页面渲染、内容管理后台的基础CRUD以及各个组织空间的基础配置。这些功能有一个共同特点变更频率低、强依赖业务状态跟主站的生命周期绑定得非常紧。另一个必须留在宿主里的原因是认证和权限不能跟业务主链路走两次网络调用否则每个页面请求都要多一跳这在架构上是不划算的。剥出去的能力包括全文索引的写入与查询、语义向量的计算与存储、个性化推荐的候选生成、行为日志的采集与聚合。这些能力的共同特点是无状态、高计算密度、算法迭代频繁而且它们面对的数据规模比业务状态大一个量级适合独立部署横向扩容。Soul独立之后暴露了两类接口一类是实时接口承担检索、推荐、相似内容查询另一类是异步接口通过消息队列接收内容变更事件和用户行为事件然后更新索引和向量库。这次拆分我们提前做了一件事把Soul存储的数据全部迁移到独立的数据库中。这在单体阶段就做好了Soul的表与其他业务表的物理存储一直隔离所以拆分时不需要做复杂的数据搬迁只需要把数据库连接信息从内嵌配置改为独立环境变量。这一步的准备工作让整个上线的窗口期压缩到了可以接受的范围。3.3 数据一致性过渡从强一致到最终一致的改造过程拆分过程中最难的不是把代码搬出去而是处理数据一致性的变化。单体时代发布一篇文档后立刻搜索结果一定是最新的因为写入和检索在同一个数据库里读的是同一份数据。拆成两个服务之后内容服务写入文档通过消息队列通知Soul去更新索引之前那种“必现的强一致”就变成了“可能在几百毫秒内有延迟”的最终一致。我们最初采用的方案比较保守先同步调用索引更新失败了回滚主流程。但这样带来的后果是主流程的性能被索引服务拖累而且让主流程的可用性依赖Soul的可用性这违背了拆分的初衷。后来我们改成了异步更新加补偿机制。文档发布时只写业务库发送一个DocumentChanged事件到消息队列Soul消费事件后执行索引更新。为了防止消息丢失内容表里增加了一个version字段每次更新都会递增同时记录一个indexed_version字段。有一个定时巡检任务每小时扫一遍发现version和indexed_version不一致的记录就重新发送一次更新事件。这套机制虽然听起来土但把“消息丢失”和“消费者宕机”这两类典型问题兜住了。直到今天Soul的索引延迟指标一直保持在可接受范围内靠的就是这个看似笨重但稳定可靠的补偿链路。4. Soul推荐与分发机制的技术拆解让好内容找到对的人4.1 为什么标签匹配不够从内容特征到行为序列建模做内容平台迟早会遇到一个瓶颈标签匹配的推荐越做越窄。早期Soul的推荐逻辑是“给内容打标签给用户打标签然后匹配标签”。执行起来很直观运营团队也方便干预但它有一个天然缺陷——完全不理解用户当下的意图。一个后端工程师可能同时在看Kubernetes和手冲咖啡的帖子如果系统只根据他点过一杯手冲咖啡的文章就疯狂推咖啡工具测评他很快就会觉得这个推荐系统很傻。所以我们把第二版推荐的核心从“特征匹配”转向了“行为序列建模”。我们不追问用户“是什么样的人”而是只看他“最近一段时间在找什么”。系统会维护一段滑动窗口内的用户行为序列包括搜索词、浏览时长、收藏、分享、以及搜索结果点击。一个简单的用户向量是通过对序列内所有行为对应的内容向量做加权平均得到的权重由行为类型决定收藏的权重高于浏览分享的权重更高。线上召回阶段采用的是双路召回一路是语义向量最近邻一路是行为相似用户的协同过滤。两路各自取Top 50之后放入排序阶段排序模型第一版用的是GBDT特征包括文本相关性、内容时效性、行为相似度、质量分和多样化惩罚项。这套结构覆盖的细节很多但核心思想就一个推荐系统不是要让用户不断看到和他过去一模一样的东西而是理解和跟随他当下的问题上下文让优质内容在对的时刻出现。4.2 冷启动阶段的兜底策略规则、模板与人工经验任何一个推荐系统都躲不开冷启动内容冷启动和用户冷启动得分开处理。内容冷启动方面新入库的文档没有足够的行为数据模型无法评估它的质量。我们的做法是先用一个启发式质量分作为“保底”代码片段有可运行的示例、文档包含清晰的更新日志、页面结构有完整的标题层级这些都会加分反之纯碎片化无上下文的代码块、没有标明适用环境的内容会减分。在积累到足够的曝光和交互数据之前就靠这个质量分参与排序。用户冷启动处理得更简单首次登录的新用户会进入一个领域偏好选择页选择“后端”“前端”“运维”“算法”等方向。这种信息虽然颗粒度粗但比没有任何信息强得多至少能让首屏推荐的内容不是平均意义上的全网热门而是用户所选领域里的热门优质内容。在用户产生二十条有效行为之前我们坚持以“领域热门规则去重”为主要策略不做过于激进的个性化因为过早的个性化在数据不足时反而会把用户推向狭窄的信息角落。这些策略全都沉淀成了平台后台可视化的“分发规则模板”运营团队可以手动调节不同目录内容的冷启动曝光配额。这既是技术手段也保留了人的控制力。我的经验是在算法不成熟的时候人和规则的兜底往往比强行调模型参数更靠谱。4.3 效果评估与算法迭代中的关键指标推荐和搜索这类系统最怕“自我感觉良好”必须用指标来牵引迭代方向。我们最关注的三个核心指标是检索结果点击率、搜索结果到内容详情的转化率以及推荐位的单位曝光带来的收藏转化率。除了这些业务指标我们还单独监控群体层面的指标比如新入库内容在7天内是否获得了足够的曝光因为“新内容冷启动覆盖率”决定了内容创作者愿不愿意继续贡献内容。有一段经历非常典型初期推荐位点击率一路在涨我们很开心但随后发现内容详情的平均阅读时长反而下降了。深入排查之后发现高点击的内容大多数是标题吸引眼球的入门教程技术深度不足用户点进去滑两下就走。这个反例提醒我们单一看点击率会被带偏。后来我们在排序模型里增加了阅读时长和收藏行为的权重并且用“短曝光少点击”的内容做试探性分发逐步校准了推荐方向。模型迭代的节奏也很讲究。我们保持两周一个小迭代的节奏每次只改一个变量做A/B实验观察至少七天再决定是否全量。对于Soul引擎这样的推荐模块来说激进迭代带来的用户体验波动是非常危险的因为用户一旦对推荐结果产生“不靠谱”的印象之后很难再拉回来。5. 演进路上踩过的坑三轮事故复盘与根因定位链路5.1 搜索质量事故分词与词典更新不及时引发的连锁反应Soul引擎上线后不久我们就经历了一次让整个平台口碑受损的搜索事故。事情的直接表现是用户搜索一个最近很热门的新技术关键词搜索结果为零但平台里明明已经收录了大量相关内容。排查链路从索引数据开始。我第一反应是检查索引里有没有内容确认有然后检查关键词是否命中发现没有命中再检查数据库里的原始文档内容确实存在。最后定位到问题出在分词环节——我们用的中文分词插件依赖一份自定义词典新技术的名词没有被收进词典分词器切出来的全是碎片化词元实际索引和查询之间根本对不上。修复过程不复杂把新词批量加进自定义词典重启索引服务重新构建增量索引问题解决。但这件事暴露了机制上的缺陷词典依赖人工维护一定会滞后于内容增长速度。后来我们加了两个自动化措施。一是定期从高曝光但低搜索转化的用户搜索日志中挖出“搜索无结果”的词自动聚合成候选新词列表召回率超过阈值就自动入库。二是内容采集时对代码仓库的README和主流社区的高频词做统计自动补充技术词条。这个坑在内容型平台里非常典型任何依赖分词和关键词匹配的系统都值得提前做好词典管理规划。5.2 内容分发错乱定时任务的重复执行与本地缓存污染第二次严重事故发生在Soul独立部署后的第三周现象是部分用户收到了完全不应该推送给他们的内容即使点了“不感兴趣”下次刷新还是会再次出现。因为问题看起来出在推荐链路算法团队先检查了特征和模型没发现明显异常接着检查了消息队列也没看到重复消费的迹象。查了两天之后根因终于浮出水面竟然来自两个问题叠加。第一个是推荐结果的缓存过期时间设置过长并且没有考虑用户维度的失效策略导致用户已经反馈了“不喜欢”之后旧缓存仍然在往外吐结果。第二个问题更隐蔽我们在离线重算用户推荐候选集的定时任务里用了“先删全表再写入”的逻辑正常情况下只有一台机器在执行任务但运维同事在扩容时忘了关掉另一个旧副本上的定时器——两个节点的任务同时运行一个删完写入另一个又把上一次的结果覆盖了回来。复现链路确认后我们把定时任务改成基于数据库锁的分布式调度保证同一时间只有一个节点在执行同时也给用户反馈行为加了实时缓存失效逻辑“不感兴趣”会被立即写入Redis来拦截后续分发。这次事故也给我们提了个醒无状态服务的“无状态”是指不保存业务状态但分布式任务本身的状态管理如果不做严谨设计一样会翻车。5.3 内容安全治理从被动接收到主动体系的建设过程平台内容多了之后安全治理就不是可选项目而是生死线了。这里指的安全不只是政治敏感层面的内容还包括抄袭、垃圾广告、低质量引流文本、个人隐私泄露等问题。我们最开始采用的是纯事后处理用户举报后人工审核导致违规内容可能存在几天才被发现。这套被动机制很快被现实教育了。有一段时间有批量账号在平台发布带有外链的付费课程广告措辞伪装成技术教程人工审核根本忙不过来。后来我们建立了三层治理体系。第一层是入口过滤注册时的设备指纹与历史风险库以及发布接口的文本指纹做初步筛查。第二层是实时规则在内容发布时跑一个轻量级分类模型识别广告、标题党、低俗文本平台词典库也持续更新。第三层是人工抽检与用户反馈处理对于机器模型置信度较低的内容进入人工审核队列。此外所有原文和审核日志都会保留一段时间的快照方便做违规溯源和模型复盘。这一套治理架构建完之后违规内容从用户看到到被封禁的平均时间从几天缩短到小时级别。技术平台的护城河不只是搜索精度和推荐算法内容的可信任度才是用户敢不敢长期留在这里的根本。而“内容安全”想做好靠的也绝不是一个华丽的开箱组件而是持续运营的流程和规则迭代。6. 从工具到平台的下一步架构现状与生态演进规划6.1 当前的系统拓扑与关键边界经过几轮演进HagiCode目前的服务边界基本稳定下来我只讲核心部分。最外层是Web端和开放API统一经由API网关进入内部服务网关负责统一鉴权、限流、灰度策略和审计日志记录。向下拆分为内容服务、成员组织服务、Soul智能引擎和异步任务中心四个关键组件。内容服务负责文档和代码片段的CRUD、版本管理、标签管理发布内容时通过消息队列发出变更事件Soul消费事件之后更新索引和向量库。成员组织服务管人和权限它对接到每个团队空间保证一个团队的私有内容不会被其他团队的检索请求命中。异步任务中心承担定时巡检、索引补偿、统计报表生成这些不追求实时性的工作。前端目前是Vue3的SPA应用配有服务端渲染用于SEO场景。存储层是核心中的核心PostgreSQL是业务真相源Redis承担热数据和分布式锁的职责Elasticsearch和向量库为Soul提供检索与召回能力。特别说明一下我们没做跨服务的分布式事务所有跨服务状态变更都是通过“本地消息表事件重放”来保证最终一致这套机制在第三章已经讲过了。稳定的系统拓扑就是尽量让每个服务保持单一职责依赖方向清晰扩容时只需要对无状态服务扩容即可。6.2 开放生态的设计API网关、Webhook与插件机制用户量到了一个级别之后越来越多团队提出基于HagiCode做二次开发的需求有人想在文档详情页嵌入自己的组件监控面板有人想把代码片段同步到内部IDE插件里。这让我们认定独立的平台不能只是一个封闭应用它需要外部生态接口。开放API的设计走了不少弯路。最初我们只是把内部REST接口直接暴露结果外部调用方拿着内网专用的分页参数来对接双方都很难受。后来我们专门设计了面向外部语义的API版本包了一层参数映射与校验并给每个第三方应用签发独立的AppID和Secret调用必须经过网关签名校验。限流策略也改成按应用维度而不是IP维度因为同一公司下的出口IP经常是公用的按IP限流很容易误伤。Webhook机制是我们目前外部集成量最高的功能。开发者可以在平台里订阅某个目录的文档变更、新评论、新版本发布等事件平台通过HTTP请求把事件载荷推送到他们的自有系统里。这套机制做起来并不复杂核心是要有可靠的投递记录和失败重试以及重放保护我们给出的方案是每条Webhook事件带一个幂等键接收方按幂等键做去重。目前插件机制还在打磨中我们的思路是从“内容扩展点”和“数据源接入点”两个方向入手而不是先做一个庞大的插件运行沙箱让事情保持足够简单和可控。6.3 下一阶段的技术方向多模态内容与向量检索持续演进的方向上我们近期主要在看两块多模态内容的接入和向量检索的深化。先说多模态内容。技术人的知识沉淀早就不是纯文本了大量有价值的信息存在录屏视频、架构图、白板照片和演讲幻灯片之中。这些内容的检索如果还停留在人工打标签阶段效率太低。我们正在尝试的是把视频和图像内容做抽帧与OCR识别再通过图像特征向量和文本向量放入统一的向量空间。用户在搜索某个问题时召回结果里既可以有纯文本文档也可以有对应的视频讲解片段。这项工作很大程度依赖模型推理的成本控制目前还在用小流量实验验证性价比不会为了堆功能而盲目上线。向量检索方面我们在探索用预训练模型替换之前的TF-IDF向量让语义相似度的判断更接近人的直觉。但工程上需要注意的坑很多比如向量维度过高会带来巨大的存储和计算开销、需要做量化压缩还有新内容上线后索引构建延迟不能太高。考虑到曾经吃过词典更新慢的亏这次我们提前设计了增量向量索引的流水线确保算法模型的升级不会让数据链路拖后腿。如果让我重新做一次第一版的内容模型设计一定要更抽象而不是急着把字段写死。我们后续很多补丁式的代码改动其实都源于最初没给内容类型留够扩展空间。这可能是整个演进故事里最值得拿出来分享的教训技术平台的长期生命力取决于你对未来不可见需求预留的柔性而不是当下把所有功能做到最满。
返回列表