短链服务最容易写错的地方不是跳转,是短码怎么来。用自增 ID 直接做 Base62 编码,别人抓十几个短码就能顺着把全站链接遍历出来;用长链哈希取前 6 位,几万条之后碰撞概率就上来了,你得写重试逻辑。这篇给一套我实际在用的方案:号段模式发号 + Feistel 置换混淆,代码可以直接跑,后面几节讲跳转链路和建表时一定会踩的坑。
一、先把约束定死,再选方案
三条硬约束,任何一条没满足,后面都要返工。
短码不可枚举。 短码空间是公开的,但你不能让「看了一个短码就能猜出下一个」。这条决定了短码不能是 ID 的单调函数。
发号必须全局唯一且无中心单点。 一旦分库分表,MySQL 的自增主键不再全局唯一;用单点 Redis INCR 则把整个写入链路挂在一个会丢数据、会主从回退的地方。
跳转不能用 301。 这条被违反得最多,第三节单独讲。
先看最常见的错误写法:
// 错误:自增主键直接 Base62
// 结果 /1 /2 /3 ... 全都能访问到,一个人写个 for 循环就能拖走整个库
func ShortCode(id int64) string {
return base62Encode(uint64(id))
}
加个固定盐也没用:base62(hash(id + salt)) % 62^6 会碰撞,而且撞了你不知道。算一下就知道不能靠运气:6 位 Base62 有 62⁶ = 56,800,235,584 个位置,插入 3.4 万条时生日问题给出的碰撞概率大约是 1%;到 100 万条,碰撞概率接近 100%。所以「哈希取前 6 位」这个方案必须写碰撞重试,别想着「空间这么大不会撞」。
剩下的路只有一条:发一个全局唯一的自增 ID,然后把这个 ID 用一个双射打乱成 6 位短码。双射保证不碰撞,打乱保证不可枚举。
二、短码生成:Feistel 置换 + 号段发号
Feistel 网络:给自增 ID 做无碰撞混淆
Feistel 网络本来是分组密码的结构,它的性质正好是我们要的:输入输出等长、每轮都可逆、整体是个双射。6 位 Base62 的域大小是 62⁶ ≈ 5.68e10,小于 2³⁶ = 6.87e10,所以我们在 36 位空间上做置换,左右各切 18 位。
package shortcode
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const codeLen = 6
const domain = uint64(56800235584) // 62^6
const halfBits = 18
const halfMask = uint64(1)<<halfBits - 1 // 262143
// roundFn 只要雪崩性够好就行,不需要可逆
func roundFn(x, k uint64) uint64 {
z := x + k*0x9E3779B97F4A7C15
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9
z = (z ^ (z >> 27)) * 0x94D049BB133111EB
return (z ^ (z >> 31)) & halfMask
}
// feistel 在 2^36 空间上做双射;4 轮起步,2 轮混淆不够
func feistel(x, key uint64) uint64 {
l := (x >> halfBits) & halfMask
r := x & halfMask
for i := uint64(0); i < 4; i++ {
l, r = r, l^roundFn(r, key+i)
}
return (l << halfBits) | r
}
// Encode 把自增 ID 转成 6 位短码:同 ID 同短码,不同 ID 必不撞码
func Encode(id uint64, key uint64) string {
x := feistel(id, key)
// Feistel 的像空间是 2^36,比 62^6 大,落在域外的继续置换。
// 这是 cycle walking:因为置换的轨道是环,从域内出发必然有限步回到域内,
// 期望轮数 2^36/62^6 ≈ 1.21,且映射仍然是双射
for x >= domain {
x = feistel(x, key)
}
buf := make([]byte, codeLen)
for i := codeLen - 1; i >= 0; i-- {
buf[i] = alphabet[x%62]
x /= 62
}
return string(buf)
}
跑一下就能看到效果:ID 从 100000 开始递增,得到的短码在 6 位空间里乱跳,相邻 ID 的短码之间推不出任何关系。
func main() {
// key 别硬编码在代码里,从环境变量或 KMS 读
key := uint64(0x5F3A1C7D9E2B4A68)
for id := uint64(100000); id < 100008; id++ {
fmt.Println(id, shortcode.Encode(id, key))
}
}
几个坑:
- key 泄露等于短码可枚举。 别人知道 key 就能反解 ID,再按 ID 顺序构造短码。当密钥管理,该轮换就轮换——老短码已经落库,轮换只影响新生成的规律,不会让存量链接失效。
- 别把轮数降到 1~2 轮省 CPU。 单次 Encode 是纳秒级,瓶颈根本不在这。
- ID 必须小于 2³⁶。 超过 6.87e10,左右半的切分就错了。而 6 位 Base62 也只装得下 568 亿条,两者基本同时见底,该扩到 7 位(62⁷ = 3.52e12)了。
- 不想要短码列的话可以反解。 Feistel 的逆就是把轮次倒过来跑,能直接从短码还原 ID,然后用主键查库,省一次二级索引回表。但一旦产品要支持用户自定义短码,这条路就废了,所以我选择老老实实存
short_code列 + 唯一索引。
号段发号器:把 DB 的 QPS 除以 1000
Feistel 需要一个全局唯一的自增 ID。号段模式的做法是:DB 每次分配一批,服务在内存里发。
CREATE TABLE `segment` (
`biz_tag` varchar(64) NOT NULL,
`max_id` bigint unsigned NOT NULL DEFAULT 0,
`step` int NOT NULL DEFAULT 1000,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;
INSERT INTO `segment` (`biz_tag`, `max_id`, `step`) VALUES ('short_url', 0, 1000);
取一段新号,用 LAST_INSERT_ID() 把两条 SQL 压成一次业务往返:
func (a *Allocator) fetch(ctx context.Context) (segRange, error) {
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return segRange{}, err
}
defer tx.Rollback()
// 关键:LAST_INSERT_ID() 是「连接级」的,必须和 UPDATE 在同一连接上。
// 用事务是最省事的做法;拿连接池直接跑两条 Exec 会分到不同连接,拿到 0
if _, err := tx.ExecContext(ctx,
`UPDATE segment SET max_id = LAST_INSERT_ID(max_id + step) WHERE biz_tag = ?`,
a.tag); err != nil {
return segRange{}, err
}
var newMax uint64
if err := tx.QueryRowContext(ctx, `SELECT LAST_INSERT_ID()`).Scan(&newMax); err != nil {
return segRange{}, err
}
if err := tx.Commit(); err != nil {
return segRange{}, err
}
// 返回闭区间 [newMax-step+1, newMax]
return segRange{cur: newMax - a.step + 1, max: newMax}, nil
}
LAST_INSERT_ID(expr) 这个写法一次 UPDATE 就完成了「自增并返回新值」,不用 SELECT ... FOR UPDATE。
最容易踩的坑:把 LAST_INSERT_ID() 放到另一条 Exec 里。 database/sql 的连接池会把两条语句分到不同连接,LAST_INSERT_ID() 在另一个连接返回 0,于是发号器从 0 开始发,短码全部撞车,uk_code 唯一索引开始报错。这种 bug 在压测时不一定复现(连接池可能刚好复用同一连接),上线才炸。
服务端的取号逻辑,关键是异步预取双 buffer:
type segRange struct{ cur, max uint64 }
type Allocator struct {
mu sync.Mutex
cur, max uint64
step uint64
ready chan segRange // 预取好的下一段,容量 1
inflight chan struct{} // 容量 1,保证只有一个预取 goroutine
db *sql.DB
tag string
}
func (a *Allocator) Next(ctx context.Context) (uint64, error) {
for {
a.mu.Lock()
if a.cur <= a.max {
id := a.cur
a.cur++
// 当前号段过半就预取,别等用尽才去拉 DB
needPrefetch := a.cur > a.max-a.step/2
a.mu.Unlock()
if needPrefetch {
a.tryPrefetch()
}
return id, nil
}
a.mu.Unlock()
// 号段耗尽:优先吃预取的,没有才同步回源
select {
case seg := <-a.ready:
a.mu.Lock()
a.cur, a.max = seg.cur, seg.max
a.mu.Unlock()
default:
seg, err := a.fetch(ctx)
if err != nil {
return 0, err
}
a.mu.Lock()
a.cur, a.max = seg.cur, seg.max
a.mu.Unlock()
}
}
}
func (a *Allocator) tryPrefetch() {
select {
case a.inflight <- struct{}{}:
default:
return // 已经有一个在预取了
}
go func() {
defer func() { <-a.inflight }()
// 预取不跟调用方的 ctx:请求取消了号段还是要拿到
seg, err := a.fetch(context.Background())
if err != nil {
return // 预取失败不致命,下次用尽时同步取
}
select {
case a.ready <- seg:
default:
// ready 满了就丢掉。号段浪费无所谓,ID 不会重复
}
}()
}
step 的取舍:
| step | 每次 DB 往返覆盖的 ID 数 | 服务重启浪费的 ID | 发 1 万 QPS 时 DB 的 QPS |
|---|---|---|---|
| 100 | 100 | ≤100 | 100 |
| 1000 | 1000 | ≤1000 | 10 |
| 10000 | 10000 | ≤10000 | 1 |
重启浪费 1000 个 ID 完全不用心疼——总共 568 亿个位置,浪费的连零头都不到。真正要关心的是:不加预取时,每个号段边界都会有一批请求同步等一次 DB 往返(同机房 0.5~3ms),在边界上形成周期性的 P99 毛刺。这就是双 buffer 存在的唯一理由。
三、跳转链路:301、穿透和开放重定向
短码查出来之后就是 302 跳转:
var codeRe = regexp.MustCompile(`^[0-9a-zA-Z]{6}$`)
func (s *Server) handleRedirect(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code") // Go 1.22+ 的 net/http 路由
// 先挡非法输入:这类请求一秒钟能来几万个,不该走到 Redis 或 DB
if !codeRe.MatchString(code) {
http.NotFound(w, r)
return
}
longURL, err := s.resolve(r.Context(), code)
switch {
case errors.Is(err, errNotFound):
http.NotFound(w, r)
return
case err != nil:
http.Error(w, "service unavailable", http.StatusServiceUnavailable)
return
}
// 302,不是 301:301 会被浏览器和 CDN 永久缓存,
// 之后改目标地址不生效,点击量也永远统计不到
w.Header().Set("Cache-Control", "no-cache, no-store, max-age=0")
http.Redirect(w, r, longURL, http.StatusFound)
}
301 是这套系统里杀伤力最大的错误。 上线时你想着「短链又不会变,缓存了对性能好」,然后三件事一起发生:用户浏览器把 code→URL 永久记下来了,运营改跳转目标,改完老用户还是跳到旧地址,你得求用户清缓存;跳转请求不再打到你的服务,PV/UV 全丢;短链做封禁时,已经被缓存的照样能访问。用 302,性能靠自己的 Redis + 本地缓存扛。
查短码要挡两层:
func (s *Server) resolve(ctx context.Context, code string) (string, error) {
// L1:进程内 LRU,TTL 100ms 级别。热点短链能挡住绝大部分请求
if v, ok := s.local.Get(code); ok {
return v.(string), nil
}
// singleflight:同一个 code 的并发回源只放一个进 Redis/DB,避免击穿
v, err, _ := s.sf.Do(code, func() (any, error) {
u, err := s.redis.Get(ctx, "s:"+code).Result()
if err == nil {
s.local.Set(code, u, 100*time.Millisecond)
return u, nil
}
if !errors.Is(err, redis.Nil) {
// Redis 挂了别直接报错,降级查 DB,否则 Redis 一抖全站 5xx
log.Warnf("redis get failed: %v", err)
}
u, err = s.db.GetByCode(ctx, code)
if errors.Is(err, sql.ErrNoRows) {
// 空值也缓存,短 TTL。否则有人扫不存在的短码,每次都打到 DB
s.redis.Set(ctx, "s:"+code, "", 60*time.Second)
return "", errNotFound
}
if err != nil {
return "", err
}
s.redis.Set(ctx, "s:"+code, u, 24*time.Hour)
return u, nil
})
if err != nil {
return "", err
}
if v == "" {
return "", errNotFound
}
return v.(string), nil
}
这里的边界条件:
- 空值缓存的 TTL 不能长。 60 秒是折中:被扫的时候 60 秒只打穿一次,而正常业务里刚创建的短链不会在 60 秒内被访问。
- 别用布隆过滤器解决穿透。 短码空间 568 亿,布隆要覆盖全量得有几十 GB 内存,而且它不支持删除——短链封禁后位删不掉,还得额外维护黑名单。空值缓存加频控更省事。
- 本地缓存的 TTL 决定改跳转目标的生效延迟。 100ms 意味着运营改完基本立刻生效,别设成几分钟。
- 热点短链(一条被大 V 转发的链接)会压在单个 key 上。本地 LRU 就是为这个准备的,能把 Redis 带宽压力削掉一两个数量级。容量按 QPS × TTL 估:1 万 QPS、100ms 过期,只需要存几千个 key。
注入长链时的校验是安全边界:
func validateLongURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if len(raw) > 2048 {
return "", errors.New("url too long")
}
u, err := url.Parse(raw)
if err != nil {
return "", err
}
// 只允许 http/https。javascript:、data:、file: 全部拒掉
if u.Scheme != "http" && u.Scheme != "https" {
return "", errors.New("scheme not allowed")
}
// 挡 //evil.com 这种 protocol-relative 写法:Scheme 为空、Host 是 evil.com
if u.Host == "" || !strings.Contains(u.Host, ".") {
return "", errors.New("invalid host")
}
return u.String(), nil
}
如果产品还有「抓取标题/截图」的功能,得再加一层:解析目标域名的 IP,拒绝 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、169.254.0.0/16 和 ::1。否则这就是一个现成的 SSRF 入口,http://169.254.169.254/latest/meta-data/ 能直接读云主机的临时凭证。
四、建表:两个几乎一定会踩的 DDL 错误
CREATE TABLE `short_url` (
`id` bigint unsigned NOT NULL,
`short_code` char(6) NOT NULL,
`url_hash` binary(16) NOT NULL COMMENT '归一化长链的 MD5',
`long_url` varchar(2048) NOT NULL,
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1 正常 0 封禁',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`short_code`),
UNIQUE KEY `uk_url` (`url_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
错误一:直接给 long_url 建唯一索引。
mysql> CREATE TABLE t (u varchar(2048) NOT NULL, UNIQUE KEY(u)) CHARSET=utf8mb4;
ERROR 1071 (42000): Specified key was too long; max key length is 3072 bytes
utf8mb4 每字符最多 4 字节,2048 × 4 = 8192 字节,超过 InnoDB 单列索引 3072 字节的上限。用 url_hash binary(16) 存 MD5,索引只占 16 字节。
错误二:拿原始 URL 直接算 hash 去重。
https://example.com/a?utm_source=weibo
https://example.com/a?utm_source=wechat
https://example.com/a/
这三条是同一个页面,但 MD5 完全不同,去重形同虚设。先归一化:
func normalize(raw string) (string, []byte, error) {
u, err := url.Parse(raw)
if err != nil {
return "", nil, err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", nil, errors.New("bad scheme")
}
u.Host = strings.ToLower(u.Host)
u.Fragment = "" // #fragment 不发给服务端,不该参与去重
q := u.Query()
for k := range q {
if strings.HasPrefix(strings.ToLower(k), "utm_") {
q.Del(k)
}
}
u.RawQuery = q.Encode() // Encode 按 key 排序,参数顺序不影响结果
u.Path = strings.TrimSuffix(u.Path, "/")
s := u.String()
sum := md5.Sum([]byte(s))
return s, sum[:], nil
}
去重本身也有代价,得提前想清楚:同一个长链被两个用户创建,共用一条记录还是各存各的? 共用省存储、防刷,但点击统计会混在一起,而且一个用户删除或封禁短链会影响到另一个用户。多租户场景下唯一索引应该是 (tenant_id, url_hash),把去重限制在租户内部。
补一个判断标准:什么时候可以砍掉号段发号器。如果你的短链服务是单库 MySQL、写 QPS 在几百这个量级,直接用自增主键就够了,Feistel 那 30 行照样用得上——它解决的是可枚举问题,跟 ID 从哪来无关。号段发号器是给分库分表或者写 QPS 上万准备的,它换来的是「DB 压力除以 step」和「发号不受 DB 抖动影响」,代价是 ID 会跳号、重启会浪费一段、以及上面那个 LAST_INSERT_ID 的连接级坑。先确认自己需不需要付这个代价,再动手写。