Vercel Edge Functions 与自托管 TiDB 集群的混合架构下实现全局低延迟数据访问


一个核心的技术矛盾摆在面前:如何为一个需要全球用户访问的数据密集型应用,同时提供极致的前端加载速度和极低的数据库读取延迟。应用前端与无状态API层部署在Vercel的全球边缘网络上是显而易见的选择,但数据本身呢?数据具有“引力”,它倾向于集中存储。如果数据库部署在单一区域,例如美东 us-east-1,那么亚太地区用户的每一次数据请求都必须穿越半个地球,几十到上百毫秒的网络延迟足以摧毁精心优化的前端体验。

传统的解决方案,如在多个云区域部署只读副本,会引入数据同步延迟和复杂的状态管理。而Vercel自家生态的存储方案(如Vercel Postgres)虽然集成度高,但在全球分布式事务和水平扩展能力上,面对特定场景时会遇到瓶颈。我们需要一个真正具备全球分布式能力的数据库,同时又希望保留对数据存储的完全控制权,以规避厂商锁定和优化成本。

这就是我们选择混合架构的原因:利用Vercel的Edge网络分发前端和无状态计算,同时通过Ansible在多个云厂商的虚拟机上自托管一个TiDB集群作为全球统一的数据底座。 这种架构的挑战在于粘合两个世界:无服务器的、短暂的计算环境和有状态的、长连接的分布式数据库。

架构决策:为何是这种组合?

在选择最终方案前,我们评估了另外两种路径。

方案A:完全Serverless化
使用Vercel搭配一个全球分布式的Serverless数据库(如FaunaDB或CockroachDB Serverless)。

  • 优势: 极简的运维,按需付费,与Vercel生态无缝集成。
  • 劣势: 对SQL的兼容性、事务模型和查询性能的控制力较弱。对于复杂的分析查询或需要精细调优的场景,Serverless数据库的黑盒特性会成为障碍。更重要的是,大规模使用下的成本模型可能变得不可预测,且数据主权完全交由第三方。

方案B:完全自托管
在多个区域自建Kubernetes集群,部署前端、后端服务和数据库。

  • 优势: 拥有对整个技术栈的终极控制权,可进行深度定制和优化。
  • 劣势: 运维成本极高。维护一个全球分布的K8s集群、配置CDN、处理边缘路由等工作本身就是一个巨大的工程。这与我们希望专注于业务逻辑的初衷相悖。

最终选择:混合架构
该架构旨在取长补短。Vercel负责它最擅长的部分:全球静态资源分发和边缘函数执行。我们则将复杂且需要精细控制的数据层掌握在自己手中。TiDB因其与MySQL协议兼容、原生支持水平扩展和分布式事务的特性,成为自托管数据库的首选。Ansible则作为自动化部署和配置管理的工具,极大地降低了维护这个分布式集群的复杂度。

graph TD
    subgraph "User Devices Globally"
        User1[用户 - 亚洲]
        User2[用户 - 欧洲]
        User3[用户 - 北美]
    end

    subgraph "Vercel Edge Network"
        Edge[Vercel CDN/Edge]
        Fn_Asia[Vercel Function - ap-east-1]
        Fn_EU[Vercel Function - fra1]
        Fn_US[Vercel Function - iad1]
    end

    subgraph "Self-Hosted Multi-Cloud TiDB Cluster (Managed by Ansible)"
        LB[HAProxy Load Balancer]
        TiDB_Asia[TiDB Server - AWS Singapore]
        TiDB_EU[TiDB Server - GCP Frankfurt]
        TiDB_US[TiDB Server - Azure Virginia]
        PD1[PD Server]
        PD2[PD Server]
        PD3[PD Server]
        TiKV1[TiKV Server - Region A]
        TiKV2[TiKV Server - Region B]
        TiKV3[TiKV Server - Region C]
    end

    User1 --> Edge
    User2 --> Edge
    User3 --> Edge

    Edge -->|Request| Fn_Asia
    Edge -->|Request| Fn_EU
    Edge -->|Request| Fn_US

    Fn_Asia -->|SQL Query| LB
    Fn_EU -->|SQL Query| LB
    Fn_US -->|SQL Query| LB

    LB --> TiDB_Asia
    LB --> TiDB_EU
    LB --> TiDB_US

    TiDB_Asia <--> PD1
    TiDB_EU <--> PD2
    TiDB_US <--> PD3

    TiDB_Asia <--> TiKV1
    TiDB_Asia <--> TiKV2
    TiDB_Asia <--> TiKV3

    TiDB_EU <--> TiKV1
    TiDB_EU <--> TiKV2
    TiDB_EU <--> TiKV3

    TiDB_US <--> TiKV1
    TiDB_US <--> TiKV2
    TiDB_US <--> TiKV3

