去年我接手了一个用 Express 写的订单 API,代码量不算大,但每次改动都像在拆雷:package.json 里躺着 60 多个直接依赖,TypeScript 配置和转译配置叠了三层,升级一个底层库要连带排查四五个中间件。最让我难受的是权限模型约等于没有,node_modules 里任何一行代码都能访问文件系统和网络,安全全靠自觉。那次重构我抱着试试看的心态用了 Deno,数据库也顺手从单机 PostgreSQL 换成了完全兼容 PG 协议的 YugabyteDB,结果这套组合把我对 JavaScript API 的认知直接刷新了一遍。
这篇文章会完整讲清楚我为什么这样选型、如何一步步写出可运行的 API、数据库层的分布式设计怎么做,以及部署和踩坑的细节。适合正在维护 Node API、动了换引擎心思的团队,也适合对分布式数据库感兴趣但不想换语言的前端和全栈开发者。即使你目前还在用 Express,前面讲到的很多理念也能直接迁移过去。
1. 为什么把 API 从 Node 迁到 Deno:一次选型复盘
先说结论:Deno 不是 Node 的简单替代品,它对 JavaScript 运行时做了一次面向云原生时代的重新定义。而 YugabyteDB 解决的是数据库在数据量涨上去之后"怎么继续优雅扩展"的问题。这两者组合在一起,带来的是一种更省心的 API 生产方式。
1.1 我在 Node.js API 上踩过的三组坑
第一组坑是依赖地狱。Express 项目跑久了,直接依赖和传递依赖加起来动辄几百个包,每次 npm install 都要看运气。某次升级 express-validator 从 6.x 到 7.x,内部报错和我一丁点关系都没有,仅仅因为某个中间件只适配了旧版本,整个 CI 就挂了。排错花了两天,最后结论是"先锁版本,等上游适配"。
第二组坑是 TypeScript 配置链太长。tsconfig.json、Babel 配置、ts-node-dev、ESLint 的 TS 插件,一环扣一环。团队新同学入职第一周,光是把开发环境跑起来就要处理各种版本匹配问题。这些其实都不是业务问题,但每天都在消耗精力。
第三组坑是权限。Node 的设计哲学是"运行时给足权限,由应用自己负责边界",这在浏览器里没问题,但在服务端会放大供应链攻击的风险。只要某个依赖被污染,它可以静默读取你的 /etc/shadow 或者向外部发送环境变量。而 Deno 从架构上把这个问题堵死了:没显式授权的能力,代码就是不能用。
1.2 Deno 给 JavaScript API 带来的现代化能力
Deno 的几个特性,刚好打在 Node 的痛点上。
第一是原生 TypeScript。Deno 内置了类型检查和转译,不需要 ts-node,不需要 Babel,不需要 tsc 构建链。你写的 .ts 文件直接能跑,类型检查可以单独用 deno check 在 CI 里做。
第二是权限安全模型。启动一个脚本时,必须显式声明 --allow-net、--allow-env、--allow-read 等权限,默认全部拒绝。这非常像 Android 的运行时权限:代码可以访问什么,由使用者说了算。
第三是标准库完善。Deno 官方标准库提供了 HTTP 服务器、URL 解析、文件操作、测试框架等基础能力,用不到第三方依赖就能把 API 写出来。Deno.serve 这个原生 API 性能不错,做 REST 服务完全够用。它不像 Express 那样需要先 npm install 一堆东西才能动弹。
第四是模块分发现代化。Deno 可以直接通过 URL 引用代码,也支持 jsr.io 这种原生 TypeScript 包仓库。去掉 node_modules 之后,项目结构清爽太多。依赖统一在 deno.json 的 imports 字段里管理,一眼能看完。
1.3 YugabyteDB:为什么选兼容 PostgreSQL 的分布式数据库
数据库层我原本用的是单机 PostgreSQL,说实话用着挺好,SQL 标准、事务、生态都满意。但业务数据量涨到几十 GB 之后,我开始担心几件事:磁盘满怎么办?主库挂了恢复要多长时间?读写压力上来后怎么扩展?传统思路是上读写分离、分区、中间件,那一套的运维成本对小团队来说并不低。
YugabyteDB 吸引我的是它把"分布式"这件事做成了默认能力,同时对外保持 PostgreSQL 兼容。它的 YSQL 接口直接使用 PostgreSQL 的查询层和线协议,意味着现成的 pg 驱动、ORM、迁移工具都能用,业务代码几乎不用为"分布式"三个字改写。
它底层用 Raft 共识协议做多副本复制,节点故障时自动选主,不需要人为介入。数据按主键自动分片到多个 tablet,节点不够的时候加节点就能水平扩展。这些能力如果自己在单机 PG 上搭,工程量会非常可观。
和单机 PostgreSQL 对比,大家态度通常是这样的:
| 对比维度 | 单机 PostgreSQL | YugabyteDB |
|---|---|---|
| 扩展方式 | 主从复制、读写分离,节点写入能力有限 | 水平分片,加节点即可扩展写入能力 |
| 故障恢复 | 通过 Patroni 等外部工具,运维复杂 | Raft 自动选主,节点故障自动恢复 |
| 驱动兼容 | PostgreSQL 生态 | 完全复用 PostgreSQL 生态 |
| 运维成本 | 低数据量下较低 | 自带管理界面和云托管,中规中矩 |
| 适用规模 | 适合百 GB 以下的单体业务 | 适合需要跨区容灾、数据增长快的业务 |
当然,如果你的数据量一直不大、也没有高可用要求,单机 PG 完全够用,没必要为了分布式而分布式。但如果你在做 SaaS 产品、API 服务,又要考虑未来三五年增长,那用 YugabyteDB 可以让数据库层"晚一点成为瓶颈"。
2. 搭建 Deno 项目骨架与 YugabyteDB 连接层
选型归选型,真正动手的时候要解决的问题都很具体:Deno 怎么装,项目结构怎么摆,数据库连接串怎么写,连接池怎么管。这一章是完整可复现的部分。
2.1 环境准备与项目初始化
安装 Deno 用官方脚本,macOS 和 Linux 执行:
curl -fsSL https://deno.land/install.sh | shWindows 用户用 PowerShell 或者 Scoop:
scoop install deno # 或者 irm https://deno.land/install.ps1 | iex装完验证一下版本,我当时用的是 1.4x 以上,现在建议直接装 Deno 2.x,API 变化不大,长期维护更有利:
deno --version然后创建一个目录,把项目结构先定下来:
api/ deno.json main.ts config.ts db.ts handlers/ books.ts models/ book.ts middleware/ errorHandler.ts logger.ts cors.ts tests/ api_test.ts .env这个结构参考了常见的分层:main.ts 只负责启动 HTTP 服务和组装路由,handlers 处理请求响应,middleware 处理横切关注点,db.ts 管理数据库连接池,config.ts 统一读取配置。小项目这样分层不显冗余,后期加功能也找得到地方。
deno.json 是 Deno 的配置文件,我一般这样写:
{ "tasks": { "dev": "deno run --allow-net --allow-env --allow-read=.env --env-file=.env --watch main.ts", "start": "deno run --allow-net --allow-env --allow-read=.env --env-file=.env main.ts", "test": "deno test --allow-net --allow-env --allow-read=.env --env-file=.env" }, "imports": { "postgres": "https://deno.land/x/postgres@v0.19.3/mod.ts", "zod": "https://deno.land/x/zod@v3.23.8/mod.ts" } }这里有几个细节值得说一下。--env-file=.env 让 Deno 自动读取 .env 文件注入环境变量,不用自己在代码里解析。--allow-read=.env 只放行读取 .env,比直接 --allow-read 只读整个文件系统要收敛。开发时加 --watch 会热重启,体验和 nodemon 差不多。
2.2 连接 YugabyteDB:端口、SSL 与连接串
YugabyteDB 有多个接口,最容易踩坑的是端口:PostgreSQL 兼容接口 YSQL 的默认端口是 5433,不是 5432。YCQL(Cassandra 兼容)是 9042,Redis 兼容是 6379。很多人第一次连不上,就是下意识用了 5432。
本地快速起一个 YugabyteDB 实例,用 Docker 最省事:
docker run -d --name yugabytedb \ -p 5433:5433 \ -p 9042:9042 \ -p 15433:15433 \ yugabytedb/yugabyte:2024.2.0.0启动后,YSQL 的连接串是:
postgres://yugabyte:yugabyte@localhost:5433/yugabyte如果你用的是云托管的 YugabyteDB,连接串里通常还要带 sslmode 参数,类似这样:
postgresql://admin:密码@节点地址:5433/yugabyte?sslmode=require我把连接串放到项目根目录的 .env 里:
DATABASE_URL=postgres://yugabyte:yugabyte@localhost:5433/yugabyte DB_POOL_SIZE=10 PORT=8000config.ts 里统一读取:
export const DATABASE_URL = Deno.env.get("DATABASE_URL")!; export const DB_POOL_SIZE = Number(Deno.env.get("DB_POOL_SIZE") ?? "10"); export const PORT = Number(Deno.env.get("PORT") ?? "8000");这里用 ! 是告诉 TypeScript 环境变量一定会存在,如果缺失尽早报错,而不是带病运行。
2.3 连接池与请求生命周期管理
连接数据库最大的注意点就是连接要复用,不能每个请求都新建连接。新连接握手过程开销不小,高并发下会直接把数据库打垮。我使用的是 deno postgres 驱动里的 Pool。
db.ts:
import { Pool } from "postgres"; import { DATABASE_URL, DB_POOL_SIZE } from "./config.ts"; export const pool = new Pool(DATABASE_URL, DB_POOL_SIZE); export async function withClient<T>( fn: (client: Awaited<ReturnType<typeof pool.connect>>) => Promise<T>, ): Promise<T> { const client = await pool.connect(); try { return await fn(client); } finally { client.release(); } }withClient 这个封装很值得养成习惯。它保证无论查询成功还是抛出异常,连接都能回到池子里。很多线上事故都是因为忘了在 finally 里 release,连接被一个个占死。
使用示例:
export async function findBooks() { return withClient(async (client) => { const result = await client.queryObject( `SELECT id, title, author, price, created_at FROM books ORDER BY created_at DESC LIMIT 100`, ); return result.rows; }); }注意 queryObject 返回的对象是行对象数组,直接可以用 result.rows 拿数据。老版本的 query 接口返回的是 Row 数组,字段要用 index 访问,改起来很别扭。如果你用的是 0.19.x 之后的驱动,推荐直接用 queryObject。
连接池大小也不是越大越好。每个连接在数据库端都要占用一个后端进程资源,连接数翻倍不等于性能翻倍,反而可能加剧锁竞争。起步设 10 就够了,后期根据 P99 延迟再调。
3. 用原生 TypeScript 实现 REST 路由与请求校验
数据库连上了,接下来就是 API 本身。我不打算引入 Express 或 Fastify 这类框架,而是直接用 Deno 原生的 HTTP 能力实现,这样依赖面最小、权限问题最少,也最容易看明白整个请求生命周期。
3.1 用标准库 serve,还是上框架
Deno 生态里有几个选择:直接用 Deno.serve,或者用 Hono、Oak、Fresh 这类框架。我最终用 Deno.serve 加 URLPattern 手写路由,理由是它们能覆盖 90% 的 CRUD 场景,而且不引第三方代码。Deno.serve 是原生实现,性能很不错,底层的 HTTP 实现是经过生产验证的。
什么样的项目该考虑框架?如果你需要大量现成中间件、复杂的路由嵌套,或者团队已经非常熟悉某个框架,那用 Hono 更高效。它和 Express 中间件风格很像,迁移成本低。但如果你是给团队做一个长期维护的内部 API,我的经验是少依赖一个框架就少一份升级负担。框架本身没有问题,问题是它会周期性地提醒你又该升版本了。
3.2 路由与 CRUD 核心实现
我定义了两组路由:一组处理集合,一组处理单条资源。main.ts 大致长这样:
import { serve } from "https://deno.land/std@0.224.0/http/server.ts"; import { listBooks, createBook } from "./handlers/books.ts"; import { getBook, updateBook, deleteBook } from "./handlers/books.ts"; import { withErrorHandler } from "./middleware/errorHandler.ts"; import { withLogger } from "./middleware/logger.ts"; import { withCors } from "./middleware/cors.ts"; const booksPattern = new URLPattern({ pathname: "/api/books" }); const bookPattern = new URLPattern({ pathname: "/api/books/:id" }); Deno.serve(async (req) => { return withErrorHandler(withLogger(withCors(async (req) => { const url = new URL(req.url); if (booksPattern.test(url)) { if (req.method === "GET") return await listBooks(req); if (req.method === "POST") return await createBook(req); } if (bookPattern.test(url)) { if (req.method === "GET") return await getBook(req, url); if (req.method === "PUT") return await updateBook(req, url); if (req.method === "DELETE") return await deleteBook(req, url); } return Response.json({ error: "Not Found" }, { status: 404 }); }))); });URLPattern 是浏览器标准 API,Deno 直接内置了,不用额外安装。它从 pathname 里提取 :id 参数比正则表达式直观得多。withCors、withLogger、withErrorHandler 都是自己写的中间件,本质上就是把真正处理请求的函数包一层,这也是函数式中间件最简单的形态。
处理单本书的逻辑:
export async function getBook(req: Request, url: URL): Promise<Response> { const match = new URLPattern({ pathname: "/api/books/:id" }).exec(url); const id = match?.pathname.groups.id; if (!id) { return Response.json({ error: "Invalid book id" }, { status: 400 }); } const book = await findBookById(id); if (!book) { return Response.json({ error: "Book not found" }, { status: 404 }); } return Response.json(book); }这里要注意,URLPattern 每次 new 一个是有开销的,所以正式代码应该把 pattern 提到模块顶层复用,示例里为了可读性写得随意了些。我实际项目中会把所有路由 pattern 集中在一个 router.ts 里,而不是每次请求 new。
3.3 请求校验:别把前端的不信任数据直接丢给数据库
写 API 最基本的原则是"永远不信任入站请求体"。之前我有一次对接前端时,对方传的 createdAt 字段类型不对,请求到达数据库驱动后直接报了一个类似400 invalid schema for function的错。这类错误表述往往非常底层,调用方根本看不懂,而且问题是出在参数校验阶段,却要查数据库日志才能定位。
所以我在所有修改类接口里都接了 zod 做校验。zod 是一个运行时校验库,它能在请求体解析阶段就把数据类型卡死,让非法数据根本走不到 SQL 层。定义 Book 的写模型:
import { z } from "zod"; export const createBookSchema = z.object({ title: z.string().min(1).max(200), author: z.string().min(1).max(100), price: z.number().positive().optional(), tags: z.array(z.string()).max(10).optional(), }); export type CreateBookInput = z.infer<typeof createBookSchema>;在 handler 里:
export async function createBook(req: Request): Promise<Response> { let body: unknown; try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); } const parsed = createBookSchema.safeParse(body); if (!parsed.success) { return Response.json({ error: "Validation failed", fields: parsed.error.flatten().fieldErrors, }, { status: 422 }); } const book = await insertBook(parsed.data); return Response.json(book, { status: 201 }); }safeParse 比 try/catch parse 更温和,它能一次性把所有字段错误收集起来返回给调用方。用过之后你会发现,前端联调的争吵少了一大半:不是你后端玄学报错,而是字段规则清清楚楚写在响应里。
3.4 日志、CORS 与统一错误响应
中间件我写了三种。日志中间件负责记录每个请求的方法、路径、状态码、耗时,并给请求生成一个 requestId,贯穿到数据库查询日志里,对排查线上问题非常关键:
export async function withLogger(req: Request, next: (req: Request) => Promise<Response>): Promise<Response> { const start = performance.now(); const requestId = crypto.randomUUID(); const res = await next(req); const duration = (performance.now() - start).toFixed(2); console.log(`${req.method} ${new URL(req.url).pathname} ${res.status} ${duration}ms ${requestId}`); return res; }CORS 中间件要处理 OPTIONS 预检请求,不要把它漏掉,否则前端浏览器跨域调用会莫名其妙失败:
const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }; export async function withCors(req: Request, next: (req: Request) => Promise<Response>): Promise<Response> { if (req.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders }); } const res = await next(req); const wrapped = new Response(res.body, res); for (const [k, v] of Object.entries(corsHeaders)) { wrapped.headers.set(k, v); } return wrapped; }错误处理中间件包住整个路由,任何 handler 抛出的异常都会转成统一 JSON,避免栈信息直接暴露:
export async function withErrorHandler(req: Request, next: (req: Request) => Promise<Response>): Promise<Response> { try { return await next(req); } catch (err) { console.error("unhandled error:", err); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } }这一套组合下来,API 的行为是可预测的:成功有成功的数据结构,失败有失败的数据结构,调用方可以通过 error 字段判断问题方向,而不是对接一个又一个零散报错。
4. 数据层设计:从 PostgreSQL 兼容性到分布式表策略
API 的复杂性最终都会沉淀到数据层。YugabyteDB 最大的好处是你依然能用纯 SQL 思考问题,但它毕竟是分布式数据库,建表的时候需要多考虑一层"数据怎么分布"。
4.1 Schema 设计与建表语句
我用 ysqlsh 连接数据库执行建表语句。ysqlsh 相当于 psql,用法一致:
docker exec -it yugabytedb bin/ysqlsh -h localhost -p 5433 -U yugabyte -d yugabyte建一张 books 表:
CREATE TABLE books ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, title TEXT NOT NULL, author TEXT NOT NULL, price NUMERIC(10, 2), tags TEXT[], created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );几个设计选择说明一下:
id 用 UUID 而不是自增 bigint。分布式环境下,自增主键会变成全局热点,所有插入都落在最后一个 tablet 上。UUID 随机分布,写压力能均匀散到各个分片。
TEXT[] 数组类型是 PostgreSQL 的特色,YugabyteDB 同样支持。如果标签只在展示层用,数组比关联表轻量得多,不必任何多值都建一张子表。等以后真的需要按标签复杂筛选时再提成关联表也不迟。
TIMESTAMPTZ 是带时区的时间类型,存储的是 UTC 绝对时间,客户端读取时按自己的时区展示。这一点对分布式部署格外重要,节点可能分布在不同区域,必须统一用绝对时间。
4.2 分布式表、分片与主键设计
YugabyteDB 默认会把表按主键做 Hash 分片,分成多个 tablet 分散在不同节点上。所以你不需要像传统中间件那样自己指定分片键,但主键设计直接决定了查询能不能落在少数 tablet 上。
写这条 SQL 时我显式指定了分片数量:
ALTER TABLE books SPLIT INTO 8 TABLETS;8 个 tablet 对多数业务足够了。tablet 数量不是越多越好,因为每个 tablet 都有副本和后台任务,小表分几十个 tablet 纯属浪费资源。如果表很小、业务也不大,甚至可以不拆,YugabyteDB 的 colocated 表模式能节省资源。
主键设计上要抓住一个核心原则:等值查询条件尽量匹配主键的最左列。比如你要频繁按 author 查书,那应该把 author 作为主键的一部分,或者单独建索引。在分布式环境里,一个跨 tablet 的全表扫描可比单机库里慢一个量级,所以查询模式要在建表前想清楚。
索引同样遵循这个逻辑。我想支持按 title 模糊搜索,但这在分布式库里做 LIKE '%xxx%' 会很吃力,我选择先做一个简单的 trigram 索引,后面数据量大再考虑全文检索方案。建一个降序索引方便按时间排序分页:
CREATE INDEX idx_books_created_at ON books (created_at DESC);4.3 事务与并发控制
事务在 YugabyteDB 里是完全支持的,可以放心用 ACID 事务。但我必须强调一个坑:事务必须保持在同一个数据库连接上执行。很多新手在单机库里习惯用类似 pool.query 的便捷函数连续执行多条 SQL,这在连接池模式下每条语句可能走了不同连接,事务根本建立不起来。
正确写法:
export async function createBookWithAudit(book: CreateBookInput, operatorId: string) { return withClient(async (client) => { await client.queryObject("BEGIN"); try { await client.queryObject( `INSERT INTO books (title, author, price, tags) VALUES ($1, $2, $3, $4) RETURNING id`, book.title, book.author, book.price ?? null, book.tags ?? null, ); await client.queryObject( `INSERT INTO audit_log (entity, operation, operator_id) VALUES ('book', 'CREATE', $1)`, operatorId, ); await client.queryObject("COMMIT"); } catch (err) { await client.queryObject("ROLLBACK"); throw err; } }); }所有语句都在 withClient 拿到的同一个 client 上执行,事务边界才有效。
另外一个常见问题是并发冲突。分布式数据库发生事务冲突的概率比单机高,因为数据副本之间存在同步延迟。YugabyteDB 发生冲突时会返回 PostgreSQL 风格的 40001 序列化失败错误码,业务上要做重试。重试逻辑不能写得太激进,我一般做成最多 3 次、指数退避,第一次等 20ms,第二次等 50ms:
const MAX_RETRY = 3; for (let attempt = 1; attempt <= MAX_RETRY; attempt++) { try { return await createBookWithAudit(data, operatorId); } catch (err) { if (err?.code === "40001" && attempt < MAX_RETRY) { await delay(20 * attempt * attempt); continue; } throw err; } }这段重试逻辑看起来简单,但在真实并发场景下能挡掉很多偶发失败。错误信息里如果看到 40001,不用慌,这是分布式事务的正常现象,关键是应用层怎么优雅处理。
4.4 从旧 API 迁移数据与代码重构思路
如果你像我一样是从 Node + Express + pg 的老项目迁移过来,路径会比想象中顺畅。YugabyteDB 的 YSQL 兼容 PostgreSQL,你原来写的 SQL 大部分可以原样执行。真正要改的是代码层。
老的 pg 驱动里,查询结果集要自己处理 RowDataPacket 之类的东西,而 deno postgres 的 queryObject 直接返回对象数组,字段名就是列名,序列化成本低很多。在迁移时我做到"先通后优":先把原接口的所有 SQL 原封不动跑通,让响应结构和原来一致;再逐条优化变成分布式友好的写法。
数据迁移我用的是 pg_dump 大约十万行的表,先在旧库里导出为自定义格式,再用 ysqlsh 导入。这个操作对数据量不大(几十 GB 以内)的库完全可行,而且不需要停机太久。数据导入完成后,跑一遍对账脚本,逐表比对行数和关键字段,确认无差异再切流量。
有个容易忽略的点是 NUMERIC 类型的序列化。PostgreSQL 驱动在查询 NUMERIC 时常返回字符串,避免 JavaScript 大数精度丢失。这意味着旧 API 的响应里 price 可能曾经是个数字,现在可能是字符串。如果前端有强类型校验,这个细节会导致字段类型变更,迁移时要主动统一处理:要么在 SQL 里 cast,要么在序列化层转数字。
5. 测试、部署与生产环境踩坑记录
代码写完只是开始,真正考验人的是如何保证它上线后稳定。这一章讲自动化测试、Docker 部署,以及我在这个组合上踩过的几个真坑。
5.1 Deno 内置测试框架与集成测试
Deno 自带测试框架,和 Jest、Vitest 那套比,胜在零依赖、无配置。我用 Deno.test 写了一组集成测试,直接拉起服务,对真实数据库发请求验证完整链路。
import { assertEquals, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; Deno.test("POST /api/books creates a book", async () => { const res = await fetch("http://localhost:8000/api/books", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "Deno in Action", author: "Alice", price: 59.9, tags: ["deno", "api"], }), }); assertEquals(res.status, 201); const data = await res.json(); assert(data.id); assertEquals(data.title, "Deno in Action"); });跑测试用 deno task test。测试之间要注意数据隔离,我一般每个测试开头先 truncate 相关表,避免用例互相影响。如果是更重的测试,可以把测试数据库独立成一个 schema,跟开发库完全隔离。
Deno 的权限参数在测试时也要给全,像 --allow-net、--allow-env、--allow-read=.env 都不能少,否则 fetch 和数据库连接都会被运行时拒绝。如果发现测试莫名其妙报权限错误,先检查是不是忘了在 task 里加权限。
5.2 生产部署:Docker 镜像与最小化权限
生产环境我用 Docker 构建镜像。因为 Deno 支持编译为单文件二进制,镜像可以做得非常小:
FROM denoland/deno:2.2.1 AS builder WORKDIR /app COPY . . RUN deno compile --output=server --allow-net --allow-env --allow-read=.env main.ts FROM debian:bookworm-slim WORKDIR /app COPY --from=builder /app/server . COPY .env .env EXPOSE 8000 CMD ["./server"]两个明显好处:第一,最终镜像没有源码和依赖目录,攻击面小很多;第二,deno compile 出来的二进制启动速度非常快,内存占用也不大,适合在容器环境中频繁调度。
如果不想编译,直接在容器里跑 deno run 也可以,但一定要锁依赖版本。deno.json 里的 imports 已经是固定版本了,再加一个 deno.lock 保证可复现构建。默认 Deno 会用 lock 文件,提交到仓库就行。
健康检查我加了一个轻量接口:
Deno.serve(async (req) => { if (new URL(req.url).pathname === "/healthz") { return Response.json({ status: "ok" }, { status: 200 }); } // 其余路由... });在编排平台里配个探针,每 10 秒打一次 /healthz,后面扩容、发布都能自动摘除不健康实例。
5.3 生产环境踩坑:连接池耗尽、SSL、批量写入性能
连接池耗尽是我遇到最多的生产事故。症状很典型:API 请求开始变慢,然后全部卡住,日志里出现 couldn't connect to postgres 或 connection timeout。排查后基本是同一个原因:某个 handler 在执行事务或长查询时抛了异常,却没走 release,连接被占着不放。修复就是统一用 withClient 模式,把释放连接放进 finally。上线后加一个指标,比如当前连接池活跃连接数,超过 80% 就告警。
SSL 问题主要出现在连云托管实例时。YugabyteDB 云服务要求 SSL 连接,但自签名证书在本地开发时经常不被信任。我当时的报错是 connection terminated unexpectedly 这类模糊提示。解决方法是本地连的时候在连接串里用 sslmode=require,生产环境用云厂商提供的证书路径配置 sslrootcert。连接串看起来像这样:
postgresql://user:password@host:5433/db?sslmode=verify-full&sslrootcert=/path/to/root.crt批量写入性能是第三个坑。最开始我在 API 里循环执行 INSERT,一次导 5 万行数据跑了近两分钟。问题不在 Deno,而在逐条插入的事务开销。后来改成多行 INSERT,一次写 500 行,性能提升近一个数量级;如果数据量更大,用 COPY 命令几乎是唯一高效方案。YugabyteDB 对 COPY 的支持很成熟,大批量历史数据迁移我都推荐先用 COPY。
另外留意一个细节:YugabyteDB 默认端口是 5433,但有些云厂商的端口分配和默认不同,连接前一定要把控制台上给的完整连接串粘进 .env,而不是自己拼接。我见过有人把端口写成 5432 或者 3306,折腾了半天才发现是端口问题。
5.4 我给"现代化 JS API"的最后一公里建议
走到这一步,你的 API 已经用 Deno 跑起来、数据落在 YugabyteDB 上、有测试、有 Docker 镜像。再往后,我会建议你补三件事。
第一件是保留请求级日志关联 ID。现在日志里已经有了 requestId,下一步把数据库查询、第三方调用、响应耗时串到同一个 trace 里。不用上特别重的链路追踪组件,先在日志结构化里做好,出问题能快速定位到具体请求就够了。
第二件是接入 OpenAPI 描述文档。Deno 生态里有 openapi 相关库可以从代码生成规范文档,也可以直接用 JSDoc 注释配插件。有了 OpenAPI 文档,前端可以直接生成类型安全的客户端 SDK,接口变化时编译期就能发现问题,这比维护一份 Markdown 接口文档靠谱得多。
第三件是把关键接口加上缓存层。YugabyteDB 的响应速度很快,但热数据走缓存可以省掉大量数据库连接和网络开销。我通常对列表类接口做 30 秒的短期缓存,对单条资源做 60 秒,数据库压力能降一半以上。等业务量再涨,数据库节点也能从容扩容,不必在架构上过度设计。
我实际用下来的最大感受是,Deno 让 JavaScript API 的开发和运维都变得更简单了,YugabyteDB 又让数据库这一层不用过早成为瓶颈。前端团队可以完整掌控从请求路由到数据存储的整条链路,而且每一环都有清晰的安全边界。如果你正打算重构手头的 Node API,不妨找一个小服务先用 Deno 和 YugabyteDB 试试水,跑通一个接口后再决定要不要全量迁移。这套组合的收益,不是看文档能体会到的,得真正跑起来才知道。