在BASE链上实现一个生产级Serverless中继服务的架构演进与成本控制


为我们的BASE链上dApp构建一个gasless中继服务(Relayer)最初听起来像个周末就能搞定的小项目。用户在前端发起一个操作,请求打到我们的API,API用服务端的钱包签名并广播交易。这个模式很直接,但真正投入生产环境的思考时,最初的乐观迅速被一系列棘手的问题所取代:这个中继服务将暴露在公网上,它如何抵御拒绝服务攻击(DoS)和恶意经济消耗?我们的服务器钱包里可是真金白银的ETH。

选择Serverless架构几乎是下意识的决定。Vercel Functions提供了完美的弹性伸缩能力,我们不需要预置服务器,流量来了自动扩容,没有流量就没有费用。这是一个理想的起点,但也是一系列架构权衡的开始。

第一版构想:过于天真的实现

初步的技术选型是 Vercel Functions + Ethers.js。我们将中继钱包的私钥存储在Vercel的环境变量中,创建一个API路由 /api/relay-tx,它接收用户的请求数据,构造交易,然后发送到BASE链上。

// pages/api/relay-tx.ts
import { ethers } from 'ethers';
import { NextApiRequest, NextApiResponse } from 'next';

// 这些应该从环境变量中安全地读取
const RELAYER_PRIVATE_KEY = process.env.RELAYER_PRIVATE_KEY!;
const BASE_RPC_URL = process.env.BASE_RPC_URL!;
const CONTRACT_ADDRESS = '0x...'; // 你的合约地址
const contractABI = [ /* ... 你的合约ABI ... */ ];

// 在函数外部初始化,以便在函数调用之间复用
const provider = new ethers.JsonRpcProvider(BASE_RPC_URL);
const wallet = new ethers.Wallet(RELAYER_PRIVATE_KEY, provider);
const contract = new ethers.Contract(CONTRACT_ADDRESS, contractABI, wallet);

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method Not Allowed' });
  }

  // 这里的inputData是用户希望执行操作的数据,需要严格校验
  const { inputData } = req.body;

  // 缺少对inputData的严格验证,这是一个巨大的安全隐患
  if (!inputData) {
     return res.status(400).json({ error: 'Invalid input data' });
  }

  try {
    // 构造并发送交易
    const tx = await contract.someFunction(inputData, {
      gasLimit: 200000, // 硬编码的gasLimit
    });

    console.log(`Transaction sent: ${tx.hash}`);
    
    // 不等待交易确认,直接返回,避免长时间占用函数
    return res.status(202).json({ success: true, txHash: tx.hash });

  } catch (error: any) {
    console.error('Relayer transaction failed:', error);
    // 错误处理过于粗糙
    return res.status(500).json({ error: 'Internal Server Error' });
  }
}

这段代码能跑,但它在生产环境中活不过一分钟。任何一个脚本小子都能写个循环请求这个API,在几分钟内耗尽我们钱包里所有的BASE ETH。这里的核心痛点是:无状态的Serverless函数无法有效阻止滥用。我们需要引入状态管理,实现访问控制和速率限制。

引入Firestore:实现有状态的速率限制

Firestore是Google的Serverless NoSQL数据库,与Vercel Functions是天作之合。它的按需计费模式和自动伸缩能力与Serverless理念完全契合。我们的目标是为每个访问者(暂时用IP地址标识)设置一个时间窗口内的请求次数上限。

首先,我们需要一个数据模型来存储IP地址的请求记录。

rateLimits 集合:

  • 文档ID: 用户IP地址 (为了符合Firestore的文档ID规则,将 . 替换为 _)
  • 字段:
    • count (number): 在当前时间窗口内的请求次数
    • timestamp (Timestamp): 时间窗口的起始时间

现在,我们来重构API函数,集成Firestore进行检查。

// lib/firebase.ts - Firestore客户端初始化
import { initializeApp, getApps, cert } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';

const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_KEY!);

if (!getApps().length) {
  initializeApp({
    credential: cert(serviceAccount),
  });
}

export const db = getFirestore();
// pages/api/relay-tx-v2.ts
import { ethers } from 'ethers';
import { NextApiRequest, NextApiResponse } from 'next';
import { db } from '../../lib/firebase';
import { FieldValue } from 'firebase-admin/firestore';

// ... ethers.js的初始化代码保持不变 ...

const RATE_LIMIT_COUNT = 5; // 每小时5次
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1小时

