一个核心的技术矛盾摆在面前:如何为一个需要全球用户访问的数据密集型应用,同时提供极致的前端加载速度和极低的数据库读取延迟。应用前端与无状态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是动态的,你不能简单地在数据库防火墙上设置一个白名单。生产环境中的解决方案是:
- 使用Vercel的“Secure Compute”功能获取静态出口IP。
- 在自托管的云上设置一个VPC,并在VPC中部署一个NAT网关或代理,Vercel函数通过这个代理访问数据库。
- 最直接但安全性稍差的方式是允许来自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.' });
}
}
这段代码的核心思想是:
- 连接池复用:
db_conn定义在handler外部,利用了Node.js模块缓存机制。只要Vercel的执行环境(热函数)不被回收,后续请求就会复用已初始化的连接池。 - 生产级配置: 使用
serverless-mysql库,它专门为Serverless环境优化了连接管理,包括自动重连、超时处理等。 - 利用TiDB特性:
SET @@session.tidb_read_staleness = -5是一个强大的优化。它告诉TiDB,这个查询不要求最新的数据,可以从任何一个副本读取5秒前的数据。这使得TiDB可以智能地选择延迟最低的副本提供服务,极大提升了全球用户的读取性能。 - 健壮的错误处理: 捕获数据库异常,并返回通用的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的函数调用、执行时长费用,以及多个云厂商的虚拟机、存储和跨区域数据传输费用。精确的成本归因和优化需要更完善的可观测性体系。
未来的优化路径可以集中在以下几点:
- 数据放置策略: 深度利用TiDB的Placement Rules功能,将与特定区域用户强相关的数据(例如用户个人资料)的Raft Leader和副本优先放置在该区域,实现数据层面的“就近访问”。
- 连接优化: 探索在Vercel边缘函数和TiDB集群之间引入一个轻量级的、全球分布的代理层(如envoy),专门负责连接池管理和智能路由,进一步降低冷启动时的连接建立开销。
- CQRS模式引入: 对于写密集型应用,可以考虑引入命令查询职责分离(CQRS)模式。写操作通过消息队列异步处理,而Vercel函数只负责读取经过物化的视图,从而实现极致的读写分离和性能。