这个架构的核心在于,用户的请求会被Vercel路由到最近的边缘函数。该函数再连接到我们自托管的TiDB集群的全局负载均衡器。TiDB内部通过Raft协议保证数据一致性,其计算层(TiDB Server)是无状态的,可以就近部署,而存储层(TiKV)则根据我们的策略进行数据分片和副本放置。

核心实现:用代码粘合边缘与中心

1. Ansible: 自动化部署高可用的TiDB集群

手动部署一个生产级的TiDB集群是极其繁琐且容易出错的。我们使用Ansible来标准化这个流程。这里的关键在于幂等性,无论执行多少次,集群都应收敛到预期的状态。

以下是一个简化的Ansible Playbook结构,用于在一个三节点的集群上部署TiDB核心组件。

inventory.ini 文件:

[tidb_servers]
192.168.1.10
192.168.1.11

[pd_servers]
192.168.1.10
192.168.1.11
192.168.1.12

[tikv_servers]
192.168.1.10
192.168.1.11
192.168.1.12

[monitoring_servers]
192.168.1.13

[all:vars]
ansible_user=admin
ansible_ssh_private_key_file=~/.ssh/id_rsa
# TiDB cluster version and deployment directory
tidb_version="v7.5.0"
deploy_dir="/opt/tidb-deploy"
data_dir="/opt/tidb-data"

deploy_tidb.yml Playbook:

- hosts: all
  become: true
  tasks:
    - name: Create system user for tidb
      user:
        name: tidb
        shell: /bin/bash
        state: present

    - name: Create deployment and data directories
      file:
        path: "{{ item }}"
        state: directory
        owner: tidb
        group: tidb
        mode: '0755'
      loop:
        - "{{ deploy_dir }}"
        - "{{ data_dir }}"

- hosts: pd_servers
  become: true
  tasks:
    - name: Deploy and configure PD server
      # 在真实项目中, 这里会使用 TiUP (TiDB's cluster manager)
      # 或者直接管理 systemd 服务
      block:
        - name: Download TiUP
          get_url:
            url: "http://tiup-mirrors.pingcap.com/tiup-v1.12.0-linux-amd64.tar.gz"
            dest: "/tmp/tiup.tar.gz"
        
        - name: Install TiUP
          shell: "tar -zxvf /tmp/tiup.tar.gz -C /usr/local/bin && tiup cluster"
          args:
            warn: false # tiup cluster will print info to stderr
        
        - name: Generate initial cluster configuration
          command: >
            su - tidb -c "tiup cluster deploy my-cluster {{ tidb_version }} ./topology.yaml --user root -i {{ ansible_ssh_private_key_file }}"
          # topology.yaml 是一个描述集群拓扑的详细文件, 这是一个生产实践的核心
          # 此处为简化示意, 真实项目中 topology.yaml 会通过 template 模块动态生成
          # 并且只会执行一次
          when: "'pd_servers[0]' == inventory_hostname"
          
- hosts: all
  become: true
  tasks:
    - name: Start the TiDB cluster
      # 确保只在主控节点执行一次
      command: su - tidb -c "tiup cluster start my-cluster"
      when: "'pd_servers[0]' == inventory_hostname"

一个常见的坑在于网络配置。Vercel Functions的出口IP是动态的,你不能简单地在数据库防火墙上设置一个白名单。生产环境中的解决方案是:

  1. 使用Vercel的“Secure Compute”功能获取静态出口IP。
  2. 在自托管的云上设置一个VPC,并在VPC中部署一个NAT网关或代理,Vercel函数通过这个代理访问数据库。
  3. 最直接但安全性稍差的方式是允许来自Vercel所有IP段的访问,但这需要严格的数据库层身份验证和TLS加密。我们选择方案2,通过一个HAProxy层来做负载均衡和访问控制。