function getClientIp(req: NextApiRequest): string {
    const forwarded = req.headers['x-forwarded-for'];
    if (typeof forwarded === 'string') {
        return forwarded.split(',')[0].trim();
    }
    return req.socket.remoteAddress || 'unknown';
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Method Not Allowed' });
    }

    const clientIp = getClientIp(req);
    if (clientIp === 'unknown') {
        return res.status(400).json({ error: 'Could not determine client IP.' });
    }

    const ipDocId = clientIp.replace(/\./g, '_');
    const rateLimitRef = db.collection('rateLimits').doc(ipDocId);

    try {
        await db.runTransaction(async (transaction) => {
            const doc = await transaction.get(rateLimitRef);

            if (!doc.exists) {
                transaction.set(rateLimitRef, {
                    count: 1,
                    timestamp: FieldValue.serverTimestamp(),
                });
            } else {
                const data = doc.data()!;
                const now = Date.now();
                const windowStart = data.timestamp.toDate().getTime();

                if (now - windowStart > RATE_LIMIT_WINDOW_MS) {
                    // 窗口已过期,重置
                    transaction.update(rateLimitRef, {
                        count: 1,
                        timestamp: FieldValue.serverTimestamp(),
                    });
                } else if (data.count < RATE_LIMIT_COUNT) {
                    // 窗口内,计数增加
                    transaction.update(rateLimitRef, {
                        count: FieldValue.increment(1),
                    });
                } else {
                    // 超出限制
                    throw new Error('Rate limit exceeded');
                }
            }
        });
    } catch (error: any) {
        if (error.message === 'Rate limit exceeded') {
            console.warn(`Rate limit exceeded for IP: ${clientIp}`);
            return res.status(429).json({ error: 'Too Many Requests' });
        }
        console.error('Firestore transaction failed:', error);
        return res.status(500).json({ error: 'Internal server error during rate limit check' });
    }
    
    // ... 接下来的交易发送逻辑 ...
    const { inputData } = req.body;
    // ...
}

使用Firestore的事务(db.runTransaction)至关重要。它能确保“读-改-写”操作的原子性,防止并发请求绕过速率限制检查。这个版本比第一版安全了几个数量级,但新的问题也随之浮现:成本和延迟

每个进入我们API的请求,无论是否合法,都会触发一次Firestore事务,这至少包含一次读操作和一次写操作。在遭受低烈度的DDoS攻击时,即使我们的钱包资金是安全的,Firestore的账单也可能会爆炸。此外,每次对Firestore的往返都会增加几十到上百毫秒的延迟,影响用户体验。

引入项目级工具链Rome:保证代码质量与一致性

在解决性能和成本问题之前,我们先来解决一个工程实践问题。随着项目变得复杂(比如我们添加了前端代码、更多的API函数、共享的库),代码库的管理开始变得混乱。我们需要统一的格式化、Lint规则和代码检查。

传统的做法是组合使用ESLint, Prettier, Stylelint等多个工具,每个工具都有自己的配置文件和插件生态,维护起来很麻烦。Rome提供了一个一体化的解决方案。它是一个用Rust编写的高性能工具链,一个依赖、一个配置文件就能搞定格式化、Linting、编译等所有事情。

我们在项目根目录设置pnpm-workspace.yaml来管理monorepo,并安装Rome。

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

项目结构:

/
├── apps/
│   └── web/          # Next.js前端应用,包含我们的API
├── packages/
│   ├── ui/           # 共享React组件
│   └── config/       # 共享配置 (e.g., tsconfig)
├── pnpm-workspace.yaml
├── package.json
└── rome.json         # Rome的唯一配置文件

rome.json的配置异常简洁:

{
  "$schema": "./node_modules/rome/configuration_schema.json",
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true
    }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentSize": 2,
    "lineWidth": 80
  },
  "javascript": {
      "formatter": {
          "quoteStyle": "single"
      }
  }
}

现在,我们可以在CI/CD流程中加入一个简单的命令来检查整个代码库的质量:

pnpm rome check .

在提交前,可以用 pnpm rome format --write .pnpm rome check --apply-unsafe . 自动修复大部分问题。这种单一工具链带来的开发体验提升是巨大的,它让我们能更专注于业务逻辑,而不是在各种工具的配置泥潭里挣扎。

架构优化:多层防御与成本控制

回到性能和成本问题。我们需要一种比Firestore更廉价、更快速的方式来过滤掉大部分恶意流量。一个典型的多层防御策略如下:

  1. Vercel Edge Middleware: 在请求到达Node.js函数之前,在边缘网络上执行的轻量级代码。它非常适合执行IP黑名单检查或使用Vercel KV(一个基于Redis的键值存储)进行初步的速率限制。KV的读写延迟极低(个位数毫秒),成本也远低于Firestore。
  2. 函数内内存缓存: 对于单个函数实例,它可以处理来自同一IP的短时高频请求。Vercel会为活跃的函数保持“温实例”,这些实例的内存状态得以保留。

让我们用函数内内存缓存来优化现有的Node.js函数。这个改动成本最低,且效果显著。

