第一个短链接
长链接太丑了
某个周末,我在社交媒体上分享一个链接。那个 URL 长成这样:
https://www.example.com/search?q=系统设计课程&category=计算机&price=100-500&sort=sales80 多个字符,占了半行字。发出去之后,整条动态的观感极差。
我盯着屏幕想了几秒——自己做一个短链接工具?反正周末没事。
理想中的效果:
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-9、a-z、A-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。理由很简单:
- 我需要准确的点击统计——每次点击都能被记录
- 长链接可能会变——用户应该能修改指向
- 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 临时重定向 |
| API | RESTful 风格 |
但问题也很明显,只是当时的流量还不足以暴露它们:
- 性能:每次查询都打数据库,没有缓存
- 哈希冲突:短码空间有限,迟早会撞
- 高并发:SQLite 写锁会卡死
- 统计功能:没有点击数据,连谁在用都不知道
那时候我以为这个项目就这样了。上线没人用,也就不用操心这些。
没想到两周后……
我的思考
思考 1
为什么短链接用 62 进制(0-9a-zA-Z)而不用 10 进制?我最初只是照着别人的方案做,后来才理解背后的道理。
使用 62 进制的原因:
更短的链接长度
- 62 进制:62^6 ≈ 568 亿种组合(6 位字符)
- 10 进制:10^6 = 100 万种组合(6 位字符)
- 相同长度下,62 进制能表示更多不同的值
字符集友好
- 数字(0-9):10 个
- 小写字母(a-z):26 个
- 大写字母(A-Z):26 个
- 共 62 个字符,都是 URL 安全字符
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 永远不会变化
- 不关心点击统计
- 希望减少服务器请求(利用浏览器缓存)