2. Vercel Function: 优雅地处理数据库连接

Serverless环境的“无状态”和“短暂性”给数据库连接管理带来了巨大挑战。为每个请求都创建新的TCP连接到TiDB会产生巨大的开销,并可能迅速耗尽数据库的连接数。

我们需要一个在函数调用之间复用连接的策略。在Node.js环境中,可以将数据库连接实例或连接池实例缓存在模块的全局作用域中。

api/data/[id].ts 后端API:

import { NextApiRequest, NextApiResponse } from 'next';
import mysql from 'serverless-mysql';

// 全局变量, Vercel会为热函数复用这个实例
// 这里的配置是生产级的关键
let db_conn;

function getDbConnection() {
  if (db_conn && db_conn.quit) {
    // 检查连接是否仍然存活
    // serverless-mysql 内部会处理这个
    return db_conn;
  }

  console.log('Initializing new database connection pool...');
  db_conn = mysql({
    config: {
      host: process.env.TIDB_HOST,
      port: parseInt(process.env.TIDB_PORT || '4000', 10),
      database: process.env.TIDB_DATABASE,
      user: process.env.TIDB_USER,
      password: process.env.TIDB_PASSWORD,
      ssl: {
        // 在生产环境中强制使用TLS
        rejectUnauthorized: true,
        // ca: fs.readFileSync('path/to/ca.pem').toString(),
      },
    },
    // serverless-mysql 的关键配置
    library: require('mysql2'), // 使用性能更好的 mysql2 驱动
    maxRetries: 3, // 连接失败时的重试次数
    connTimeout: 5000, // 连接超时时间
    onConnect: (conn) => {
      // 可以在每次获取连接时设置会话变量
      console.log('Database connection established.');
      conn.query("SET session tidb_snapshot = @@tidb_snapshot;");
    },
    onClose: () => {
        console.log('Database connection closed.');
    }
  });
  return db_conn;
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { id } = req.query;

  if (req.method !== 'GET') {
    return res.status(405).json({ message: 'Method Not Allowed' });
  }

  try {
    const db = getDbConnection();
    
    // 开启TiDB的Stale Read功能,从就近的副本读取几秒前的历史数据
    // 这对于对实时性要求不高的读取请求,可以显著降低延迟和TiKV的压力
    // 这是TiDB相比传统MySQL在分布式场景下的巨大优势
    await db.query('SET @@session.tidb_read_staleness = -5'); // Read data from 5 seconds ago

    const results = await db.query(
      'SELECT id, content, author, created_at FROM articles WHERE id = ? LIMIT 1',
      [id]
    );

    // 确保在函数执行完毕前,显式结束连接(归还到池中)
    // serverless-mysql 会自动处理这个,但显式调用是好习惯
    await db.end();

    if (results && results.length > 0) {
      res.status(200).json(results[0]);
    } else {
      res.status(404).json({ message: 'Article not found' });
    }
  } catch (error) {
    console.error('Database query failed:', error);
    // 隐藏具体的数据库错误信息,防止信息泄露
    res.status(500).json({ message: 'An internal server error occurred.' });
  }
}

这段代码的核心思想是:

  1. 连接池复用: db_conn 定义在handler外部,利用了Node.js模块缓存机制。只要Vercel的执行环境(热函数)不被回收,后续请求就会复用已初始化的连接池。
  2. 生产级配置: 使用 serverless-mysql 库,它专门为Serverless环境优化了连接管理,包括自动重连、超时处理等。
  3. 利用TiDB特性: SET @@session.tidb_read_staleness = -5 是一个强大的优化。它告诉TiDB,这个查询不要求最新的数据,可以从任何一个副本读取5秒前的数据。这使得TiDB可以智能地选择延迟最低的副本提供服务,极大提升了全球用户的读取性能。
  4. 健壮的错误处理: 捕获数据库异常,并返回通用的500错误,同时在服务端记录详细日志。

3. Jotai: 在前端管理分布式状态

