第一个短链接

长链接太丑了

某个周末,我在社交媒体上分享一个链接。那个 URL 长成这样:

https://www.example.com/search?q=系统设计课程&category=计算机&price=100-500&sort=sales

80 多个字符,占了半行字。发出去之后,整条动态的观感极差。

我盯着屏幕想了几秒——自己做一个短链接工具?反正周末没事。

理想中的效果:

https://short.url/a1b2c3

干净利落。

最简实现

我的原则:先跑起来再说。

数据库用 SQLite,最简单的表结构,存长 URL 和短码的映射:

CREATE TABLE url_mapping (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    short_code VARCHAR(10) NOT NULL UNIQUE,
    long_url VARCHAR(2048) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

短码怎么生成?我第一反应是 MD5 哈希——对长 URL 取哈希,截取前几位当短码:

import hashlib

def generate_short_code(url, length=6):
    """用 MD5 哈希生成短链接码"""
    md5_hash = hashlib.md5(url.encode()).hexdigest()
    return md5_hash[:length]

# 试试看
url = "https://www.example.com/very-long-url"
short_code = generate_short_code(url)
print(f"短链接码:{short_code}")  # "a1b2c3"

插入和查询也很直白:

-- 存入
INSERT INTO url_mapping (short_code, long_url) VALUES ('a1b2c3', 'https://www.example.com/...');

-- 查询
SELECT long_url FROM url_mapping WHERE short_code = 'a1b2c3';

一个下午,第一版就跑起来了。

发现 Base62 编码

上线之后我用了几天,发现 6 位十六进制的短码看起来不够”短链接”。那些十六进制字符(0-9, a-f)只用了 16 个符号,浪费了大量编码空间。

研究了一下,我发现可以用 Base62 编码——把 0-9a-zA-Z 这 62 个字符全部用上。

BASE62 = "01233456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

def encode_base62(num):
    """将十进制数转换为 62 进制"""
    if num == 0:
        return BASE62[0]

    base62_str = ""
    while num > 0:
        base62_str = BASE62[num % 62] + base62_str
        num //= 62

    return base62_str

# 试试看
print(encode_base62(1))        # "1"
print(encode_base62(12345))    # "3D7"
print(encode_base62(1000000))  # "4C92"

这比直接截取 MD5 好在哪?算一笔账就明白了:

  • 16 进制(MD5 截取):每位 16 种可能,6 位 = 16^6 = 1677 万种组合
  • 62 进制(Base62):每位 62 种可能,6 位 = 62^6 = 568 亿种组合

同样的 6 位字符,容量差了 3000 多倍

我的做法是把 MD5 哈希的前 8 位十六进制转成十进制整数,再用 Base62 编码:

def generate_short_code(url):
    """MD5 + Base62 生成短链接码"""
    md5_hash = hashlib.md5(url.encode()).hexdigest()
    hash_int = int(md5_hash[:8], 16)  # 取前 8 位十六进制 → 十进制
    return encode_base62(hash_int)

这样生成的短码又短又紧凑。

哈希冲突的担忧

做完 Base62 版本后,有天夜里我躺在床上突然想到一个问题:不同 URL 可能产生相同的短码。

MD5 输出 128 位,我只取了前 32 位(8 个十六进制字符),再转成 Base62。这意味着我把庞大的哈希空间压缩到了很小的范围。

根据生日悖论,当存储的链接数达到一定规模后,冲突的概率远比直觉想象的要高。

两个不同 URL 碰撞出同一个短码——用户点击短链接,跳到了错误的页面。这是不可接受的事故。

我想了几种应对方案:

方案 A:冲突后加随机盐重试。 如果生成的短码已被占用,就给 URL 加一个随机后缀重新哈希,直到不冲突。

方案 B:自增 ID + Base62。 用数据库的自增 ID 转成 Base62,从根源上杜绝冲突:

def create_short_url(long_url):
    # 插入长 URL,获取自增 ID
    cursor.execute("INSERT INTO url_mapping (long_url) VALUES (?)", (long_url,))
    row_id = cursor.lastrowid
    # ID 转 Base62 作为短码
    short_code = encode_base62(row_id)
    cursor.execute("UPDATE url_mapping SET short_code = ? WHERE id = ?", (short_code, row_id))
    return short_code

自增 ID 方案简单、不会冲突,但有个缺点:暴露业务量——知道短码就能反推数据库 ID,推算出我总共有多少链接。

对当时的我来说,这不是什么大问题。但这个选择背后的取舍,我记住了。

302 还是 301?

短链接生成了,还需要实现跳转。这里有个看似简单实则影响深远的选择:用 301 还是 302 重定向?

from flask import Flask, redirect, abort, request

app = Flask(__name__)

@app.route('/<short_code>')
def redirect_to_original(short_code):
    long_url = db.query(
        "SELECT long_url FROM url_mapping WHERE short_code = ?", short_code
    )
    if not long_url:
        abort(404)

    # 302 还是 301?这是一个问题
    return redirect(long_url, code=302)

两者的区别:

特性301 永久重定向302 临时重定向
SEO 权重传递给目标 URL保留在短链接
浏览器缓存会缓存不缓存
统计准确性不准确(缓存后不再请求服务器)准确(每次都请求服务器)
适用场景永久不变的链接可能变化的链接

我选了 302。理由很简单:

  1. 我需要准确的点击统计——每次点击都能被记录
  2. 长链接可能会变——用户应该能修改指向
  3. SEO 权重不是我关心的事

完整代码

把上面所有东西拼在一起,我写了第一版完整的短链接服务:

from flask import Flask, redirect, abort, request
import hashlib
import sqlite3

app = Flask(__name__)
BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

def encode_base62(num):
    if num == 0:
        return BASE62[0]
    base62_str = ""
    while num > 0:
        base62_str = BASE62[num % 62] + base62_str
        num //= 62
    return base62_str

def generate_short_code(url):
    """MD5 + Base62"""
    md5_hash = hashlib.md5(url.encode()).hexdigest()
    hash_int = int(md5_hash[:8], 16)
    return encode_base62(hash_int)

@app.route('/shorten', methods=['POST'])
def shorten_url():
    """创建短链接"""
    long_url = request.json.get('url')
    if not long_url:
        return {'error': 'URL is required'}, 400

    # 已存在则直接返回
    existing = db.query(
        "SELECT short_code FROM url_mapping WHERE long_url = ?", long_url
    )
    if existing:
        return {'short_url': f'https://short.url/{existing[0]}'}

    # 生成短码并存入
    short_code = generate_short_code(long_url)
    db.execute(
        "INSERT INTO url_mapping (short_code, long_url) VALUES (?, ?)",
        (short_code, long_url)
    )
    return {'short_url': f'https://short.url/{short_code}'}, 201

@app.route('/<short_code>')
def redirect_to_original(short_code):
    """重定向到原始 URL"""
    result = db.query(
        "SELECT long_url FROM url_mapping WHERE short_code = ?", short_code
    )
    if not result:
        abort(404)
    return redirect(result[0], code=302)

总共不到 60 行代码,但每个短链接的生成、存储、跳转流程都走通了。

上线

我把服务部署到一台 1 核 2G 的云服务器上。

成本账本:

项目费用
云服务器(1 核 2G)¥10/月
域名¥50/年
收入¥0

月成本 ¥10 多一点,我还能接受。

我把项目发到了一个技术社区。写了一篇帖子,介绍我的短链接服务,附上源码链接。

第一天:0 个用户

又发到第二个社区。还是 0 个用户

没人关心一个无名开发者的周末小项目。说实话,有点灰心。

这个阶段的技术账本

回过头看,第一版做的事很简单:

组件实现
短链接生成MD5 哈希 + Base62 编码
存储SQLite
重定向302 临时重定向
APIRESTful 风格

但问题也很明显,只是当时的流量还不足以暴露它们:

  1. 性能:每次查询都打数据库,没有缓存
  2. 哈希冲突:短码空间有限,迟早会撞
  3. 高并发:SQLite 写锁会卡死
  4. 统计功能:没有点击数据,连谁在用都不知道

那时候我以为这个项目就这样了。上线没人用,也就不用操心这些。

没想到两周后……

我的思考

思考 1

为什么短链接用 62 进制(0-9a-zA-Z)而不用 10 进制?我最初只是照着别人的方案做,后来才理解背后的道理。

参考答案

使用 62 进制的原因:

  1. 更短的链接长度

    • 62 进制:62^6 ≈ 568 亿种组合(6 位字符)
    • 10 进制:10^6 = 100 万种组合(6 位字符)
    • 相同长度下,62 进制能表示更多不同的值
  2. 字符集友好

    • 数字(0-9):10 个
    • 小写字母(a-z):26 个
    • 大写字母(A-Z):26 个
    • 共 62 个字符,都是 URL 安全字符
  3. URL 兼容性

    • 这 62 个字符在 URL 中不需要编码
    • 可以直接在浏览器、邮件、短信中使用
    • 不会产生特殊字符问题

思考 2

如果要支持 10 亿个短链接,至少需要多少位 62 进制字符?我在做容量规划的时候算过这笔账。

参考答案

计算过程

需要表示 10 亿 = 1,000,000,000 个不同的值

62 进制 n 位字符可以表示:62^n 种组合

求解:62^n ≥ 1,000,000,000

取对数:n ≥ log(1,000,000,000) / log(62) n ≥ 9 / log10(62) n ≥ 9 / 1.792 n ≥ 5.02

答案:至少需要 6 位 62 进制字符。

验证:

  • 5 位:62^5 = 916,132,832(不足 10 亿)
  • 6 位:62^6 = 56,800,235,584(超过 10 亿)

思考 3

回头看,302 是对的选择吗?如果重来一次,我会不会选 301?

参考答案

302 仍然是更好的选择:

1. 统计准确性

  • 302 不会被浏览器缓存,每次都会请求服务器
  • 可以准确记录每次点击
  • 对于需要 click tracking 的短链接服务至关重要

2. URL 可变性

  • 短链接对应的长 URL 可能会变化
  • 302 表示”临时”重定向,允许后续修改
  • 301 表示”永久”,修改后浏览器可能仍使用缓存

3. 业务需求

  • 短链接服务需要知道谁在什么时候访问了链接
  • 需要地域、设备、referrer 等统计信息
  • 302 保证每次请求都能被记录

什么时候用 301?

  • 确定 URL 永远不会变化
  • 不关心点击统计
  • 希望减少服务器请求(利用浏览器缓存)