![]()
一个监控工具从单账户用到100个租户,数据库查询慢了47倍,Webhook丢事件,还有人把密钥配错了别人的端点。这是BillingWatch作者晒出的真实踩坑记录。
他用FastAPI+SQLite搭了个"无聊但能用"的方案,却在Stripe路由上卡了整整两周。最后发现,最干净的解法不是共享端点加校验,而是给每个租户发一把专属钥匙。
01 从"给自己用"到"别人也想用"
BillingWatch最初是个单租户工具。作者对接一个Stripe账户,写死配置,跑起来就行。
然后邮件来了。"能支持多个团队吗?""我们子公司有独立Stripe账户。""代理客户需要隔离数据。"
需求很明确:同一套代码,100个租户,数据不能串门。作者选了最朴素的方案——行级隔离。每张表加tenant_id字段,每个查询强制过滤。
代码长这样:
```python @app.get("/anomalies") def list_anomalies(tenant_id: str = Depends(get_tenant_id), db: Session = Depends(get_db)): return db.query(Anomaly).filter(Anomaly.tenant_id == tenant_id).all() ```
「每个端点、每次查询、每个仪表盘请求都这么处理。无聊,但有效。」作者原话。
SQLite在测试环境够用,生产可切Postgres。没有花哨的多数据库路由,没有按租户分片。就一个WHERE条件,堵死所有跨租户泄漏的可能。
02 Webhook路由:100个账户的头痛问题
查询隔离简单,Stripe webhook才是硬骨头。
![]()
Stripe要求每个账户配置独立的webhook端点,接收支付成功、退款、争议通知。100个租户意味着100个不同的Stripe账户,都要往你的服务器推事件。
最脏的方案:所有租户共享一个端点,靠payload里的账户ID路由。作者试过,很快放弃。
「Stripe的签名验证用的是账户级密钥。你先得用密钥A验签,失败了换密钥B,再失败换密钥C……100个租户就是100次尝试,延迟爆炸,日志里全是误报。」
更糟的是安全风险。共享端点意味着任何租户的验签失败都会暴露其他租户的存在,时序攻击能猜出有效账户范围。
作者换了思路:反向操作。不给租户一个共享入口,给每个租户发专属URL。
```python @app.post("/webhook/{tenant_token}") async def stripe_webhook(tenant_token: str, request: Request): tenant = db.query(Tenant).filter(Tenant.webhook_token == tenant_token).first() if not tenant: raise HTTPException(status_code=404) # 用该租户自己的密钥验签 event = stripe.Webhook.construct_event(payload, sig, tenant.webhook_secret) process_event(event, tenant.id) ```
租户在Stripe后台配置的是`https://yourdomain.com/webhook/abc123def`,其中abc123def是随机生成的唯一token。找不到token直接404,找到才用对应密钥验签。
「干净隔离,没有共享密钥。每个租户的事件流物理上就是不同的HTTP端点。」
03 幂等性:Stripe的"至少一次"承诺
Stripe保证webhook至少送达一次。网络抖动、服务器重启、502错误都会触发重试。100个租户的中等流量下,重复事件开始堆积。
作者加了张极简的幂等表:
```python class ProcessedEvent(Base): event_id = Column(String, primary_key=True) # Stripe事件ID tenant_id = Column(String, nullable=False) processed_at = DateTime ```
![]()
处理前先查:`SELECT 1 FROM processed_events WHERE event_id=? AND tenant_id=?`。命中就跳过,没命中才执行业务逻辑,最后INSERT。
复合索引(event_id, tenant_id)让查询在百万行数据下保持亚毫秒级。SQLite扛得住,切Postgres后性能更稳。
「有个细节:Stripe事件ID是全局唯一的,但我们的幂等键是复合的。万一两个租户恰好收到同名事件?理论可能,实际没发生。复合键是多租户的安全带。」
04 被嫌"太简单"的方案,为什么作者坚持
作者在HN帖子下回复过一条评论。有人问:为什么不用更"优雅"的方案,比如按租户分数据库,或者用消息队列解耦?
「我试过EventBridge+SQS的架构。租户50的时候,CloudWatch账单先让我清醒了。现在这套代码,新租户注册就是INSERT一行,配个webhook URL,5分钟上线。」
行级隔离的代价是查询都要带tenant_id。但作者算了笔账:100个租户,假设每个每天产生1000条事件,一年才3650万行。SQLite单文件上限140TB,Postgres分区表能撑到百亿级。
「复杂度是渐进式加的。SQLite变慢了?加索引。还不够?切Postgres。再不够?按时间分区。每一步都有明确的瓶颈信号,而不是提前为'可能'的问题写2000行配置。」
有个用户反馈让作者印象深。某SaaS公司把BillingWatch嵌进自己的平台,给客户做白标账单监控。他们的运维问:能不能隐藏tenant_id的存在,让界面看起来是"原生"的?
作者回了句:「tenant_id是URL里的path参数,不是cookie。你的客户永远看不到,除非他们开开发者工具——那时候他们已经知道自己在用第三方服务了。」
对方沉默两天,回复:「我们试了另外两家竞品,一家按API调用收费,100租户每月$800;另一家强制用他们的托管数据库。你的'无聊方案',我们fork后自己部署了。」
100个租户,零额外基础设施成本,代码行数从单租户版本的1200行涨到1800行。这600行里,400行是webhook路由和幂等性,200行是租户管理的CRUD。
作者最后补了句:「如果有人问我多租户架构建议,我会说先写最让你不好意思拿出来展示的代码。能跑、能测、能睡个好觉,比'架构优雅'重要十倍。」
你的监控工具是从第几个租户开始重构的?还是还在用"暂时先这样"的单账户方案硬撑?
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.