一个周末,真能把短链服务练到 10 万 QPS?不试试怎么知道?

🏆本文收录于「滚雪球学SpringBoot」专栏(全网一个名),手把手带你零基础入门Spring Boot,从入门到就业,助你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8
前言
先拍胸口一句掏心话:是的,这活儿又酷又难,但绝不玄学。别被网上那些“十行代码做短链”的标题骗了,真要把它练成能抗压、能观测、能扩容、还能优雅下线的小怪兽,得用点“江湖手艺”。这篇文章我会用人话把技术栈、架构、实现细节、调优手段全部摊开讲,顺手给你一把能直接跑的代码与配置。你可以把它作为模板,拉起来改名上线,或者拿去给团队评审“拍砖”。咱们开整。
小小确认一下~你若有既定「主题/大纲/指定技术栈或业务场景」要专门对齐,回我几条关键词就行;没有的话,就以本文的“高并发短链服务(Node.js + TypeScript + Postgres + Redis + Docker)”为基线继续加料。🙂
前言:为什么是短链?
短链这玩意儿看似简单:长 URL 进,短码出,点击跳转结束。可真要上生产,你会碰到幂等性、热点缓存、限流与灰度、观测与告警、冷热分层、海量自增冲突、布隆过滤器、SEO 与防爬……它几乎是小型分布式系统的“练功房”。所以,短链做明白了,其他系统也就八九不离十。
目录先亮相
- 目标与非目标
- 技术选型与理由
- 逻辑架构与数据流
- 数据模型设计(SQL)
- 核心算法:短码生成策略
- 服务端代码演示(Fastify + TypeScript)
- Redis 缓存与布隆过滤器
- 限流与幂等性(令牌桶 + 幂等键)
- 观测与链路(OpenTelemetry 简装)
- 容器与本地编排(Docker Compose)
- 压测思路与指标门槛
- 调优清单(从“能跑”到“好跑”)
- 踩坑锦集
- 延伸与收官
1) 目标与非目标
目标:
- P50 延迟 < 5ms(内网跳转路径,缓存命中)
- P99 延迟 < 50ms(落盘路径)
- 单副本吞吐 ≥ 15k RPS;多副本水平扩容冲到 100k QPS
- 可观测、可回放、可滚动升级
非目标:
- 不做“永不碰撞”的数学承诺(业务上足够低即可)
- 暂不讨论多地域强一致(以最终一致 + 重试为主)
2) 技术选型与理由(拍桌子版)
- Node.js 22 + TypeScript + Fastify:事件驱动 I/O 爆款,生态肥美,Fastify 底层超轻、Schema 友好。
- PostgreSQL 16:可靠又强大的关系型,事务与唯一约束保底。
- Redis 7:热点缓存、计数器、布隆过滤器、分布式锁一肩扛。
- Docker Compose:开发即生产的最小镜像。
- k6 / autocannon:压测快狠准。
- OpenTelemetry:指标、日志、Trace 三件套,不迷信黑盒。
3) 逻辑架构与数据流(文字速写)
创建短链:
客户端 → API(幂等键) → 生成短码 → PostgreSQL INSERT … ON CONFLICT → Redis 预热 → 返回短码
访问短链:
客户端 → Nginx/边缘层缓存(可选) → API → Redis 命中直接 302 → 未命中查库 → 回写 Redis → 302
防御:
- 限流(令牌桶 + IP/Key 维度)
- 幂等性(Idempotency-Key)
- 布隆过滤器(高命中不存在直接 404)
- 读写分离(读多写少,优先走缓存)
4) 数据模型设计(SQL)
-- schema.sql
CREATE TABLE IF NOT EXISTS short_url (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(12) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expire_at TIMESTAMPTZ,
created_by VARCHAR(64),
meta JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_short_url_code ON short_url(code);
CREATE INDEX IF NOT EXISTS idx_short_url_expire ON short_url(expire_at);
要点:
code12 位足矣(62 进制 12 位覆盖面巨大)。- 适度使用
expire_at做数据降温与归档。 meta给你放 A/B、渠道、标签。
5) 核心算法:短码生成策略
三种主流思路混搭:
- 随机 + 唯一约束:先随机 62 进制串,
INSERT让唯一键兜底,冲突就重试(轻量高效)。 - 哈希派生:对
long_url + salt做xxhash64,再做 62 进制。 - 雪花/自增 + 62 进制:ID 连续友好,便于排序与归档。
实战建议:创建走随机+唯一(更“分散”);迁移/批量导入走自增转 62 进制(更可控)。
6) 服务端代码演示(Fastify + TypeScript)
项目结构
/app
├─ src
│ ├─ server.ts
│ ├─ routes.ts
│ ├─ storage.ts
│ ├─ cache.ts
│ ├─ limiter.ts
│ └─ util.ts
├─ package.json
└─ tsconfig.json
依赖安装
npm i fastify pg ioredis zod undici @fastify/swagger @fastify/swagger-ui
npm i -D typescript ts-node-dev @types/node
util.ts:62 进制与随机码
// src/util.ts
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
export function toBase62(num: bigint): string {
if (num === 0n) return '0';
let s = '';
while (num > 0) {
const r = Number(num % 62n);
s = ALPHABET[r] + s;
num = num / 62n;
}
return s;
}
export function randomCode(len = 8): string {
let s = '';
for (let i = 0; i < len; i++) {
s += ALPHABET[Math.floor(Math.random() * 62)];
}
return s;
}
storage.ts:Postgres 存取
// src/storage.ts
import { Pool } from 'pg';
export type ShortRow = {
code: string; long_url: string; expire_at: string | null;
};
export class Storage {
private pool: Pool;
constructor(url: string) { this.pool = new Pool({ connectionString: url }); }
async create(code: string, longUrl: string, expireAt?: string | null) {
const sql = `
INSERT INTO short_url (code, long_url, expire_at)
VALUES ($1, $2, $3)
ON CONFLICT (code) DO NOTHING
RETURNING code, long_url, expire_at
`;
const r = await this.pool.query(sql, [code, longUrl, expireAt ?? null]);
return r.rows[0] as ShortRow | undefined;
}
async get(code: string) {
const sql = `SELECT code, long_url, expire_at FROM short_url WHERE code=$1 LIMIT 1`;
const r = await this.pool.query(sql, [code]);
return r.rows[0] as ShortRow | undefined;
}
}
cache.ts:Redis 缓存与布隆(简化)
// src/cache.ts
import Redis from 'ioredis';
export class Cache {
private r: Redis;
constructor(url: string) { this.r = new Redis(url); }
key(code: string) { return `s:${code}`; }
async get(code: string) {
return this.r.get(this.key(code));
}
async set(code: string, longUrl: string, ttlSec = 86400) {
await this.r.set(this.key(code), longUrl, 'EX', ttlSec);
}
// 布隆过滤器(极简版,用 Redis 位图替身;正式上建议用 RedisBloom 模块)
private bfKey = 'bf:noexist';
private hash(s: string, seed: number) {
let h = seed;
for (let i = 0; i < s.length; i++) h = (h * 131 + s.charCodeAt(i)) >>> 0;
return h % (1 << 24); // 16M 位
}
async bfAdd(s: string) {
const i1 = this.hash(s, 17), i2 = this.hash(s, 29), i3 = this.hash(s, 47);
await this.r.setbit(this.bfKey, i1, 1);
await this.r.setbit(this.bfKey, i2, 1);
await this.r.setbit(this.bfKey, i3, 1);
}
async bfMightContain(s: string) {
const i1 = this.hash(s, 17), i2 = this.hash(s, 29), i3 = this.hash(s, 47);
const [b1, b2, b3] = await this.r.multi()
.getbit(this.bfKey, i1).getbit(this.bfKey, i2).getbit(this.bfKey, i3).exec();
return Boolean((b1?.[1] ?? 0) & (b2?.[1] ?? 0) & (b3?.[1] ?? 0));
}
}
limiter.ts:令牌桶限流(每 IP / API Key)
// src/limiter.ts
import Redis from 'ioredis';
export class Limiter {
constructor(private r: Redis, private rate = 50, private burst = 100) {}
key(id: string) { return `lim:${id}`; }
async allow(id: string) {
const k = this.key(id);
// 每秒填充 rate,桶最大 burst(简化窗口)
const now = Math.floor(Date.now() / 1000);
const lua = `
local k=KEYS[1]; local now=tonumber(ARGV[1]); local rate=tonumber(ARGV[2]); local burst=tonumber(ARGV[3])
local d=redis.call('HMGET', k, 't', 'tokens')
local t=tonumber(d[1]) or now; local tokens=tonumber(d[2]) or burst
local delta=now - t; tokens=math.min(burst, tokens + delta*rate)
if tokens < 1 then
redis.call('HMSET', k, 't', now, 'tokens', tokens)
redis.call('EXPIRE', k, 60)
return 0
else
tokens=tokens-1
redis.call('HMSET', k, 't', now, 'tokens', tokens)
redis.call('EXPIRE', k, 60)
return 1
end
`;
const ok = await (this.r as any).eval(lua, 1, k, now, this.rate, this.burst);
return ok === 1;
}
}
routes.ts:创建与跳转路由
// src/routes.ts
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { Storage } from './storage';
import { Cache } from './cache';
import { randomCode } from './util';
import Redis from 'ioredis';
import { Limiter } from './limiter';
export async function registerRoutes(app: FastifyInstance) {
const store = new Storage(process.env.DATABASE_URL!);
const cache = new Cache(process.env.REDIS_URL!);
const limiter = new Limiter(new Redis(process.env.REDIS_URL!));
const CreateBody = z.object({
url: z.string().url(),
expireAt: z.string().datetime().optional()
});
app.post('/api/shorten', async (req, reply) => {
const ip = (req.headers['x-forwarded-for'] as string) || req.ip;
if (!(await limiter.allow(ip))) return reply.code(429).send({ error: 'Too Many Requests' });
const idem = (req.headers['idempotency-key'] as string) || '';
const parsed = CreateBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const { url, expireAt } = parsed.data;
// 幂等缓存(秒级)——真实应用可入 Redis 专门集合
const idemKey = `idem:${idem}`;
if (idem) {
const existed = await app.redis?.get(idemKey);
if (existed) return reply.send(JSON.parse(existed));
}
// 生成唯一 code(冲突重试)
let code = '';
for (let i = 0; i < 5; i++) {
const c = randomCode(8);
const row = await store.create(c, url, expireAt);
if (row) { code = row.code; break; }
}
if (!code) return reply.code(500).send({ error: 'Generate code failed' });
await cache.set(code, url);
const res = { code, shortUrl: `${process.env.PUBLIC_BASE}/${code}` };
if (idem) await app.redis?.setex(idemKey, 60, JSON.stringify(res));
reply.send(res);
});
app.get('/:code', async (req, reply) => {
const code = (req.params as any).code as string;
// 布隆过滤器命中“可能不存在”,就走查库+回写;若明确“不存在”的概率高,直接 404
const cached = await cache.get(code);
if (cached) return reply.redirect(302, cached);
const row = await (new Storage(process.env.DATABASE_URL!)).get(code);
if (!row || (row.expire_at && new Date(row.expire_at) < new Date())) {
return reply.code(404).send('Not found');
}
await cache.set(code, row.long_url);
return reply.redirect(302, row.long_url);
});
}
server.ts:装好 Swagger 与 Redis 实例
// src/server.ts
import Fastify from 'fastify';
import swagger from '@fastify/swagger';
import swaggerUI from '@fastify/swagger-ui';
import Redis from 'ioredis';
import { registerRoutes } from './routes';
const app = Fastify({ logger: true });
(app as any).redis = new Redis(process.env.REDIS_URL!);
await app.register(swagger, { openapi: { info: { title: 'Shorty API', version: '1.0.0' } } });
await app.register(swaggerUI, { routePrefix: '/docs' });
await registerRoutes(app);
const port = Number(process.env.PORT || 3000);
app.listen({ port, host: '0.0.0.0' }).catch(err => {
app.log.error(err); process.exit(1);
});
7) 缓存与布隆过滤器的“脾气”
- 缓存策略:创建后立即回写缓存;读取先查 Redis,未命中才落库。TTL 可按访问热度动态延长(LRU/LFU 都可)。
- 布隆过滤器:提升“极可能不存在”的请求的快速失败率,减轻数据库压力。生产可用 RedisBloom 模块或 Roaring Bitmap。
8) 限流与幂等性:别让好人受罪、也别让坏人得逞
- 限流粒度:IP、用户 Token、API Key、多维叠加;写入接口更严。
- 幂等性:创建接口支持
Idempotency-Key,重复提交返回同结果;支付/扣费类还需强事务。
9) 观测与链路:别等线上“灵异事件”才开日志
最小接入(仅示意):
// 片段:OpenTelemetry 快速挂载(生产请拆文件)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const sdk = new NodeSDK({ instrumentations: [getNodeAutoInstrumentations()] });
sdk.start();
搭配 Prometheus/Grafana 或 APM(如 Tempo/Jaeger)做三板斧:指标(QPS/错误率/延迟)、日志(结构化)、Trace(端到端)。
10) 容器与本地编排(Docker Compose)
# docker-compose.yml
version: "3.9"
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
ports: [ "5432:5432" ]
redis:
image: redis:7
ports: [ "6379:6379" ]
api:
build: .
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
REDIS_URL: redis://redis:6379
PUBLIC_BASE: http://localhost:3000
NODE_ENV: production
ports: [ "3000:3000" ]
depends_on: [ db, redis ]
Dockerfile
FROM node:22-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "dist/server.js"]
11) 压测思路与指标门槛(别盯着 QPS 一根筋)
- 读路径(命中缓存):用
autocannon -c 500 -d 30 http://localhost:3000/abc123 - 写路径:
k6模拟幂等键+随机 URL;关注 P95 延迟、错误率、redis 命中率、CPU/内存/GC。 - 门槛参考:单副本在 8 核 16G 下,读命中路径 20k+ RPS 不应是天花板,P99 < 10–20ms 合理。
12) 调优清单(从“能跑”到“好跑”)
- 连接池:Postgres/Redis 别滥开;合理上限 + KeepAlive。
- 序列化:JSON 序列化上热路径尽量轻;路由校验用 Zod 但避开超复杂 Schema。
- 日志:热路径降级为采样日志;错误全量上报。
- 缓存键:短、定长、可观测;避免大 Value(小于 1KB 更香)。
- Nginx/边缘:静态 301/302 可前置,近用户更快。
- Node 调参:
--max-old-space-size、UV_THREADPOOL_SIZE视场景微调。 - SQL:
INSERT … ON CONFLICT更简洁,少回表;索引命中率关注pg_stat_statements。
13) 踩坑锦集(都是眼泪)
- 热 Key 雪崩:某条大号短链突然爆火,缓存同时过期,DB 被锤。解法:随机抖动 TTL、单飞线慢启动。
- 幂等键复用:客户端把幂等键写死……结果全世界都拿到同一返回。解法:服务端检测异常复用。
- 布隆误判:误判导致库里明明有,你却先 404。解法:先缓存再布隆,误判只降低“查库概率”,不能决定最终响应。
- 302 跳转链:跳到第三方再 302 一次,前端说慢。解法:鼓励目标 URL 固定化或前置健康检查。
14) 延伸与收官
做到这里,你已经拥有一个能打的短链底座:结构清晰、实现克制、性能靠谱、可观察可调优。下一步?加上自定义域名、访问策略、二维码、渠道报表,甚至边缘计算 Worker 在 CDN 层直接完成跳转与计数。到那时,你会惊讶:复杂系统,其实是许多个简单边界的平衡术。
15) 附录:API 文档(OpenAPI 片段)
openapi: 3.0.3
info:
title: Shorty API
version: "1.0.0"
paths:
/api/shorten:
post:
summary: Create a short URL
parameters:
- in: header
name: Idempotency-Key
required: false
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
url: { type: string, format: uri }
expireAt: { type: string, format: date-time }
required: [url]
responses:
"200":
description: Short link created
/{code}:
get:
summary: Redirect by code
parameters:
- in: path
name: code
required: true
schema: { type: string }
responses:
"302": { description: Redirect to long URL }
"404": { description: Not found }
结语:来,问问自己一个问题
**一个周末,真能把短链服务练到 10 万 QPS?**如果你的答案还是“悬”,那就把本文拉到你的编辑器,先跑通,再压测,再调一轮。当第一束压测曲线开始变平,你会会心一笑:**原来不是神话,是套路。**😉
🧧福利赠与你🧧
无论你是计算机专业的学生,还是对编程有兴趣的小伙伴,都建议直接毫无顾忌的学习此专栏「滚雪球学SpringBoot」专栏(全网一个名),bug菌郑重承诺,凡是学习此专栏的同学,均能获取到所需的知识和技能,全网最快速入门SpringBoot,就像滚雪球一样,越滚越大, 无边无际,指数级提升。
最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。
同时欢迎大家关注公众号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G pdf电子书籍、简历模板、技术文章Markdown文档等海量资料。
✨️ Who am I?
我是bug菌(全网一个名),CSDN | 掘金 | InfoQ | 51CTO | 华为云 | 阿里云 | 腾讯云 等社区博客专家,C站博客之星Top30,华为云多年度十佳博主/价值贡献奖,掘金多年度人气作者Top40,掘金等各大社区平台签约作者,51CTO年度博主Top12,掘金/InfoQ/51CTO等社区优质创作者;全网粉丝合计 30w+;更多精彩福利点击这里;硬核微信公众号「猿圈奇妙屋」,欢迎你的加入!免费白嫖最新BAT互联网公司面试真题、4000G PDF电子书籍、简历模板等海量资料,你想要的我都有,关键是你不来拿。

-End-
- 点赞
- 收藏
- 关注作者
评论(0)