短链服务设计:Base62 混淆发号与跳转链路的坑

格 格达软件 2026-10-16 1 次阅读 AI 生成

短链服务最容易写错的地方不是跳转,是短码怎么来。用自增 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 的连接级坑。先确认自己需不需要付这个代价,再动手写。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。