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

举报
bug菌 发表于 2025/11/01 22:16:34 2025/11/01
【摘要】 🏆本文收录于「滚雪球学SpringBoot」专栏(全网一个名),手把手带你零基础入门Spring Boot,从入门到就业,助你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8 前言先拍胸口一句掏心话:是的,这活儿又酷又难,但绝不玄学。别被网上那些...

🏆本文收录于「滚雪球学SpringBoot」专栏(全网一个名),手把手带你零基础入门Spring Boot,从入门到就业,助你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!

环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8

前言

先拍胸口一句掏心话:是的,这活儿又酷又难,但绝不玄学。别被网上那些“十行代码做短链”的标题骗了,真要把它练成能抗压、能观测、能扩容、还能优雅下线的小怪兽,得用点“江湖手艺”。这篇文章我会用人话把技术栈、架构、实现细节、调优手段全部摊开讲,顺手给你一把能直接跑的代码与配置。你可以把它作为模板,拉起来改名上线,或者拿去给团队评审“拍砖”。咱们开整。

小小确认一下~你若有既定「主题/大纲/指定技术栈或业务场景」要专门对齐,回我几条关键词就行;没有的话,就以本文的“高并发短链服务(Node.js + TypeScript + Postgres + Redis + Docker)”为基线继续加料。🙂


前言:为什么是短链?

短链这玩意儿看似简单:长 URL 进,短码出,点击跳转结束。可真要上生产,你会碰到幂等性热点缓存限流与灰度观测与告警冷热分层海量自增冲突布隆过滤器SEO 与防爬……它几乎是小型分布式系统的“练功房”。所以,短链做明白了,其他系统也就八九不离十


目录先亮相

  1. 目标与非目标
  2. 技术选型与理由
  3. 逻辑架构与数据流
  4. 数据模型设计(SQL)
  5. 核心算法:短码生成策略
  6. 服务端代码演示(Fastify + TypeScript)
  7. Redis 缓存与布隆过滤器
  8. 限流与幂等性(令牌桶 + 幂等键)
  9. 观测与链路(OpenTelemetry 简装)
  10. 容器与本地编排(Docker Compose)
  11. 压测思路与指标门槛
  12. 调优清单(从“能跑”到“好跑”)
  13. 踩坑锦集
  14. 延伸与收官

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);

要点:

  • code 12 位足矣(62 进制 12 位覆盖面巨大)。
  • 适度使用 expire_at 做数据降温与归档。
  • meta 给你放 A/B、渠道、标签。

5) 核心算法:短码生成策略

三种主流思路混搭:

  1. 随机 + 唯一约束:先随机 62 进制串,INSERT 让唯一键兜底,冲突就重试(轻量高效)。
  2. 哈希派生:对 long_url + saltxxhash64,再做 62 进制。
  3. 雪花/自增 + 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-sizeUV_THREADPOOL_SIZE 视场景微调。
  • SQLINSERT … 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-

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。