自建Notion与GitHub双向同步服务:轻量级MCP数据管道实践
1. 项目概述这不是一个“玩具服务器”而是一条生产级数据管道的起点你有没有过这种体验在 Notion 里写完产品需求想自动同步到 GitHub 的 issue 模板里或者把 GitHub 上刚 merge 的 PR 描述实时推送到 Notion 的项目进度看板中——但每次都要手动复制粘贴、反复切换标签页、漏掉关键字段、甚至误操作覆盖已有内容我试过用 Zapier、Make.com 做过十几版自动化流程结果不是触发延迟严重平均 3–7 分钟就是某天突然收费策略变更整条链路直接瘫痪。直到去年底团队决定彻底甩开第三方集成平台自己搭一套轻量但可控的中间服务——核心目标就三个数据主权在我手上、响应必须亚秒级、出问题我能立刻定位到哪一行日志。这个项目标题里的 “Self-Hosted MCP Server” 并不是什么新造概念MCPModel Context Protocol在这里我们把它务实理解为“模型上下文协议”的轻量实践层它不负责训练大模型而是专注做一件事——在 Notion 和 GitHub 这两个完全独立、API 设计哲学迥异的系统之间建立一条可审计、可限流、可鉴权、可灰度发布的双向语义通道。Auth 不是加个登录页就完事而是基于 JWT OAuth2.1 的最小可行实现支持细粒度 scope 控制比如只允许读 Notion database但禁止写入Rate Limits 也不是简单套个express-rate-limit中间件而是按用户身份、按 endpoint、按操作类型read/write/delete三级嵌套限流连 webhook 回调失败重试的指数退避策略都写进了配置。它跑在一台 2C4G 的云服务器上月均成本不到 $8却支撑了我们 12 个活跃项目的跨平台协同。如果你正被 SaaS 工具间的“数据孤岛”卡住脖子又不想投入全职后端人力去维护一套重服务那这个方案就是为你量身定制的——它不追求通用性只解决你此刻最痛的那个缝合点。2. 整体架构设计与技术选型逻辑为什么放弃“全栈框架”选择“乐高式堆叠”2.1 核心思路拒绝“银弹”拥抱“可替换模块”很多人看到“自建服务”第一反应是“得用 Django/Flask/FastAPI 吧再配个 PostgreSQL”——这恰恰是我们踩过最深的坑。去年初我们用 FastAPI SQLAlchemy 搭了个“看起来很美”的服务结果上线两周就发现Notion API 的 rate limit 是按 workspace 级别计算的而我们的数据库事务锁住了整个 sync 流程导致一个用户的慢请求会拖垮所有人的 webhook 处理。于是我们彻底重构思路把整个服务拆成四个物理隔离、协议明确的模块——Auth Gateway、Notion Adapter、GitHub Adapter、Sync Orchestrator。它们之间只通过 Redis Stream 通信每个模块都是独立进程、独立部署、独立扩缩容。比如 Notion Adapter 只干三件事接收来自 Auth Gateway 的带签名 token 的 sync 请求、调用 Notion 官方 SDK 获取 page/database 数据、把结构化 JSON 推进notion:outboundstream。它完全不知道 GitHub 长什么样也不关心用户是谁——所有鉴权和路由逻辑都在上游 Gateway 层完成。这种设计带来三个硬性好处第一单点故障不影响全局Notion Adapter 崩了GitHub Adapter 依然能处理其他请求第二升级零感知我们上周把 Notion Adapter 从 v1.0 升级到 v2.0支持 block-level diff全程没动其他模块一行代码第三测试成本断崖下降每个 Adapter 都可以单独用 mock server 跑 100% 的单元测试不用再搭整套环境。2.2 为什么选 Rust Axum 做 Auth GatewayGateway 是整条链路的“守门人”必须扛住突发流量、杜绝内存泄漏、毫秒级完成鉴权。我们对比了 Node.jsExpress、GoGin、PythonFastAPI和 RustAxum四套方案实测数据如下压测环境4C8G 服务器wrk -t12 -c400 -d30s方案P99 延迟内存占用峰值持续 30 分钟 CPU 占用率意外崩溃次数Express128ms1.2GB82%3 次OOMGin42ms480MB65%0FastAPI89ms950MB76%1 次asyncio event loop hangAxum23ms210MB41%0关键不是“Rust 快”而是它的所有权模型天然规避了 Node.js 的 callback hell 和 Python 的 GIL 瓶颈。比如 JWT 解析Axum 的jsonwebtokencrate 在解析时根本不分配堆内存所有字符串操作都在栈上完成而 Express 的jsonwebtoken库每次验证都要新建 V8 contextGC 压力极大。我们还利用了 Axum 的tower::limit::RateLimitLayer做第一道限流它基于滑动窗口算法精度到毫秒级比 Express 的rate-limit中间件固定时间窗口更能应对脉冲流量。更重要的是Rust 的编译期检查让我们在开发阶段就堵死了 90% 的空指针和竞态条件——上线三个月Gateway 的 error 日志只有 7 条全是客户端传了非法 token没有一条是服务端 panic。2.3 为什么 Notion Adapter 用 TypeScript BunGitHub Adapter 用 Go这不是炫技而是精准匹配 API 特性。Notion 官方 SDK 是 TypeScript 编写的它对 block 结构的类型定义极其严谨比如heading_1、callout、synced_block这些类型在 runtime 是动态生成的用 TypeScript 直接 consume 官方类型定义能省掉 80% 的 DTO 映射代码。而 Bun 作为新兴运行时在启动速度和内存效率上碾压 Node.js我们的 Notion Adapter 启动时间从 Node.js 的 1.2 秒降到 0.18 秒这对 Kubernetes 的滚动更新至关重要——旧 Pod 还没完全下线新 Pod 已经 ready 并开始消费 stream。反观 GitHub API它的 webhook payload 极其庞大一个 PR event 的 JSON 超过 2MB且文档里埋了大量“未公开但实际存在”的字段比如pull_request.merged_by在某些企业版实例里是 null。Go 的encoding/json包对未知字段的容忍度极高配合github.com/google/go-github/v53这个社区维护最勤的 SDK我们能稳定解析 99.98% 的 webhook 事件。最关键的是Go 的 goroutine 调度器在处理高并发 HTTP client 请求时比 TypeScript 的 event loop 更可控——我们给 GitHub Adapter 配置了 50 个并发 worker每个 worker 用http.Client复用连接池实测在 1000 QPS 下依然保持 50ms 的平均延迟。2.4 Sync Orchestrator 为什么坚持用 Python Celery这里有个反直觉的选择明明前面都用高性能语言为什么最核心的“协调器”反而用 Python答案是业务逻辑复杂度远高于性能需求。Orchestrator 要做的不是“快”而是“准”和“稳”。它要解析 Notion page 的 rich text提取其中的github-issue-123这类自定义 mention再调用 GitHub API 关联 issue要判断一个 Notion database row 的 status 字段从 “In Progress” 变成 “Done” 时是否该关闭对应的 GitHub issue还要处理冲突比如用户同时在 Notion 和 GitHub 修改了同一字段以哪个为准这些规则全是 if-else 堆出来的业务判断用 Python 写三天就能上线 MVP用 Rust 写光是类型系统适配就得两周。我们用 Celery 的acks_lateTrue和reject_on_worker_lostTrue保证任务至少一次执行at-least-once delivery用 Redis 作为 broker 和 result backend所有任务状态都可查。性能瓶颈根本不存在——我们给 Orchestrator 配了 8 个 worker但日常峰值也就 12 QPSCPU 占用常年低于 15%。真正的价值在于当产品经理说“下周要加个功能把 Notion 里的 deadline 字段自动转成 GitHub issue 的 due date”我打开orchestrator/tasks.py加 20 行 Python 代码10 分钟就上线了。这才是工程效率的本质用对的语言解决对的问题。3. 核心细节解析与实操要点Auth、Rate Limits、Webhook 三大生死线3.1 Auth 实现JWT OAuth2.1 的最小可行闭环Auth 不是“加个登录按钮”而是构建一个可信的身份传递链。我们的流程分三步走用户授权 → Token 签发 → 网关校验。第一步用户点击 Notion 或 GitHub 的“Connect Account”按钮跳转到我们的/auth/notion/login或/auth/github/login。这里不做任何页面渲染而是用302 redirect直接跳转到对应平台的 OAuth2 授权页Notion 的https://api.notion.com/v1/oauth/authorizeGitHub 的https://github.com/login/oauth/authorize并带上我们预注册的client_id、redirect_uri和scopeNotion 限定users.read,databases.read,pages.readGitHub 限定repo,user:email。第二步平台授权成功后会回调我们的/auth/callback我们用code换取 access_token然后立即调用平台 API 获取用户唯一标识Notion 用https://api.notion.com/v1/users/me拿idGitHub 用https://api.github.com/user拿login再把这个标识、租户 ID我们用子域名区分客户如acme.yourdomain.com、权限 scope 打包进 JWT payload用 HS256 算法签名。关键细节来了我们不用 Redis 存 token而是把所有必要信息都塞进 JWT 的 payload 里并设置 7 天过期。为什么因为 Gateway 需要毫秒级校验如果每次都要查 RedisP99 延迟直接翻倍。JWT 的 payload 长这样{ sub: notion_user_id_abc123, iss: your-mcp-server.com, aud: [notion-adapter, github-adapter], exp: 1735689600, iat: 1735084800, scopes: [databases.read, pages.write], tenant: acme }第三步Gateway 收到请求后用对称密钥HS256验证签名检查exp是否过期aud是否匹配当前模块scopes是否包含请求 endpoint 所需权限。 提示audAudience字段是防越权的关键。比如 Notion Adapter 只认aud: notion-adapter的 token就算有人伪造了一个 GitHub 的 tokenGateway 也会在aud校验时直接拒绝根本不会转发给下游。3.2 Rate Limits三级嵌套限流让每个请求都“明码标价”Rate Limits 不是“全局限 100 次/分钟”这么粗暴。我们设计了三层控制用户级 → Endpoint 级 → 操作类型级。第一层用户级限流。每个用户由 JWT 的sub字段唯一标识在 1 小时内最多发起 5000 次请求。这用 Axum 的tower::limit::RateLimitLayer实现底层是滑动窗口计数器存储在 Redis 的INCREXPIRE组合里。第二层Endpoint 级限流。比如/v1/notion/pages/{page_id}这个 endpoint无论谁调用每秒最多处理 10 个请求——这是防止单个高频 endpoint 拖垮整个 Notion Adapter。我们在 Gateway 的路由定义里显式声明let app Router::new() .route(/v1/notion/pages/:id, get(get_page).layer( RateLimitLayer::new(10, Duration::from_secs(1)) )) .route(/v1/github/repos/:owner/:repo/issues, post(create_issue).layer( RateLimitLayer::new(5, Duration::from_secs(1)) ));第三层操作类型级限流。这才是精髓Notion 的 write 操作比 read 操作“贵”得多。我们给每个 API 调用打上“cost”标签GET /pagescost1POST /pagescost5PATCH /pagescost3。然后在 Gateway 的 middleware 里动态计算“剩余配额”用户总配额 5000已用 4800那么他还能发 200 个 GET但只能发 40 个 POST。这个 cost 不是拍脑袋定的而是根据 Notion 官方文档的 rate limit 换算来的——Notion 对 workspace 的总限制是 3 request/sec但每个 request 的复杂度不同createPage会触发 block 渲染消耗更多后端资源。 注意GitHub 的 rate limit 是按 access_token 计算的默认 5000 points/hour所以我们给每个用户的 GitHub token 单独维护一个 Redis counter每次调用 GitHub API 前先DECR如果返回负数就立即返回429 Too Many Requests绝不让请求真正发出去。这避免了因超限被 GitHub 临时封禁 token 的风险。3.3 Webhook 处理幂等性、重试、死信队列的实战落地Webhook 是整个系统的“神经末梢”也是最脆弱的一环。Notion 和 GitHub 的 webhook 都可能重复发送网络抖动、超时重试、乱序到达不同 region 的数据中心延迟不同、甚至丢失对方服务宕机。我们的处理流程强制遵循四个原则接收即存盘、处理必幂等、失败即重试、重试仍败入死信。第一步“接收即存盘”。Gateway 收到 webhook 后不做任何业务逻辑只做三件事校验X-Hub-Signature-256GitHub或X-Notion-SignatureNotion确保来源可信解析 payload提取唯一事件 IDGitHub 用X-GitHub-DeliveryheaderNotion 用X-Notion-Event-Id把完整 payload timestamp event_id 写入 Redis Stream 的webhook:rawchannel。这一步必须在 50ms 内完成否则对方会认为超时而重发。第二步“处理必幂等”。Orchestrator 的 worker 从webhook:raw消费事件首先用event_id查询 Redis 的SET结构key 为processed_events:{event_id}如果已存在直接 skip如果不存在先SET这个 key带 24 小时过期再执行业务逻辑。这样即使同个事件被消费两次也只会处理一次。第三步“失败即重试”。如果业务逻辑抛异常比如 GitHub API 返回 404worker 不 ack 消息而是把它重新XADD到webhook:retrystream并设置MAXLEN 10000防止无限堆积。我们用一个独立的retry-consumer服务每 30 秒扫描webhook:retry对失败超过 3 次的事件执行指数退避重试第一次 1 分钟后第二次 2 分钟后第三次 4 分钟后。第四步“重试仍败入死信”。如果重试 5 次都失败就把事件推送到webhook:dead-letterstream并发 Slack 告警给运维群。我们有专门的dlq-monitor服务监听这个 stream人工介入分析原因比如是 Notion page 被删除了还是 GitHub repo 权限变更了。这套机制上线后webhook 处理成功率从最初的 92.3% 提升到 99.997%月均死信事件不到 2 个。4. 实操过程与核心环节实现从零部署一个可运行的 MCP Server4.1 环境准备与依赖安装避开 Docker 的“便利陷阱”我们不用 Docker Compose 一键启全部服务而是坚持“每个模块独立部署”。为什么因为生产环境的可观测性和排障效率远比部署速度重要。Docker Compose 把所有服务绑在一个docker-compose.yml里一旦某个服务内存泄漏docker stats看到的是一堆容器的混合指标根本分不清是哪个在吃内存。我们的做法是用 systemd 管理每个服务的生命周期用 cgroup 限制资源用 journalctl 查日志。以 Notion Adapter 为例它的 systemd unit 文件长这样# /etc/systemd/system/notion-adapter.service [Unit] DescriptionNotion Adapter Service Afternetwork.target [Service] Typesimple Usernotion WorkingDirectory/opt/mcp/notion-adapter ExecStart/usr/local/bin/bun run src/index.ts Restartalways RestartSec10 MemoryMax512M CPUQuota50% StandardOutputjournal StandardErrorjournal [Install] WantedBymulti-user.target关键参数解释MemoryMax512M强制限制内存上限超限直接 OOM kill避免拖垮整台机器CPUQuota50%限制 CPU 使用率不超过 50%防止抢占其他服务资源StandardOutputjournal把 stdout/stderr 全部接入 systemd journal查日志只需journalctl -u notion-adapter -f。部署时我们用 Ansible 脚本自动化先apt install bunUbuntu 22.04再git clone代码库npm ci安装依赖最后systemctl daemon-reload systemctl enable --now notion-adapter。整个过程 3 分钟内完成且所有操作都有 Ansible playbook 记录可审计、可回滚。4.2 Auth Gateway 配置详解Nginx 与 Axum 的协同分工Gateway 前面必须加一层 Nginx不是为了负载均衡单机部署而是为了卸载 TLS、压缩、静态文件托管和基础防护。我们的 Nginx 配置精简到极致upstream mcp_gateway { server 127.0.0.1:8080; } server { listen 443 ssl http2; server_name *.yourdomain.com; ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; # 强制 HTTPS add_header Strict-Transport-Security max-age31536000; includeSubDomains always; # 静态资源比如前端管理界面 location /static/ { alias /opt/mcp/frontend/static/; expires 1y; } # API 路由全部透传给 Axum location / { proxy_pass http://mcp_gateway; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; } }Axum 本身不处理 TLS所以 Nginx 做 SSL 卸载把解密后的 HTTP/2 流量转发给本地8080端口。这样 Axum 的 CPU 完全不用花在加密计算上P99 延迟更稳定。Axum 的路由定义里我们显式区分了 public 和 private endpointlet app Router::new() // Public routes: auth flow, health check .route(/auth/notion/login, get(notional_login)) .route(/auth/github/login, get(github_login)) .route(/health, get(health_check)) // Private routes: require JWT auth .route_layer(middleware::from_fn(validate_jwt)) .nest(/v1, api_routes()); // api_routes() 里定义所有需要鉴权的 endpointvalidate_jwtmiddleware 是核心它从Authorization: Bearer tokenheader 里提取 token用jsonwebtoken::decode验证签名和有效期然后把解析出的Claims结构注入ExtensionClaims后续所有 handler 都能通过ExtensionClaims参数直接拿到用户身份和权限不用重复解析。4.3 Notion Adapter 实现如何优雅处理 Block 结构的动态性Notion 的最大难点不是 API 调用而是它的 block 结构极度动态。一个page可能包含heading_1、paragraph、callout、synced_block等几十种 block type而且每种 type 的 schema 都不同。官方 SDK 的 TypeScript 类型定义是用Recordstring, any模糊处理的但这会导致 runtime 类型错误。我们的解决方案是用 Zod 做运行时 schema 校验用 discriminated union 做类型收束。先定义一个基础 block schemaimport { z } from zod; export const BaseBlockSchema z.object({ id: z.string(), type: z.string(), // 这里先留 string后面用 discriminated union 细化 created_time: z.string().datetime(), last_edited_time: z.string().datetime(), }); export const Heading1BlockSchema BaseBlockSchema.extend({ type: z.literal(heading_1), heading_1: z.object({ rich_text: z.array(RichTextSchema), }), }); export const ParagraphBlockSchema BaseBlockSchema.extend({ type: z.literal(paragraph), paragraph: z.object({ rich_text: z.array(RichTextSchema), }), });然后在解析 API response 时用 Zod 的discriminatedUnion动态匹配const BlockSchema z.discriminatedUnion(type, [ Heading1BlockSchema, ParagraphBlockSchema, CalloutBlockSchema, // ... 其他 20 种 block ]); export async function getPageBlocks(pageId: string): PromiseBlock[] { const response await notionClient.blocks.children.list({ block_id: pageId, page_size: 100, }); // 运行时校验不匹配的 block 直接抛错不会让脏数据流入下游 return BlockSchema.array().parse(response.results); }这样当 Notion 新增一种table_rowblock 时我们只需要在BlockSchema里加一个分支Zod 就会在 runtime 自动识别并校验而不是让程序在block.type table_row时因为缺少table_row字段而 crash。实测下来这套方案让 Notion Adapter 的 crash 率从早期的每周 2–3 次降为零。4.4 GitHub Adapter 配置Webhook Secret 的安全存储与轮换GitHub 要求每个 webhook 都配置一个secret用于生成X-Hub-Signature-256header。这个 secret 绝不能硬编码在代码里也不能存在 Git 仓库。我们的做法是用 systemd 的 EnvironmentFile 加密加载配合定期轮换脚本。先创建加密的 environment 文件# 生成随机 secret openssl rand -base64 32 /etc/mcp/github-webhook-secret.env # 设置权限只有 root 和 github 用户可读 chown root:github /etc/mcp/github-webhook-secret.env chmod 640 /etc/mcp/github-webhook-secret.env然后在 GitHub Adapter 的 systemd unit 文件里引用[Service] EnvironmentFile/etc/mcp/github-webhook-secret.env # ...Adapter 启动时直接读取环境变量GITHUB_WEBHOOK_SECRET。轮换 secret 时我们写了一个 Ansible playbook它会1生成新 secret2调用 GitHub API 更新所有 repo 的 webhook secret3重启 GitHub Adapter 服务4发送 Slack 通知。整个过程 2 分钟内完成且旧 secret 会保留 24 小时GitHub 支持双 secret 验证确保无缝切换。 提示GitHub 的 webhook secret 是 base64 编码的但它的 HMAC-SHA256 签名算法要求原始字节所以代码里必须先Buffer.from(process.env.GITHUB_WEBHOOK_SECRET, base64)再传给 crypto 模块否则签名永远校验失败。4.5 Sync Orchestrator 任务编排Celery Beat 与动态任务路由Orchestrator 的核心是任务调度。我们不用 Cron而是用 Celery Beat 做分布式定时任务。Beat 进程会定期默认每秒向 Redis 的celeryqueue 发送beatmessageworker 消费后执行对应 task。关键是要支持“按租户动态路由”。比如acme租户的 sync 任务应该只由acme-worker消费不能被beta租户的 worker 拿走。Celery 的task_routes配置支持正则匹配# celeryconfig.py task_routes { orchestrator.tasks.sync_notion_to_github: { queue: notion-to-github, routing_key: notion-to-github }, orchestrator.tasks.sync_github_to_notion: { queue: github-to-notion, routing_key: github-to-notion } } # 动态租户队列每个租户一个专属 queue def route_task(name, args, kwargs, options, task): tenant kwargs.get(tenant, default) return {queue: f{tenant}-sync}这样当调用sync_notion_to_github.delay(tenantacme, ...)时Celery 自动把任务发到acme-syncqueue而我们启动 worker 时指定-Q acme-sync,broadcast就只消费这个租户的任务。所有租户的 worker 可以共存于同一台机器互不干扰。我们还用 Celery 的canvas功能组合复杂流程比如“同步一个 Notion database 到 GitHub repo”会分解为get_database_pages→filter_changed_pages→create_or_update_issues三个子任务用chord组织前两个并行执行第三个等全部完成后再触发。这种编排让业务逻辑清晰可测也方便单独重试某个失败的子任务。5. 常见问题与排查技巧实录那些文档里绝不会写的坑5.1 Notion API 的 “429 Too Many Requests”你以为是限流其实是认证失效这是最隐蔽的坑。Notion 的 rate limit 错误响应是429但它的 response body 里不包含Retry-Afterheader只有一句模糊的message: Too many requests。很多开发者以为是真超限了疯狂加 sleep结果发现加了 5 秒 delay 还是 429。真相是Notion 的 access token 有 1 小时有效期但它的 refresh token 是永久有效的。当你的 token 过期后Notion API 不会返回401 Unauthorized而是诡异地返回429。我们花了整整两天抓包才定位到这个问题。解决方案在每次调用 Notion API 前先检查 token 的exp时间戳如果距离过期不足 5 分钟就用 refresh token 换新 token。refresh 的 endpoint 是https://api.notion.com/v1/oauth/tokenbody 长这样{ grant_type: refresh_token, refresh_token: your_refresh_token_here, client_id: your_client_id, client_secret: your_client_secret }注意Notion 的 refresh token 不能重复使用每次 refresh 后旧的 refresh token 会立即失效新的 response 里会返回一个新的refresh_token。所以你的代码必须原子性地更新数据库里的 token 记录否则下次调用又会失败。5.2 GitHub Webhook 的 “400 Bad Request”URL 编码的魔鬼细节GitHub 的 webhook payload 里repository.full_name字段可能包含/比如my-org/my-repo。当你在代码里拼接 API URL 时如果直接写https://api.github.com/repos/${repoFullName}/issues/会被当成路径分隔符导致 URL 解析错误。GitHub 的正确做法是对repository.full_name进行encodeURIComponent但不要对整个 URL 进行 encode。比如// ❌ 错误对整个 URL encode会把 https:// 也 encode 掉 const url encodeURIComponent(https://api.github.com/repos/${repoFullName}/issues); // ✅ 正确只 encode path segment const owner encodeURIComponent(repoFullName.split(/)[0]); const repo encodeURIComponent(repoFullName.split(/)[1]); const url https://api.github.com/repos/${owner}/${repo}/issues;我们在线上遇到过一次事故一个客户 repo 名叫a/b/cGitHub 允许斜杠我们的代码没做 encode拼出的 URL 是https://api.github.com/repos/a/b/c/issuesGitHub 服务器解析时把b/c当成子路径返回400 Bad Request而我们的日志只记录了 HTTP 状态码没打印原始 URL排查了 3 小时才发现是 URL 编码问题。5.3 Redis Stream 的 “消息堆积”消费者组确认机制的致命误解Redis Stream 的消费者组Consumer Group有个反直觉的设计消息被XREADGROUP读取后会进入Pending Entries List必须显式XACK才算真正消费成功如果不XACK消息会一直留在 pending list 里直到超时默认 60 分钟才被释放。我们最初以为XREADGROUP读出来就自动 ack 了结果发现webhook:rawstream 里 pending 消息越来越多XPENDING命令显示有上万条 pending 消息。原因是Orchestrator 的 worker 在处理完一个 webhook 后忘记调用XACK。修复很简单在 Celery task 的 finally 块里加try: # 处理业务逻辑 process_webhook(payload) except Exception as e: logger.error(fFailed to process webhook {event_id}: {e}) raise finally: # 必须 ack否则消息永远 pending redis.xack(webhook:raw, mcp-group, message_id)提示用XPENDING webhook:raw mcp-group - 10可以查看 pending 消息的详细信息包括 consumer name 和 idle time这是排查堆积问题的第一步。5.4 Axum 的 “404 Not Found”路由匹配的优先级陷阱Axum 的路由匹配是“最长前缀匹配”不是“精确匹配”。比如你定义了两个路由.route(/v1/notion/pages/:id, get(get_page)) .route(/v1/notion/pages/sync, post(sync_all_pages))你以为/v1/notion/pages/sync会匹配第二个但实际上它会匹配第一个因为:id是通配符sync被当成id的值传给了get_pagehandler导致 404。正确做法是把更具体的路由写在前面更通用的写在后面.route(/v1/notion/pages/sync, post(sync_all_pages)) // 具体路径放前面 .route(/v1/notion/pages/:id, get(get_page)) // 通配符放后面我们在线上遇到过一次事故一个客户想调用/v1/notion/pages/sync结果被路由到get_page而get_page里用id去查 Notion APINotion 返回404 Page not found客户以为服务挂了。后来我们加了路由调试日志在 Axum 的TraceLayer里打印匹配的路由才暴露了这个问题。5.5 生产环境的 “内存泄漏”Bun 的 GC 行为与 Node.js 的本质区别Bun 的文档说它“比 Node.js 更快”但它的垃圾回收器Boehm GC行为和 V8 完全不同。V8 的 GC 是增量式的会主动触发 minor/major GC而 Boehm GC 是保守式 GC它不追踪指针而是扫描内存区域猜测哪些是“可能的指针”这导致它对长期存活的大对象比如缓存的 Notion page content回收不及时。我们用bun run --gc-stats监控发现Notion Adapter 的内存占用会缓慢爬升72 小时后达到 1.2GB超出MemoryMax限制systemd 强制 kill。解决方案有两个第一显式调用global.gc()在每次处理完