import LRUCache from 'lru-cache';

// ... 其他import ...

// 在函数外部初始化,实例级别共享
// 缓存10000个IP,最长10分钟
const options = { max: 10000, ttl: 10 * 60 * 1000 };
const ipCache = new LRUCache<string, number>(options);

// ...

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    // ... 获取IP地址 ...
    const clientIp = getClientIp(req);

    // 第一层防御:函数内内存缓存检查
    const cachedCount = ipCache.get(clientIp) || 0;
    if (cachedCount >= RATE_LIMIT_COUNT) {
        console.warn(`Rate limit exceeded for IP (in-memory cache): ${clientIp}`);
        return res.status(429).json({ error: 'Too Many Requests' });
    }

    // 第二层防御:Firestore检查
    const ipDocId = clientIp.replace(/\./g, '_');
    const rateLimitRef = db.collection('rateLimits').doc(ipDocId);

    try {
        await db.runTransaction(async (transaction) => {
            // ... Firestore事务逻辑,与v2版本相同 ...
        });
    } catch (error: any) {
        // ... Firestore错误处理 ...
    }

    // 如果通过了所有检查,更新内存缓存
    ipCache.set(clientIp, cachedCount + 1);

    // ... 发送交易逻辑 ...
}

这个简单的LRU缓存可以吸收大量的重复请求。例如,一个攻击者从同一个IP在一秒内发送100个请求。第一个请求会穿透到Firestore,但接下来的99个请求都会被内存缓存直接拦截,不会产生任何数据库读写。这极大地降低了成本并保护了下游服务。

下面是整个请求流程的架构图:

sequenceDiagram
    participant User
    participant VercelEdge as Vercel Edge
    participant VercelFunc as Vercel Function (Node.js)
    participant InMemoryCache as In-Memory Cache
    participant FirestoreDB as Firestore
    participant BaseNode as BASE RPC Node

    User->>VercelEdge: POST /api/relay-tx
    VercelEdge->>VercelFunc: Forward Request
    VercelFunc->>InMemoryCache: Check IP in LRU Cache
    alt Cache Hit & Limit Exceeded
        InMemoryCache-->>VercelFunc: Exceeded
        VercelFunc-->>User: 429 Too Many Requests
    else Cache Miss or Not Exceeded
        InMemoryCache-->>VercelFunc: OK
        VercelFunc->>FirestoreDB: Start Transaction (Read IP doc)
        FirestoreDB-->>VercelFunc: IP data or null
        alt Limit Exceeded in DB
            VercelFunc-->>User: 429 Too Many Requests
        else Limit OK
            VercelFunc->>FirestoreDB: Update/Set IP doc in Transaction
            FirestoreDB-->>VercelFunc: Transaction Commit OK
            VercelFunc->>VercelFunc: Update In-Memory Cache
            VercelFunc->>BaseNode: Sign and send transaction
            BaseNode-->>VercelFunc: Transaction Hash
            VercelFunc-->>User: 202 Accepted (txHash)
        end
    end

最终的思考与遗留问题

经过这一系列的演进,我们得到一个相对健壮的Serverless中继服务。它使用了多层防御策略来平衡安全性、成本和性能。Rome保证了整个项目的工程质量和可维护性。

然而,这个方案并非完美,依然存在一些需要权衡的局限性:

  1. IP地址的不可靠性: 基于IP的速率限制对于有经验的攻击者来说很容易通过代理或僵尸网络绕过。更可靠的方案是要求用户进行身份验证,例如通过连接钱包(Sign-In with Ethereum),然后将速率限制与用户的链上地址而不是IP地址绑定。这增加了前端的复杂性,但提供了更强的安全性。

  2. Firestore成本模型: 虽然我们通过缓存减轻了负载,但Firestore的成本模型对于写入密集型应用仍然需要密切关注。如果速率限制是唯一需要持久化的状态,Vercel KV或Upstash Redis这类专为低延迟、高吞吐键值存储设计的服务,在成本效益上可能优于Firestore。

  3. 冷启动延迟: Vercel Functions的冷启动问题依然存在。对于需要即时响应的中继服务,第一次请求的几百毫秒甚至几秒的延迟可能是无法接受的。Vercel提供了预置并发(Provisioned Concurrency)功能来解决这个问题,但这会带来固定的基础成本,违背了纯粹的“按需付费”模式。需要根据业务对延迟的容忍度来决定是否启用。

  4. 私钥管理: 将私钥直接存储在环境变量中虽然方便,但不是最安全的方式。在更严格的生产环境中,应使用专门的密钥管理服务(KMS),如Google Cloud KMS或HashiCorp Vault。通过这些服务,Vercel Function可以在运行时动态获取签名权限,而无需直接暴露私钥,这显著提升了安全性。


  目录