nestjs 短链服务

生成方案对比与核心代码实现

Posted by chanweiyan on May 12, 2026

在营销短信、社交媒体分享以及二维码生成等场景中,经常需要将冗长的 URL 转换成简短的链接(类似于 t.cn/xxxxbit.ly/xxxx)。这不仅能缩减字符数量,使得排版更加美观,还能通过短链服务器进行访问量(PV/UV)的埋点统计。

本文将深入探讨在 NestJS 中实现短链服务的几种主流方案,对比它们的优缺点,并提供最佳实践的代码实现。

短链服务的三大核心方案对比

短链的核心原理非常简单:在数据库中建立“短码”到“长链接”的映射字典。当用户访问短码时,服务器查询出长链接,并返回 302 Redirection 让浏览器重定向过去。

难点在于:如何高效、唯一地生成这个“短码”?

方案一:哈希算法(MurmurHash / MD5 + Base62)

将长链接直接丢进哈希算法(推荐使用计算速度极快的非加密哈希 MurmurHash32),把得到的一个 32 位数字转成 Base62 字符即可。

  • 优点:无需依赖中心化的 ID 生成器,算法简单。相同的长链接永远生成相同的短链,天然去重。
  • 缺点:哈希截断不可避免地会产生哈希冲突(Hash Collision)。如果两个不同的长链接算出了同一个短码,就需要加盐(加随机字符串)再次哈希,处理冲突的逻辑会随着数据量增大而变得极其复杂和低效。

方案二:随机字符串 + 唯一索引冲突重试

每次请求过来,使用代码随机生成一个长度为 6 的大小写字母+数字的组合,然后去数据库里 INSERT(依靠 UNIQUE KEY 保证不重复)。

  • 优点:极其简单,代码量最少;短码是无序的,竞争对手无法通过短码推算出你的业务单量。
  • 缺点:随着数据库里的数据越来越多,随机碰撞报错的概率会急剧上升。到了后期,可能生成生成十次都会遇到冲突,此时数据库的写入性能将出现严重瓶颈。

方案三:发号器(自增 ID) + Base62 进制转换(业界推荐 ⭐)

利用数据库的自增 ID(如 MySQL 的 AUTO_INCREMENT)或 Redis 的 INCR 命令,甚至 Snowflake 雪花算法,生成一个绝对不重复的全局唯一自增数字 10001, 10002…,然后将这个十进制数字转换为 Base62(0-9, a-z, A-Z 共62个字符)进制。

  • 优点绝对不会发生冲突!生成效率极高,没有循环重试问题;且查询速度利用自增主键非常快。
  • 缺点:由于短码是连续自增的,很容易被遍历和暴力破解,同时容易暴露业务单量(通过昨天和今天的 ID 差值就能算出来)。
  • 优化解法:由于连续问题,业界通常会使用洗牌算法把这 62 个基础字符库进行乱序打乱,这样就算数字是连续的,生成的短码也是跳跃无规律的。

基于 NestJS 的代码实现(自增发号器方案)

我们将采用 Redis 的 INCR + Base62 洗牌编码 来实现这套系统。

1. Base62 进制转换工具

十进制数字转 Base62 本质上就是不断地对着 62 取余。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/utils/base62.util.ts
// 这里的 CHARS 被我们故意打乱了顺序(洗牌),防止数字被轻易推算
const CHARS = 'qweRTYuiopASdfgHJKlzxCvbnM1234567890QWErtyUIOPasDFGhjkLZXcVBNm';
const BASE = CHARS.length;

export function encodeBase62(num: number): string {
  if (num === 0) return CHARS[0];
  let result = '';
  while (num > 0) {
    result = CHARS[num % BASE] + result;
    num = Math.floor(num / BASE);
  }
  return result;
}

export function decodeBase62(str: string): number {
  let num = 0;
  for (let i = 0; i < str.length; i++) {
    num = num * BASE + CHARS.indexOf(str[i]);
  }
  return num;
}

2. 短链 Service(生成与解析)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// src/short-url/short-url.service.ts
import { Injectable, Inject } from '@nestjs/common';
import Redis from 'ioredis';
import { encodeBase62 } from '../utils/base62.util';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ShortUrlEntity } from './entities/short-url.entity';

@Injectable()
export class ShortUrlService {
  private readonly REDIS_COUNTER_KEY = 'short_url:id_counter';