前端的状态管理也变得更加复杂。由于后端数据是全球分布的,不同用户观察到的数据状态可能存在微小的延迟。 optimistic UI(乐观更新)和对数据最终一致性的处理变得至关重要。Jotai的原子化和异步特性使其非常适合处理这种复杂性。

我们定义一个atom来获取文章数据,并利用Jotai的集成来处理加载和错误状态。

store/articleAtoms.ts:

import { atom } from 'jotai';
import { loadable } from 'jotai/utils';

// 基础原子, 存储当前要查看的文章ID
export const articleIdAtom = atom<string | null>(null);

// 派生原子, 异步获取文章数据
// 这是核心, 它依赖于 articleIdAtom
const articleDataFetchAtom = atom(async (get) => {
  const id = get(articleIdAtom);
  if (!id) {
    return null;
  }
  
  // 模拟一个请求
  const response = await fetch(`/api/data/${id}`);

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error('NotFound');
    }
    throw new Error('Failed to fetch article data');
  }

  return response.json();
});

// 使用Jotai的 'loadable' 工具, 优雅地处理异步状态
// 这个原子不会抛出异常, 而是将状态包装成 { state: 'loading' | 'hasData' | 'hasError', data?, error? }
export const articleAtom = loadable(articleDataFetchAtom);

在React组件中使用:

import { useAtom, useSetAtom } from 'jotai';
import { articleIdAtom, articleAtom } from '../store/articleAtoms';
import { useEffect } from 'react';

const ArticleViewer = ({ initialId }) => {
  const setArticleId = useSetAtom(articleIdAtom);
  const [articleState] = useAtom(articleAtom);

  // 组件加载时设置初始ID
  useEffect(() => {
    setArticleId(initialId);
  }, [initialId, setArticleId]);

  switch (articleState.state) {
    case 'loading':
      return <div>Loading article...</div>;
    case 'hasError':
      // 这里的错误处理可以更精细
      if (articleState.error.message === 'NotFound') {
        return <div>Article not found.</div>;
      }
      return <div>Error loading article. Please try again.</div>;
    case 'hasData':
      const article = articleState.data;
      if (!article) {
        return <div>Select an article to view.</div>;
      }
      return (
        <article>
          <h1>{article.title}</h1>
          <p>By {article.author}</p>
          <hr />
          <div>{article.content}</div>
        </article>
      );
    default:
      return <div>Select an article to view.</div>;
  }
};

Jotai的优势在于其细粒度。articleIdAtom的变化只会触发依赖它的articleDataFetchAtom重新计算,而不会导致整个应用的不必要重渲染。loadable工具更是将异步逻辑的模板代码完全封装,让组件代码专注于UI呈现,这在处理来自复杂后端的状态时尤为重要。

架构的局限性与未来迭代方向

这种混合架构虽然解决了核心问题,但也引入了新的复杂性。首先,运维边界变得模糊。前端团队和后端/SRE团队需要紧密协作,因为Vercel Function的性能直接受到自托管数据库健康状况的影响。网络延迟是物理定律,虽然通过Stale Read可以缓解读延迟,但写操作仍然需要通过Raft协议在多个数据中心间同步,写延迟是必须接受的权衡。

其次,成本模型变得复杂。我们需要同时核算Vercel的函数调用、执行时长费用,以及多个云厂商的虚拟机、存储和跨区域数据传输费用。精确的成本归因和优化需要更完善的可观测性体系。

未来的优化路径可以集中在以下几点:

  1. 数据放置策略: 深度利用TiDB的Placement Rules功能,将与特定区域用户强相关的数据(例如用户个人资料)的Raft Leader和副本优先放置在该区域,实现数据层面的“就近访问”。
  2. 连接优化: 探索在Vercel边缘函数和TiDB集群之间引入一个轻量级的、全球分布的代理层(如envoy),专门负责连接池管理和智能路由,进一步降低冷启动时的连接建立开销。
  3. CQRS模式引入: 对于写密集型应用,可以考虑引入命令查询职责分离(CQRS)模式。写操作通过消息队列异步处理,而Vercel函数只负责读取经过物化的视图,从而实现极致的读写分离和性能。

  目录