  constructor(
    @Inject('REDIS_CLIENT') private readonly redis: Redis,
    @InjectRepository(ShortUrlEntity) private readonly repo: Repository<ShortUrlEntity>
  ) {}

  /**
   * 将长链接生成短码并落库
   */
  async generateShortUrl(originalUrl: string): Promise<string> {
    // 1. 发号器:利用 Redis 的 INCR 原子操作拿到唯一递增 ID
    const uniqueId = await this.redis.incr(this.REDIS_COUNTER_KEY);

    // 2. 将此 ID 进行进制转换得到短码
    const shortCode = encodeBase62(uniqueId);

    // 3. 落库绑定关系
    const entity = new ShortUrlEntity();
    entity.id = uniqueId;
    entity.shortCode = shortCode;
    entity.originalUrl = originalUrl;
    await this.repo.save(entity);

    return shortCode;
  }

  /**
   * 解析短码获取真实地址
   */
  async getOriginalUrl(shortCode: string): Promise<string | null> {
    // 最佳实践:这里必定会先查询 Redis 缓存层,若无再查 MySQL。此处略写
    const entity = await this.repo.findOneBy({ shortCode });
    return entity ? entity.originalUrl : null;
  }
}

3. 跳转 Controller 拦截

当用户在浏览器访问 http://你的域名/J7o 时,NestJS 捕获动态路由并将请求重定向至源网站:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// src/short-url/short-url.controller.ts
import { Controller, Get, Post, Body, Param, Res, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { ShortUrlService } from './short-url.service';

@Controller('s')
export class ShortUrlController {
  constructor(private readonly shortUrlService: ShortUrlService) {}

  // 接口:生成短链
  @Post('generate')
  async create(@Body('url') url: string) {
    const code = await this.shortUrlService.generateShortUrl(url);
    // 返回带域名的完整短链地址
    return { shortUrl: `http://localhost:3000/s/${code}` };
  }

  // 服务出口:访问短链接,进行 302 重定向
  @Get(':code')
  async redirect(@Param('code') code: string, @Res() res: Response) {
    const originalUrl = await this.shortUrlService.getOriginalUrl(code);

    if (!originalUrl) {
      // 如果没找到,可以跳制自己做的一个 404 前端页
      return res.status(HttpStatus.NOT_FOUND).send('短链接已失效或不存在');
    }

    // 核心代码:发起 302 临时重定向交出控制权
    return res.redirect(HttpStatus.FOUND, originalUrl);
  }
}

QA: 为了缩短链接长度,为什么用 Base62 代替普遍的 Base64 编码呢?

💬点击展开/收起

Base64 包含了 A-Z, a-z, 0-9 再加上 +/ 这两个特殊字符。问题就出在这两个特殊字符上: 在 HTTP URL 标准中,+ 号会被引擎当成空格解析处理掉,而 / 本身就是 URL 路径的目录分割符参数,会导致原本作为变量的参数受到破坏,从而引发框架路由解析崩溃 404。

虽然有替换为 -_ 的 Base64 URL 变种(UrlSafe),但是其显示效果不如纯字母和数字来得清晰直觉,很多文本编辑器识别出来的超链接甚至容易截断。所以去掉这俩麻烦精,仅仅用 62 个数字字母组合的 Base62 是业界一致选定的黄金标准方案。

QA: 访问短链进行跳转时,用 301 重定向还是用 302 重定向?

💬点击展开/收起

毫无疑问,推荐使用 302 Found(临时重定向)

  • 301 永久重定向:如果使用 301,当用户的浏览器第一次拿到源地址后,就会在本地死死地“缓存”掉这条映射规则(强缓存)。下一次这个用户再点击短链,浏览器甚至不会再去请求你的短链服务器,而是直接自己跳转到长链了。这样你会完完全全丢失他后续的 PV/UV 访问统计请求。
  • 302 临时重定向:每次用户点击短链都会必然先打在短链服务器上。这让服务器有机会在网关处留下他的请求日志,做各种访问次数统计、拦截管控分析、判断链接是否过期甚至修改替换其长链接的指向,这是数据统计埋点不可或缺的一环。

当然,302 的代价是服务器每次都要承受并发连接的压力。所以短链重定向往往都会采用 Redis 将热点映射进行极为极致的缓存。