一个周末就想把短链服务推到 10 万 QPS,真能靠加 Redis 做到吗?

🏆本文收录于《Java实战(高阶版)》专栏,重点剖析Java各种超核心知识点,带你从入门到精通,全面提升Java编程实力🚀;欢迎大家关注/收藏/订阅!持续更新中,up!up!up!!

前言

设想一个再普通不过的业务:运营同学生成一条活动短链接,放进短信、海报或者社群消息。用户点击 https://s.example/r/Ab3...,服务查出原始地址,返回一个重定向响应,浏览器继续访问活动页。

需求文档大概三行,接口也不难写。新建一条映射、按短码查询、返回 Location,一个下午可能就能做出能演示的版本。

麻烦发生在传播开始之后。

同一条链接可能被大量用户同时点击;生成链接的请求不多,跳转请求却会在短时间内集中涌入。数据库一开始查询飞快,等连接池排队、缓存失效、实例扩容、流量经过负载均衡器和公网链路时,原来那次不起眼的查询突然有了很多同伴。

更尴尬的是,跳转接口返回了 302,压测工具却可能继续访问目标网站。最后报表上的耗时,到底是在测自己的短链服务,还是在给别人的网站做体检?

于是出现了一个很有诱惑力的周末挑战:

把短链跳转练到每秒十万次请求。

听着像一道架构题,动起手来才发现,它首先是一道定义题和测量题。

十万是客户端计划发出的请求、网关接收到的请求,还是成功返回正确地址的响应?是在本机回环网卡上跑一分钟,还是跨机器带着 TLS、日志和故障注入稳定运行?

这几个版本,难度完全不是一回事。

这篇文章不虚构一次"周末实测十万"的战绩。我会围绕一个明确的实验项目,先用 Go 标准库搭建可以运行、能持久化、能验证并发行为的单机短链服务,再讨论如何设计压测、识别瓶颈,以及为什么真正的生产级扩容通常会走向 PostgreSQL、Redis 和多实例部署。

代码和正确性测试可以在本地执行;十万 QPS 是要验证的目标,不是本文已经达成的成绩。

如果你刚开始接触高并发服务,可以跟着实现理解请求路径、随机短码、幂等、故障恢复和压测模型;如果你已经维护过线上系统,更值得看的也许是那些限制条件:一次 fsync 为什么不该拖慢所有跳转、热点命中率怎样误导测试结果、缓存失效时为什么会突然出现数据库请求洪峰,以及怎样证明吞吐量提升没有交换掉正确性。

先把十万 QPS 说清楚,否则优化对象都可能找错

QPS 在工程交流里经常被宽泛地用来描述每秒请求量。我们这次给它一个可操作的定义:

在指定测量窗口内,短链入口成功处理的 GET /r/{code} 请求数除以该窗口的秒数。

成功必须同时满足返回 302 Found、Location 与预期地址一致。

创建短链的 POST 单独统计,不混进跳转吞吐量。缺失和过期链接的请求也单独列出,不能因为服务器能飞快地返回 404,就宣布业务跳转能力很强。

假设我们把验收条件暂定为:

指标 本次实验目标
跳转吞吐量 100,000 次正确响应/秒
稳定测量时间 10 分钟
正确响应比例 ≥ 99.9%
端到端响应时间 p99 ≤ 100 ms
压测端丢弃的计划迭代 0
响应语义 302,且 Location 正确

这里的阈值只是实验验收标准示例,不是任何官方短链服务的通用服务等级承诺。真正的预算必须来自用户体验、网络位置和容量成本。

按这个定义,十万 QPS 持续一分钟意味着六百万次请求;十分钟则是六千万次。

即使每个响应按五百一十二字节估算,仅响应数据就约为每秒五十一点二兆字节,仍未计入请求包、TLS、TCP/IP、以太网开销与重传。

不要看到响应正文几乎为空,就以为网络会自动忽略它。状态行、响应头、连接管理和负载均衡器都不是免费的。

还有一个容易混淆的概念是并发数。

假设系统每秒完成十万个请求,而平均请求停留时间为二十毫秒,按利特尔定律:
L=λWL=λWL=λWL=λWL=\lambda WL=λW L=λWL=λWL=λW

代入数据:
L=100000×0.02=2000L=100000×0.02=2000L=100000×0.02=2000L=100000×0.02=2000L=100000\times0.02=2000L=100000×0.02=2000 L=100000×0.02=2000L=100000×0.02=2000L=100000×0.02=2000

在稳定条件下,平均在途请求数约为两千。

这个计算用的是平均停留时间,不能把 p99 直接塞进公式;尾延迟决定容量和用户体验,却不是平均并发数的直接替代品。

请求的到达分布、连接复用率、客户端思考时间、是否跨区域、是否跟随跳转,都会改变压测所需的虚拟用户数量。

我们需要至少区分四类负载:

  • 热门短码重复点击。

  • 短码均匀分布。

  • 从未请求过的短码。

  • 包含创建操作的混合流量。

它们可能落在完全不同的性能区域。

百分之百的热点命中测试,更多是在测 HTTP 框架和内存读取;大量冷数据请求则可能测到磁盘、Redis 或数据库;恶意随机短码请求测到的,也许主要是失败路径和限流能力。

更重要的是,服务端达到十万正确响应,不等于用户最终成功打开原始网站。

浏览器收到 Location 之后,还要进行新的 DNS、TLS 和 HTTP 交互,目标站点可能慢、可能拒绝访问,也可能已经下线。

短链服务能够对自己的跳转决策负责,却不能把下游站点的可用性偷偷算进自己的成功率。

周末项目的需求边界:先做一个诚实的单机版本

业务约束先收紧,目的是减少无意义的变量。

短链创建接口为 POST /api/links,请求包含 url 和 ttl_seconds,要求带 Idempotency-Key 请求头;成功创建返回 201,相同请求重试返回 200 和同一短码,相同幂等键携带不同参数返回 409。

跳转接口为 GET /r/{code},同时支持 HEAD。找到且未过期返回 302,从未存在返回 404,已过期返回 410。

链接在这个版本里一经创建不能修改,也没有删除、用户账户和计费模块。过期表示不能再跳转,但为了明确区分不存在与已过期,记录暂时保留。

这是一个有意为之、也有代价的实验边界:内存会随累计创建数量增长,日志文件也不会自动缩小。

别急着把这个限制忘掉,后面讨论生产设计时,它会重新出现。

第一版不用 Redis,也不连接 PostgreSQL:

短码映射全部驻留在进程内存,创建记录以追加日志落盘,启动时重放。

这样做不是认为文件日志比数据库先进,而是把网络缓存、数据库连接池与业务代码的影响暂时隔离,得到一个能定位问题的基线。

如果基线已经因 JSON 解析、锁争用或压测端不足而卡住,直接塞进分布式组件,多半只会多出几处可以互相甩锅的地方。

相关示意图绘制如下,仅供参考:

这张图描述的是后面代码实际实现的系统,只有一个进程、一个本地日志文件。

跳转路径不碰文件系统;只有创建短链时才写日志。

这里的 sync.Map 不是一个跨实例共享缓存,第二个进程也不会自动知道第一个进程写入了什么。

通过这一点就能看出,单机高吞吐实验与真正的横向扩容是两个课题。

先解决短码:它既是索引,也是对外暴露的标识

短码看似只是数据库主键换一套字符表示,实际上要同时考虑长度、唯一性、可猜测性和生成成本。

自增数字转 Base62 容易实现、无需随机碰撞,但相邻标识通常会暴露创建规模与顺序;对公开短链来说,这会降低批量枚举的门槛。

把完整 URL 做哈希后截断,同样不能跳过冲突处理:截断后的空间仍然有限,不同 URL 仍可能映射到同一短码。

实验里选择十二位 Base62 随机短码,字符来自数字、大写字母和小写字母。

候选空间是:
N=6212≈3.226×1021N=6212≈3.226×1021N=6212≈3.226×1021N=6212≈3.226×1021N=62^{12}\approx3.226\times10^{21}N=6212≈3.226×1021 N=6212≈3.226×1021N=6212≈3.226×1021N=6212≈3.226×1021

对应的信息量约为七十一点四五比特。

用生日悖论近似公式估算:
P(collision)≈1−e−n(n−1)2NP(collision)≈1−e − n(n−1)2N P(collision)≈1−e−2Nn(n−1)P(collision)≈1−e−n(n−1)2NP(\text{collision})\approx 1-e^{-\frac{n(n-1)}{2N}}P(collision)≈1−e−2Nn(n−1) P(collision)≈1−e−n(n−1)2NP(collision)≈1−e−2Nn(n−1)P(collision)≈1−e−2Nn(n−1)

即使累计生成一亿条,至少碰撞一次的概率也仅约百万分之一点五五。

但概率很低,不等于程序可以不查重。

创建时依然检查短码是否已存在,遇到碰撞重新生成;正式方案还需要持久层唯一约束作为最终防线。

随机数来源选择 crypto/rand,不用为了省几行代码拿非密码学随机数生成器来生产公开标识。

实现还使用拒绝采样:一个随机字节在零到二百四十七之间时才接受,再对六十二取余。

因为二百四十八可以被六十二整除,各字符出现概率相同;直接对全部二百五十六个可能值取余会引入轻微偏差。

偏差未必立刻导致故障,但既然处理简单,就没必要留下这个疑点。Go 官方文档明确区分了密码学随机源的用途。

如果将来业务要求用户自定义短码,问题会立刻不同:

自定义字符需要审核、保留字管理和并发唯一性,甚至会出现"用户抢注看起来像官方地址的短码"的风险。

那时随机码生成器仍可保留,但不应被硬塞进同一个分配策略里。

创建链路:先落稳,再让用户拿到地址

创建接口最容易低估的不是随机短码,而是网络超时。

设想客户端发送创建请求,服务端已经把映射写进持久层,却在返回响应时遇到连接中断。

客户端看到超时,不知道是"根本没创建",还是"创建成功但回包丢了"。

如果每次重试都生成新码,同一笔业务会得到多个不同短链,活动素材、审计记录和后续统计就可能对不上。

这里让调用方生成稳定的幂等键,在同一创建意图的所有重试中复用。

服务端同时保存请求摘要,使用摘要判断幂等键是否被错误复用。

这里的"相同"按原始 URL 字符串和有效期参数判断,不擅自把大小写、末尾斜线或者查询参数排序视为等价。

业务要规范化 URL,应当在接口契约里明确规定,而不是让字符串处理函数暗中决定。

相关示意图绘制如下,仅供参考:

关键顺序是先持久化,再发布到内存,最后返回成功。

倘若反过来,代码先把短链放进内存、返回 201,随后磁盘写失败,那么这个链接在服务重启后就消失了。

用户手里拿着一个看似创建成功的地址,系统却找不到记录。

把这类缺陷解释成"偶发数据问题",既不精确,也不太能让运营同学接受。

但写入成功与客户端收到成功之间仍存在不确定窗口。

文件同步后,进程可能还没发出响应就退出了;客户端重试时,只要日志恢复成功,同一幂等键就应返回原短码。

因此持久化记录同时包含幂等键与请求摘要,而不是只保存短码和目标地址。

幂等并不是"把按钮禁用五秒",它是重试跨越失败边界之后,业务操作仍保持同一个结果。

示例把所有创建操作串行化。

这会限制创建吞吐量,但没有锁住跳转读取路径;因为我们的主要目标是读多写少的短链点击服务,先把正确性做扎实比在单机日志上设计十套写入优化划算。

当然,如果需求变成"短时间批量生成数十万条短链",这条串行写链路必须重新评估。

技术方案总得服务于实际流量比例。

跳转链路:把热路径缩到必要的动作

跳转读取流程非常短:

校验短码字符与长度,从内存查询不可变映射,检查绝对过期时间,设置 Location,返回 302。

不要在这条路径里做数据库事务、同步统计落盘、远程内容预览或每次都创建一个重量级日志对象。

真正的高吞吐服务,不是执行步骤很多但每一步都号称"优化过",而是让每个请求只承担它必须承担的责任。

相关示意图绘制如下,仅供参考:

为什么用 302,而不是看起来更"永久"的 301?

因为活动链接可能过期、撤销,甚至在未来引入目的地修改。

301 与 308 的永久重定向语义更容易让客户端长期缓存结果;此处采用临时重定向,并显式返回 Cache-Control: no-store,让每次实验点击都经过自己的服务。

RFC 9110 还明确区分了 302 与 307 对请求方法的语义:

307 不允许自动重定向时改变方法,而历史兼容原因使 302 允许某些客户端把 POST 改成 GET。

我们只允许对短码执行 GET/HEAD,因此不需要把重定向方法转换当成业务特性。

有一个细节经常被遗漏:过期判断必须比较绝对到期时间,不能仅依赖缓存键有没有被清理。

后台清理程序晚跑几分钟,不代表链接能多活几分钟。

示例里的内存记录即使还存在,只要当前时间达到 expires_at,就立即返回 410。

当系统扩展到多机器以后,还需要处理不同机器时钟偏差带来的边界行为。

采用 sync.Map 是因为记录写入一次后主要被读取,恰好符合 Go 官方说明的一个优化场景。

它不保证所有读取在任何情况下都没有内部同步成本,也不意味着一定比带锁的普通 map 快;真正的选择仍取决于键分布、写比例和 Go 运行时实现。

官方文档也明确说,一般情况优先普通 map 加恰当同步。

我们在实验里做这个选择,是为了避开"创建时持有同一把全局读写锁进行慢速文件同步"这个可以预见的错误。

可运行实现:一个进程、一份日志、两条关键路径

环境约定为 Go 模块 shortlab,只有标准库依赖。

代码采用 Go 1.23 已支持的语言和标准库接口写成,便于在基础环境验证;对外部署应选择仍受安全维护的 Go 版本。

截至本文核对官方发布记录,Go 1.27.2 于 2026 年 10 月 8 日发布并包含安全修复,不能因为示例在旧工具链能编译,就把旧工具链当成对外服务的部署建议。

项目目录保持简单:

json 复制代码
shortlab/
├── go.mod
├── main.go
├── main_test.go
└── load.js

不拆十几个包,不是鼓励永远把所有逻辑写在一个文件里,而是让一次周末实验的状态变化能被从头到尾读懂。

等到引入数据库适配器、缓存层和多个 API,再按读写边界拆模块会更自然。

go.mod 内容如下:

go 复制代码
module shortlab

go 1.23

main.go 给出完整的单机实验实现。

它用长度前缀和 CRC32 包装每条 JSON 日志,在启动时识别并截断不完整的末尾记录;遇到完整记录校验错误则拒绝启动,而不是一边加载损坏数据一边祈祷。

CRC32 用于检测意外损坏,不是防篡改签名。

日志文件只允许这个进程独占写入;代码没有实现跨进程文件锁,也没有实现 WAL 压缩。

go 复制代码
package main

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"hash/crc32"
	"io"
	"log"
	"mime"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"
)

const (
	codeLength = 12
	maxRecord  = 8192
	alphabet   = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
)

type Link struct {
	Code      string `json:"code"`
	URL       string `json:"url"`
	ExpiresAt int64  `json:"expires_at"` // Unix milliseconds
	IdemKey   string `json:"idem_key"`
	Hash      string `json:"hash"`
}

type Store struct {
	writes sync.Mutex
	byCode sync.Map // immutable records, read many times
	byKey  sync.Map
	file   *os.File
	failed bool
	now    func() time.Time
}

func OpenStore(path string, now func() time.Time) (*Store, error) {
	f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
	if err != nil {
		return nil, err
	}
	s := &Store{file: f, now: now}
	if err := s.recoverLog(); err != nil {
		f.Close()
		return nil, err
	}
	return s, nil
}

func (s *Store) recoverLog() error {
	info, err := s.file.Stat()
	if err != nil {
		return err
	}
	size := info.Size()
	var off int64
	for off < size {
		if size-off < 8 {
			break
		} // incomplete trailing header
		var header [8]byte
		if _, err := s.file.ReadAt(header[:], off); err != nil {
			return err
		}
		n := binary.BigEndian.Uint32(header[:4])
		if n == 0 || n > maxRecord {
			return fmt.Errorf("invalid record length at %d", off)
		}
		if size-off-8 < int64(n) {
			break
		} // incomplete trailing payload
		raw := make([]byte, n)
		if _, err := s.file.ReadAt(raw, off+8); err != nil {
			return err
		}
		if crc32.ChecksumIEEE(raw) != binary.BigEndian.Uint32(header[4:]) {
			return fmt.Errorf("checksum mismatch at %d", off)
		}
		var l Link
		if err := json.Unmarshal(raw, &l); err != nil {
			return err
		}
		if !validCode(l.Code) || !validKey(l.IdemKey) || l.URL == "" {
			return fmt.Errorf("invalid record at %d", off)
		}
		if _, exists := s.byCode.Load(l.Code); exists {
			return fmt.Errorf("duplicate code in journal")
		}
		if _, exists := s.byKey.Load(l.IdemKey); exists {
			return fmt.Errorf("duplicate idempotency key in journal")
		}
		s.byCode.Store(l.Code, l)
		s.byKey.Store(l.IdemKey, l)
		off += int64(8 + n)
	}
	if off != size {
		if err := s.file.Truncate(off); err != nil {
			return err
		}
		if err := s.file.Sync(); err != nil {
			return err
		}
	}
	_, err = s.file.Seek(off, io.SeekStart)
	return err
}

func (s *Store) Close() error {
	s.writes.Lock()
	defer s.writes.Unlock()
	return s.file.Close()
}

func (s *Store) Healthy() bool {
	s.writes.Lock()
	defer s.writes.Unlock()
	return !s.failed
}

func randomCode() (string, error) {
	var out [codeLength]byte
	filled := 0
	for filled < len(out) {
		var b [32]byte
		if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
			return "", err
		}
		for _, x := range b {
			if x >= 248 {
				continue
			} // 248 is divisible by 62
			out[filled] = alphabet[int(x)%len(alphabet)]
			filled++
			if filled == len(out) {
				break
			}
		}
	}
	return string(out[:]), nil
}

func validCode(code string) bool {
	if len(code) != codeLength {
		return false
	}
	for i := 0; i < len(code); i++ {
		if !strings.ContainsRune(alphabet, rune(code[i])) {
			return false
		}
	}
	return true
}

func validKey(key string) bool {
	if len(key) < 8 || len(key) > 64 {
		return false
	}
	for i := 0; i < len(key); i++ {
		c := key[i]
		if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' ||
			c >= '0' && c <= '9' || c == '-' || c == '_') {
			return false
		}
	}
	return true
}

func validURL(raw string) bool {
	if len(raw) == 0 || len(raw) > 2048 || strings.TrimSpace(raw) != raw ||
		strings.ContainsAny(raw, "\r\n") {
		return false
	}
	u, err := url.Parse(raw)
	if err != nil {
		return false
	}
	return (u.Scheme == "http" || u.Scheme == "https") &&
		u.Hostname() != "" && u.User == nil && u.Opaque == ""
}

func fingerprint(target string, ttl int64) string {
	h := sha256.Sum256([]byte(target + "\x00" + strconv.FormatInt(ttl, 10)))
	return hex.EncodeToString(h[:])
}

var errConflict = errors.New("idempotency key reused with different request")
var errUnavailable = errors.New("journal unavailable")

func (s *Store) Create(key, target string, ttl int64) (Link, bool, error) {
	s.writes.Lock()
	defer s.writes.Unlock()
	if s.failed {
		return Link{}, false, errUnavailable
	}
	hash := fingerprint(target, ttl)
	if prev, ok := s.byKey.Load(key); ok {
		l := prev.(Link)
		if l.Hash != hash {
			return Link{}, false, errConflict
		}
		return l, false, nil
	}
	for attempt := 0; attempt < 8; attempt++ {
		code, err := randomCode()
		if err != nil {
			return Link{}, false, err
		}
		if _, taken := s.byCode.Load(code); taken {
			continue
		}
		l := Link{
			Code:      code,
			URL:       target,
			ExpiresAt: s.now().Add(time.Duration(ttl) * time.Second).UnixMilli(),
			IdemKey:   key,
			Hash:      hash,
		}
		raw, err := json.Marshal(l)
		if err != nil {
			return Link{}, false, err
		}
		var header [8]byte
		binary.BigEndian.PutUint32(header[:4], uint32(len(raw)))
		binary.BigEndian.PutUint32(header[4:], crc32.ChecksumIEEE(raw))
		payload := append(header[:], raw...)
		n, err := s.file.Write(payload)
		if err == nil && n != len(payload) {
			err = io.ErrShortWrite
		}
		if err == nil {
			err = s.file.Sync()
		}
		if err != nil {
			s.failed = true // no more writes until operator recovery
			return Link{}, false, fmt.Errorf("%w: %v", errUnavailable, err)
		}
		s.byCode.Store(code, l) // publish only AFTER durable append
		s.byKey.Store(key, l)
		return l, true, nil
	}
	return Link{}, false, fmt.Errorf("too many code collisions")
}

func (s *Store) Lookup(code string) (Link, bool) {
	v, ok := s.byCode.Load(code)
	if !ok {
		return Link{}, false
	}
	return v.(Link), true
}

type App struct {
	store   *Store
	baseURL string
}

type createRequest struct {
	URL        string `json:"url"`
	TTLSeconds int64  `json:"ttl_seconds"`
}

func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	switch {
	case r.URL.Path == "/readyz" && r.Method == http.MethodGet:
		if !a.store.Healthy() {
			http.Error(w, "unavailable", http.StatusServiceUnavailable)
			return
		}
		w.Write([]byte("ok\n"))
	case r.URL.Path == "/api/links" && r.Method == http.MethodPost:
		a.create(w, r)
	case strings.HasPrefix(r.URL.Path, "/r/"):
		if r.Method != http.MethodGet && r.Method != http.MethodHead {
			w.Header().Set("Allow", "GET, HEAD")
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}
		a.redirect(w, r)
	default:
		http.NotFound(w, r)
	}
}

func (a *App) create(w http.ResponseWriter, r *http.Request) {
	if !validKey(r.Header.Get("Idempotency-Key")) {
		http.Error(w, "invalid Idempotency-Key", http.StatusBadRequest)
		return
	}
	typ, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if err != nil || typ != "application/json" {
		http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, 4096)
	defer r.Body.Close()
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	var req createRequest
	if err := dec.Decode(&req); err != nil {
		badJSON(w, err)
		return
	}
	var trailing any
	if err := dec.Decode(&trailing); err != io.EOF {
		if err == nil {
			err = fmt.Errorf("extra JSON value")
		}
		badJSON(w, err)
		return
	}
	if !validURL(req.URL) || req.TTLSeconds < 60 || req.TTLSeconds > 2592000 {
		http.Error(w, "invalid url or ttl_seconds (60..2592000)", http.StatusUnprocessableEntity)
		return
	}
	l, created, err := a.store.Create(
		r.Header.Get("Idempotency-Key"),
		req.URL,
		req.TTLSeconds,
	)
	switch {
	case errors.Is(err, errConflict):
		http.Error(w, err.Error(), http.StatusConflict)
		return
	case err != nil:
		log.Printf("create failed: %v", err)
		http.Error(w, "storage unavailable", http.StatusServiceUnavailable)
		return
	}
	status := http.StatusOK
	if created {
		status = http.StatusCreated
	}
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(map[string]any{
		"code":       l.Code,
		"short_url":  a.baseURL + "/r/" + l.Code,
		"expires_at": l.ExpiresAt,
	})
}

func badJSON(w http.ResponseWriter, err error) {
	var limit *http.MaxBytesError
	if errors.As(err, &limit) {
		http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
	} else {
		http.Error(w, "invalid JSON", http.StatusBadRequest)
	}
}

func (a *App) redirect(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Cache-Control", "no-store")
	code := strings.TrimPrefix(r.URL.Path, "/r/")
	if !validCode(code) {
		http.NotFound(w, r)
		return
	}
	l, found := a.store.Lookup(code)
	if !found {
		http.NotFound(w, r)
		return
	}
	if a.store.now().UnixMilli() >= l.ExpiresAt {
		http.Error(w, "expired", http.StatusGone)
		return
	}
	w.Header().Set("Location", l.URL)
	w.WriteHeader(http.StatusFound)
}

func main() {
	data := os.Getenv("DATA_FILE")
	if data == "" {
		data = "./links.wal"
	}
	addr := os.Getenv("ADDR")
	if addr == "" {
		addr = "127.0.0.1:8080"
	}
	base := strings.TrimRight(os.Getenv("BASE_URL"), "/")
	if base == "" {
		base = "http://127.0.0.1:8080"
	}
	store, err := OpenStore(data, time.Now)
	if err != nil {
		log.Fatal(err)
	}
	defer store.Close()
	server := &http.Server{
		Addr:              addr,
		Handler:           &App{store: store, baseURL: base},
		ReadHeaderTimeout: 2 * time.Second,
		ReadTimeout:       5 * time.Second,
		WriteTimeout:      5 * time.Second,
		IdleTimeout:       60 * time.Second,
		MaxHeaderBytes:    8 << 10,
	}
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		<-stop
		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := server.Shutdown(ctx); err != nil {
			log.Printf("shutdown: %v", err)
		}
	}()
	log.Printf("listening on %s", addr)
	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

像做 Code Review 一样审视这份实现

先看 Store 的两套索引。

byCode 服务跳转,byKey 服务创建重试;二者指向同一份不可变记录的值,而不是维护两份可能各自修改的业务对象。

新增记录时只允许在写锁内检查、追加并发布,不存在"两个并发请求都看见短码未被使用,于是都抢着写入"的窗口。

sync.Map 负责安全地并发读取,但跨字段业务不变量仍由创建链路上的互斥锁维护。

这一点要分清:并发容器不是分布式事务,也不会替你理解业务约束。

其次看日志格式。

每条记录在 JSON 前面放八字节头部:四字节长度、四字节校验和。

启动时只读取已经完整写出的记录,发现末尾不足一条时截断;如果记录长度非法,或完整记录的校验和不对,就失败退出。

这样处理故意保守:

末尾半条记录很可能来自进程意外退出,完整记录损坏却可能来自磁盘、文件系统或者误操作,不能因为"想让服务起来"就把它静默跳过。

生产级存储还需要文件锁、备份、校验、目录同步语义、写入故障注入和恢复工具,别把这几十行当成通用数据库引擎。

file.Sync() 是创建路径上的性能成本,也是当前持久化承诺的关键环节。

同步成功之后才发布记录,意味着读取请求不会看见服务尚未确认完成持久化的短链。

这里也有一个细微但重要的边界:

Sync 失败时,调用方拿到 503,并不必然表示磁盘完全没留下这次写入。

磁盘可能写了一部分,也可能已经写完整但确认失败。

因此示例在写故障后停止接收新的创建请求,要求人工检查并重启恢复,而不是继续朝一个状态未知的文件末尾追加。

客户端重试仍使用原来的幂等键,避免故障恢复后生成第二条链接。

再看参数校验。

我们限制 URL 长度、方案类型和用户名信息,拒绝非法 JSON、未知字段、过大的请求体;有效期只接受规定范围内的正整数。

显式限制请求体有助于避免一个无需认证的实验接口被大型请求拖住资源。

但 http.MaxBytesReader 并不能替代反向代理层的连接数限制、慢请求保护或者身份认证;浏览器访问一个危险目的地也不会因为 URL 是合法的 HTTPS 字符串就自动变安全。

最后看 HTTP Server:

读取请求头、读取整个请求、写响应以及空闲连接都有独立超时设置。

它们不是"超时越小越快"的旋钮,设得太激进会误伤高延迟客户端;设得无限大,又会让慢连接长期占用资源。

Go 的 net/http 文档定义了这些配置的语义,应该结合流量入口、代理超时和真实网络条件调整。

怎么启动,怎样确认它真的在工作

先执行 go test -race ./... 和 go vet ./...,再运行服务。

默认监听本机回环地址,是因为这个版本还没有认证和滥用防护,不应该直接挂在公网。

下面的示例使用当前工作目录中的日志文件,第二次启动会读取同一份文件恢复数据。

json 复制代码
mkdir shortlab && cd shortlab

# 将上面的 go.mod、main.go 以及后面的 main_test.go 保存到当前目录
go test -race ./...
go vet ./...
go run .

另开一个终端创建短链:

json 复制代码
curl -i http://127.0.0.1:8080/api/links \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: activity_0001' \
  --data '{"url":"https://example.com/article","ttl_seconds":3600}'

成功的响应状态应为 201 Created,正文包含 code、short_url 和以 Unix 毫秒表示的 expires_at。

代码每次运行会随机生成,不能把某个示意短码当成固定预期。

随后用响应中的短码请求 /r/{code},应返回 302 和原始 URL 的 Location;加上 curl -I 可以验证 HEAD 路径。

再次提交完全相同的创建请求,应返回 200 和相同的短码。

把同一幂等键下的目标地址换掉,应得到 409,而不是悄悄覆盖已经传播出去的链接。

json 复制代码
# 用实际返回的 code 替换 CODE
curl -i --max-redirs 0 http://127.0.0.1:8080/r/CODE
curl -I http://127.0.0.1:8080/r/CODE

这里故意不使用 curl -L。

-L 会跟随重定向,发出另一条到目标网站的请求;检查短链服务时,那是多余的测量噪声。

生产诊断时也一样:

先确定网关是否按预期生成重定向,再观察浏览器访问目标站点的第二次请求,两个问题不要混成一个。

测试不能只看正常路径:最危险的是请求刚好断在中间

短链服务的正确性有两类容易被压测吞掉的问题。

一类是业务语义错误,例如幂等键复用导致旧链接被覆盖;另一类是恢复错误,例如进程重启以后映射消失。

前者在低并发单元测试里可能看不出来,后者在只测热内存的性能脚本里更是毫无存在感。

它们却可能直接决定这个系统能不能被拿去放进实际业务。

测试通过 httptest 直接调用 HTTP 处理器,不需要真的占用网络端口;时钟以函数方式注入,使过期测试能够精确移动到边界,不必让测试进程真的睡一分钟。

测试还会模拟日志末尾写了一半、记录内容被破坏,以及四十个请求并发使用同一幂等键。

下面的文件和前面的 main.go 可以一起运行。

main_test.go:

go 复制代码
package main

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"
)

func testApp(t *testing.T) (*App, *Store, *time.Time) {
	t.Helper()
	now := time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC)
	s, err := OpenStore(
		filepath.Join(t.TempDir(), "test.wal"),
		func() time.Time { return now },
	)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { s.Close() })
	return &App{
		store:   s,
		baseURL: "http://127.0.0.1:8080",
	}, s, &now
}

func doCreate(
	a *App,
	key, body string,
) *httptest.ResponseRecorder {
	r := httptest.NewRequest(
		http.MethodPost,
		"/api/links",
		strings.NewReader(body),
	)
	r.Header.Set("Content-Type", "application/json")
	r.Header.Set("Idempotency-Key", key)
	w := httptest.NewRecorder()
	a.ServeHTTP(w, r)
	return w
}

const goodBody = `{"url":"https://example.com/a?q=1","ttl_seconds":60}`

func TestCreateReplayExpireRestart(t *testing.T) {
	a, s, clock := testApp(t)

	w := doCreate(a, "request_0001", goodBody)
	if w.Code != 201 {
		t.Fatalf("create: %d %s", w.Code, w.Body)
	}

	var got struct {
		Code      string `json:"code"`
		ShortURL  string `json:"short_url"`
		ExpiresAt int64  `json:"expires_at"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
		t.Fatal(err)
	}
	if !validCode(got.Code) {
		t.Fatal("bad code")
	}

	w2 := doCreate(a, "request_0001", goodBody)
	if w2.Code != 200 {
		t.Fatalf("replay: %d", w2.Code)
	}
	if !bytes.Equal(w.Body.Bytes(), w2.Body.Bytes()) {
		t.Fatal("replay response differs")
	}

	r := httptest.NewRequest(
		http.MethodGet,
		"/r/"+got.Code,
		nil,
	)
	x := httptest.NewRecorder()
	a.ServeHTTP(x, r)

	if x.Code != 302 ||
		x.Header().Get("Location") != "https://example.com/a?q=1" {
		t.Fatalf("redirect %d %+v", x.Code, x.Header())
	}

	*clock = clock.Add(60 * time.Second)
	x = httptest.NewRecorder()
	a.ServeHTTP(x, r)
	if x.Code != 410 {
		t.Fatalf("expiry: %d", x.Code)
	}

	path := s.file.Name()

	// 模拟关闭后日志末尾存在未写完的记录
	if err := s.Close(); err != nil {
		t.Fatal(err)
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := f.Write([]byte{0, 0, 0}); err != nil {
		t.Fatal(err)
	}
	if err := f.Close(); err != nil {
		t.Fatal(err)
	}

	reopened, err := OpenStore(
		path,
		func() time.Time { return *clock },
	)
	if err != nil {
		t.Fatal(err)
	}
	defer reopened.Close()

	saved, ok := reopened.Lookup(got.Code)
	if !ok || saved.URL != "https://example.com/a?q=1" {
		t.Fatal("lost persisted link")
	}
}

func TestInvalidInputsAndMethods(t *testing.T) {
	a, _, _ := testApp(t)

	cases := []struct {
		key, body string
		want      int
	}{
		{"bad", goodBody, 400},
		{
			"request_0002",
			`{"url":"javascript:alert(1)","ttl_seconds":60}`,
			422,
		},
		{
			"request_0003",
			`{"url":"https://example.com","ttl_seconds":59}`,
			422,
		},
		{
			"request_0004",
			`{"url":"https://example.com","ttl_seconds":2592001}`,
			422,
		},
		{
			"request_0005",
			`{"url":"https://example.com","ttl_seconds":60,"extra":1}`,
			400,
		},
		{
			"request_0006",
			`{"url":"https://example.com","ttl_seconds":60} {}`,
			400,
		},
		{
			"request_0007",
			`{"url":"https://example.com/` +
				strings.Repeat("x", 5000) +
				`","ttl_seconds":60}`,
			413,
		},
	}

	for _, tc := range cases {
		w := doCreate(a, tc.key, tc.body)
		if w.Code != tc.want {
			t.Errorf(
				"%s: got %d want %d",
				tc.key, w.Code, tc.want,
			)
		}
	}

	w := doCreate(a, "request_0008", goodBody)
	if w.Code != 201 {
		t.Fatal(w.Code)
	}
	w = doCreate(
		a,
		"request_0008",
		`{"url":"https://another.example/","ttl_seconds":60}`,
	)
	if w.Code != 409 {
		t.Fatalf("conflict: %d", w.Code)
	}

	r := httptest.NewRequest(
		http.MethodGet,
		"/r/not-a-code",
		nil,
	)
	v := httptest.NewRecorder()
	a.ServeHTTP(v, r)
	if v.Code != 404 {
		t.Fatal(v.Code)
	}

	r = httptest.NewRequest(
		http.MethodPost,
		"/r/AAAAAAAAAAAA",
		nil,
	)
	v = httptest.NewRecorder()
	a.ServeHTTP(v, r)
	if v.Code != 405 {
		t.Fatal(v.Code)
	}
}

func TestConcurrentSameKey(t *testing.T) {
	a, s, _ := testApp(t)

	var wg sync.WaitGroup
	codes := make(chan string, 40)

	for i := 0; i < 40; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()

			w := doCreate(a, "request_same", goodBody)
			if w.Code != 200 && w.Code != 201 {
				t.Errorf("status %d", w.Code)
				return
			}

			var x struct {
				Code string `json:"code"`
			}
			if err := json.Unmarshal(
				w.Body.Bytes(), &x,
			); err != nil {
				t.Error(err)
				return
			}
			codes <- x.Code
		}()
	}

	wg.Wait()
	close(codes)

	first := ""
	for code := range codes {
		if first == "" {
			first = code
		}
		if code != first {
			t.Fatal("idempotency failed")
		}
	}
	if _, ok := s.Lookup(first); !ok {
		t.Fatal("missing record")
	}
}

func TestCorruptFullRecordFailsClosed(t *testing.T) {
	a, s, _ := testApp(t)

	if doCreate(
		a,
		"request_9999",
		goodBody,
	).Code != 201 {
		t.Fatal("create failed")
	}

	path := s.file.Name()
	if err := s.Close(); err != nil {
		t.Fatal(err)
	}

	f, err := os.OpenFile(path, os.O_RDWR, 0600)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := f.WriteAt([]byte("!"), 10); err != nil {
		t.Fatal(err)
	}
	if err := f.Close(); err != nil {
		t.Fatal(err)
	}

	if store, err := OpenStore(path, time.Now); err == nil {
		store.Close()
		t.Fatal("corruption should prevent startup")
	}
}

这些测试重点并非覆盖率数字,而是明确了失败方式。

服务重启后丢链接,属于持久化语义失败;同一幂等键并发得到两个短码,属于原子性失败;过期时间到了仍返回跳转,属于业务契约失败;完整记录被改坏后程序还继续加载,属于恢复策略失败。

测试能捕获其中一部分,不能因此说已经证明断电安全、文件系统可靠或者公网安全。

还可以把错误输入整理成一张持续扩充的回归清单:

短链接服务测试用例汇总表:

编号 输入或故障场景 预期行为 验证重点
1 正常 URL、有效 TTL、新幂等键 201 Created 返回完整短链与绝对过期时间
2 完全相同的请求重放 200 OK 短码相同,有效期不被续延
3 同一幂等键搭配不同 URL 409 Conflict 不覆盖第一条记录
4 缺少请求头、非法短码 400 或 404 不发生存储写入
5 非 HTTP(S) URL、TTL 越界 422 Unprocessable Content 不创建短链映射
6 已存在但到期的短码 410 Gone 到期瞬间不能继续跳转
7 未知字段、连续两份 JSON、超大正文 400 或 413 请求解析有明确边界
8 服务重启、日志末尾存在残缺记录 恢复完整记录 不把半条写入当作成功
9 完整日志记录校验失败 拒绝启动 不默默忽略损坏记录
10 创建时持久化失败 503 Service Unavailable 禁止继续写入状态未知的日志

测试分类:

测试类别 对应用例 测试目标
正常功能 1 验证短链创建成功
幂等性 2、3 保证重复请求结果一致,防止数据覆盖
参数与输入校验 4、5、7 拒绝非法输入,控制解析边界
过期处理 6 确保过期短链不可访问
持久化与恢复 8、9 保证重启后数据完整、损坏可检测
故障处理 10 保证持久化失败时系统进入安全状态

有些场景必须进一步用故障注入验证,比如把文件系统切换为只读、模拟写满磁盘、在 Write 与 Sync 之间杀死进程、在 Sync 与响应之间杀死进程。

普通单元测试可以模拟接口错误,却很难完全复现实际存储设备的断电顺序。

验证这类能力时,应记录操作系统、文件系统、虚拟化层和持久化配置,而不是用一句"单测通过"盖章。

相关示意图绘制如下,仅供参考:

这张图强调的是故障后的重试结果,不是要求每次客户端重试都重启服务。

正常情况下,同一进程收到重复请求直接查幂等索引即可。

图中的重启与重放专门用来解释持久化确认不确定的情况;如果日志恢复时发现完整记录校验失败,本实现会停机等待处理,而不会走向"随便补建一条"的路径。

别忘了一个现实约束:

这份单机实现将幂等索引与全部历史短码永久留在内存里,创建频率一高,内存、启动重放时间和日志长度都会线性增加。

过期不是自动回收。

若要长期运行,就需要设计幂等键保留期限、墓碑、日志快照与压缩,或者把持久化职责交给真正的数据库。

压测脚本:让客户端不替服务器虚构成绩

正确性跑通之后,才能开始聊每秒十万个跳转。

压测工具选 k6,原因不是它名字响亮,而是它支持固定到达速率场景:脚本可以计划每秒启动固定数量的迭代,不必等上一批请求全部完成才发下一批。

这种开放模型更适合观察服务在持续外部流量下是否开始排队。

k6 官方把 constant-arrival-rate 描述为开放模型执行器,同时提供 dropped_iterations 指标,记录因为虚拟用户不足等原因没有真正启动的计划迭代。

下面脚本刻意关闭自动跟随跳转,把判断条件落在 302 和 Location 上。

通过环境变量配置请求速率、测量时长、虚拟用户上下限、短码和目标地址,便于重复同一个实验。

示例的 p99 < 100 ms、错误率和检查通过率阈值都是前文约定的实验预算。

脚本已经做过 JavaScript 语法检查;由于当前验证环境没有安装 k6,本文不提供任何声称由 k6 跑出的吞吐量数据。

load.js:

js 复制代码
import http from 'k6/http';
import { check } from 'k6';

const rate = Number(__ENV.RATE || '1000');
const duration = __ENV.DURATION || '60s';
const preAllocatedVUs = Number(__ENV.VUS || '200');
const maxVUs = Number(__ENV.MAX_VUS || '1000');

const base = __ENV.BASE || 'http://127.0.0.1:8080';
const code = __ENV.CODE || '';
const target = __ENV.TARGET || '';

export const options = {
  discardResponseBodies: true,

  scenarios: {
    redirect: {
      executor: 'constant-arrival-rate',
      rate,
      timeUnit: '1s',
      duration,
      preAllocatedVUs,
      maxVUs,
    },
  },

  thresholds: {
    checks: ['rate>0.999'],
    http_req_failed: ['rate<0.001'],
    http_req_duration: ['p(99)<100'],
    dropped_iterations: ['count==0'],
  },
};

export default function () {
  const res = http.get(`${base}/r/${code}`, {
    redirects: 0,
  });

  check(res, {
    '302 Found': (r) => r.status === 302,
    'Location exact': (r) => r.headers.Location === target,
  });
}

运行前先生成一条有效期足够覆盖整个压测窗口的短链,把短码和目标地址代入环境变量。

先从低速率开始,逐步上调,不要直接在开发笔记本上把 RATE 填成十万,然后对一张满是超时的截图做架构学研究。

json 复制代码
k6 run -e BASE=http://127.0.0.1:8080 \
  -e CODE=YOUR_CODE \
  -e TARGET=https://example.com/article \
  -e RATE=1000 -e DURATION=60s \
  -e VUS=200 -e MAX_VUS=1000 load.js

正式冲击大流量之前,我会把压测器放到独立机器,至少记录客户端 CPU、虚拟用户使用率、发包速率、失败类型、网卡吞吐和系统文件描述符数量。

客户端计划十万次,不代表服务器收到十万次;dropped_iterations 为零,也不单独证明服务器每秒成功完成了十万次。

必须交叉核对压测端已发送请求、入口负载均衡器实际接收请求、服务端处理数和正确 302 数量,并明确各自统计窗口。

此外,k6 的默认 HTTP 指标与业务检查是两回事。

某次请求即使返回了一个 HTTP 层面允许的重定向状态,也可能带着错误的 Location。

所以脚本既有协议层错误率阈值,也有对响应头内容的检查。

在多短码混合场景里,可以使用参数化数据集保存每个短码的期望目标,避免只测一个"永久热门键"。

Grafana 官方阈值机制支持对响应时间、错误率和自定义检查设定通过条件,但阈值通过的前提仍是负载生成符合实验设计。

不看火焰图就开始优化,和闭眼调数据库参数有什么区别

压测读数开始变化时,第一件事不是给 Go 进程加更多 goroutine。

服务端吞吐停住,可能是 CPU 饱和,也可能是客户端已经发不出请求;可能是内存分配触发频繁垃圾回收,也可能是网络队列、反向代理、TLS 握手或短码分布造成了瓶颈。

先看现象再提出假设,比猜一个流行组件来"增强架构"有效得多。

可以给实验分几级:

第一层只测 Lookup 和过期判断的微基准,了解内存结构的成本;第二层用 httptest 测处理器,不让网卡与真实连接参与;第三层通过本地 TCP 发完整 HTTP 请求;第四层从独立压测机访问服务;第五层才把 TLS、入口代理、更多实例与共享存储逐一加回来。

每向外增加一层,都记录新增加的延迟和资源开销。

这样性能下降时才知道该检查哪一层,而不是拿一张总 QPS 曲线反复解释人生。

在单机 Go 服务上,我会同时观察 CPU 使用率、可运行 goroutine 数、GC 暂停与分配速率、堆空间、网络收发、连接数、错误码分布以及线程和文件描述符。

sync.Map 读取可能表现不错,但每个请求仍然要由 HTTP 解析器处理、构造响应头、写入 socket。

为了方便诊断而在热路径上拼接大量字符串或同步写日志,也可能比查表本身更贵。

pprof 可以帮助定位 CPU 和堆分配热点,不过诊断端口必须限制在可信网络,不能直接暴露到公网。

还要警惕一类"压测优化":

关掉所有日志、关闭 TLS、改成只返回固定字符串,然后展示一个亮眼数字。

这样的实验可以作为组件上限测试,却不能叫完整短链系统吞吐量。

业务验收至少要求真正查到映射、正确计算过期、生成对应的 Location,并让日志、监控和入口安全策略以接近生产的方式运行。

优化前后必须保持同一套正确性断言,否则是在换题考试。

先做容量账,再决定该加机器还是改代码

容量规划可以从资源预算反推。

假设某个实现平均每个请求真正消耗一百微秒 CPU 时间,不考虑其他瓶颈,十万请求每秒就需要:

100000×0.0001=10100000\times0.0001=10100000×0.0001=10

也就是每秒十个 CPU 核心秒,相当于十个逻辑核心持续满负荷工作。

若只想把这部分负载压到百分之五十利用率,光这项成本就需要约二十个逻辑核心。

这只是算术模型,不是对本项目做出的 CPU 实测。

平均 CPU 服务时间必须来自实际 profile 或负载测试,不能从代码行数猜。

CPU 账算通以后,还需要单独算网络和内存。

网卡上的流量不仅包括 302 响应,还包括请求头和协议开销;反向代理若每个请求都做复杂规则匹配、身份检查或日志采集,就可能在服务进程之前先达到上限。

内存则取决于累计保存多少条链接、每条记录多长、sync.Map 的对象开销、垃圾回收和进程启动时的临时分配。

短码是十二字节,并不意味着"一亿条链接只占十二亿字节"。

映射值、字符串、哈希表结构、幂等键和运行时管理开销都要计入。

另一个关键变量是热点分布。

假设有一百万条链接,但绝大部分请求落在极少数热门短码上,局部缓存效果可能非常好。

换成每次请求都查询不同短码,同一台机器的 CPU 缓存局部性、数据工作集和后续远程缓存命中率都会变化。

为了得到对外有意义的性能结论,压测报告不能只附一个最高吞吐量,还应附数据集大小、热键占比、读写比例、命中率、请求方法、连接复用策略及请求持续时间。

真正值得警惕的是资源曲线的拐点。

达到某个负载前,平均延迟可能变化不大;越过服务能力边界后,队列开始积压、尾延迟抬升、超时重试又制造更多请求,系统可能从"基本正常"迅速滑向"不太想接电话"。

所以容量评估不仅要找最大值,还要找在故障、流量偏斜和突然突发条件下,系统能够保持稳定的工作区间。

留余量不是浪费,是给异常恢复留下空间。

Redis 什么时候该上场?等我们真的需要共享读取能力时

到这里,单机实验的主要限制已经很清楚:

日志绑定单个进程、全部映射在内存中、重启需要重放、没有跨实例同步,创建也受同步写入速度限制。

即使这个实现能在某台机器的某种热点负载下跑到很高的数字,也不能简单复制几个进程就宣布线性扩容。

多个进程共享同一日志会发生写入竞争;各自持有本地日志,则短码可能在一台实例上存在、另一台实例上不存在。

负载均衡并不能把错误的数据模型变正确。

因此生产演进时,我更倾向于让 PostgreSQL 持有权威短链记录与幂等约束,让 Redis 缓存热门映射,让多个 Go 实例只处理业务读写请求。

每次创建先进入数据库事务,数据库提交成功后尝试填充或失效缓存;缓存不可用时可以在受控并发下回源数据库。

必要时再考虑进程内一级缓存。

它不是"在现有代码里加一个 Redis 客户端"这么简单,因为单机文件日志需要退出权威数据位置,数据库唯一约束和跨实例幂等必须重新实现。

相关示意图绘制如下,仅供参考:

这是后续生产演进设计图,不是前面 Go 代码已经部署的组件。

图中直连数据库的箭头既代表创建写入,也代表缓存未命中后的应用回源;Redis 服务器不会自己去连接 PostgreSQL。

读请求先查缓存,未命中再查数据库、检查状态和有效期、回填缓存;写请求先提交数据库,再处理缓存。

Redis 官方的 cache-aside 文档也把热点缓存、TTL、有界陈旧性和缓存击穿列为这一模式的核心问题。

数据表的骨架可以保持很朴素:

sql 复制代码
CREATE TABLE short_links (
    code           VARCHAR(12) PRIMARY KEY,
    owner_id       BIGINT NOT NULL,
    idem_key       VARCHAR(64) NOT NULL,
    request_hash   CHAR(64) NOT NULL,
    destination    TEXT NOT NULL,
    expires_at     TIMESTAMPTZ NOT NULL,
    revoked_at     TIMESTAMPTZ,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (owner_id, idem_key)
);

CREATE INDEX idx_short_links_expiry
ON short_links (expires_at);

code 主键保证不会有两个记录使用相同公开短码,(owner_id, idem_key) 保证同一用户范围内重复创建请求不会生成两个业务结果。

创建时应在事务里处理唯一冲突,再读取原记录并校验请求摘要;短码冲突可换码重试,幂等键冲突则取原结果。

PostgreSQL 官方提供 ON CONFLICT 处理唯一约束冲突,但它不会替业务判断"同一幂等键的 URL 是否不同"。

数据库负责原子约束,业务负责解释约束。

为什么不是一开始就用 Redis 当唯一数据库?

Redis 本身当然有持久化和高可用能力,也能承载许多数据模型;但要把它作为权威记录,必须严肃讨论持久化配置、复制故障切换、数据丢失容忍度、备份和恢复语义。

对需要长期保留映射和审计字段的短链业务,用关系型数据库做权威存储,往往让约束与数据生命周期更容易表达。

反过来,如果这是一个明确允许丢失、短生命周期、没有审核与审计需求的临时跳转服务,方案可以轻得多。

选型依据是约束,而不是组件排名。

缓存让读取变快,也让失效规则变得必须清楚

短链创建后基本不变,这对缓存很友好。

但"基本不变"不是"永远不变"。

链接可能被管理员撤销,可能因为投诉需要立即停止跳转,也可能自然过期。

一旦允许撤销,Redis 缓存、进程内一级缓存和客户端缓存都可能持有旧映射。

为了节省一次远程读取,把撤销命令优化成无效操作,是性能系统里特别昂贵的一种胜利。

对只创建、只过期的不可变链接,缓存值除了目标地址,还必须带绝对 expires_at,应用在每次跳转前重新检查。

Redis 键 TTL 可以设置为业务剩余有效期与缓存上限的较小值,避免缓存键比短链本身活得久。

Redis 的 SET 支持 EX、PX 等过期参数,过期处理机制也有官方定义;但键从缓存中消失,不等于权威数据库记录应当同步删除。

加入撤销后,选择会更复杂。

最保守的方案是把状态查询留在权威层,牺牲部分性能换明确的即时性;常见的折中是数据库先提交撤销,再主动删除 Redis 键,缓存读取仍须处理删除失败引起的短暂旧值。

若要求严格的撤销生效上限,需要定义一个可量化的窗口,并设计失效事件、版本号、短 TTL 和故障补偿。

多实例 L1 缓存尤其棘手,因为 Redis 删除成功不代表每个 Go 进程都同步删除了旧副本。

缓存未命中也分几种情况。

真不存在的短码可以短暂负缓存,避免恶意随机探测直接冲击数据库;但不能把数据库超时、连接池耗尽或者 Redis 故障误判为"业务不存在",否则就会把一次依赖故障转换成大面积的错误 404。

对 Redis 未命中但数据库可用的情况,可以用局部请求合并、并发回源上限和短时间退避防止热点失效引起请求风暴。

Redis 不需要神化,缓存命中时它很方便,缓存没命中时系统依然需要生存能力。

另外,不必假装所有短链请求都应该经过 Redis。

假如某条链接极其热门、允许秒级撤销窗口,受控的进程内缓存能进一步减少网络跳数;假如业务要求撤销立即生效,就必须放弃一些缓存自由度。

有没有 L1、L1 活多久、是否允许 CDN 缓存重定向,应该由业务的一致性预算来决定,而不是由某张组件架构图的对称美感决定。

十万 QPS 的安全题,比性能题更容易被忽视

公开短链服务本质上提供了一种重定向能力,这意味着它也可能被滥用于钓鱼、垃圾短信、恶意软件下载和绕过某些网站过滤。

限制 URL 必须是 HTTP 或 HTTPS,只能挡住显而易见的 javascript: 等非法方案,不能证明目标网站可信。

创建接口至少要考虑认证或配额、域名与内容安全策略、投诉处理、黑名单和人工处置流程。

公开服务不能直接照搬这个没有账户体系的周末 Demo。

有些团队会在创建时抓取目标网页标题、图片或安全评分。

功能听起来不错,但服务器主动访问用户提供的地址,会引入服务端请求伪造风险。

若要做预览,需要限制可访问协议、解析后的 IP 范围、重定向链、DNS 重新解析、出口网络以及响应体大小;不能只检查一次输入字符串里的域名,然后放心地向内网发请求。

本示例不抓取目的站点,因此没有把这个风险偷偷藏在实现里,但产品一旦加预览,这张安全清单就不能跳过。

短码本身也不应该承担权限控制。

随机且不易枚举只是在降低猜测概率,任何获得链接的人通常都能使用它;如果目标涉及私密业务,访问目标仍需独立鉴权。

面对异常流量,还需要在入口层配置速率与连接限制、按来源和行为识别探测、限制创建成本,并准备处理分布式流量攻击。

限流不能只依赖客户端传来的可伪造 IP 头;若位于反向代理后面,要明确可信代理链以及真实来源地址的解析规则。

点击统计同样容易成为瓶颈。

每个跳转同步写一行统计记录,会让原本的纯读取路径变成持续写入路径。

较合理的做法是独立设计异步事件通道、缓冲与批量消费,承认事件可能重复或丢失,并按业务需求决定去重、近似计数还是严格审计。

若要求每次点击都做可追溯、不可丢失的计费记录,吞吐目标和成本模型就要重新定义,而不能继续沿用"内存查表返回 302"的测试结果。

访问日志里的 IP、设备信息和来源参数也需要数据最小化、保留期限与权限控制。

真准备上线,监控与恢复方案要和代码一起发布

实验时可以盯终端,生产运行不能指望有人在每次流量高峰都恰好打开终端。

建议按接口、状态码和结果类型统计请求数,分别记录成功跳转、未知短码、过期、验证失败、限流、依赖故障以及创建幂等冲突。

延迟看分布而不只看均值;按阶段测入口等待、缓存耗时、数据库查询耗时和响应写入耗时。

日志要能关联请求,却不应把原始目标 URL 中可能包含的凭证、令牌或敏感查询参数毫无保留地记录出来。

就绪检查也要服务于真实行为。

示例的 /readyz 只检查本地日志写入链路有没有进入失败状态,它没有验证外部数据库、DNS、入口网络或存储容量。

到了多实例架构,健康检查应区分"进程活着"和"能不能接新流量",并避免所有实例在某个共享依赖抖动时同时退出服务,让负载均衡器找不到一个可用节点。

读服务能否在数据库短暂不可用时继续提供已缓存的合法短链,也需要明确策略。

演练故障时,我会优先覆盖 Redis 整体不可用、数据库连接数打满、热点缓存集中失效、某个实例重启、上游代理提前超时、某台机器时钟偏移以及磁盘空间不足。

每种故障都要写清楚降级后的业务行为:是继续返回正确缓存、限制回源、暂时拒绝创建,还是对无法验证的数据返回 503。

这里的重点不是制造一个"无论什么都能用"的神话,而是确保错误可以被识别,系统会以我们事先认可的方式失败。

容量上线通常分阶段:

先用确定的数据集压出基线,再做混合负载与长稳测试;接着把相同配置放到小比例真实流量,观察状态码、尾延迟和缓存命中率;确定回滚方案后才逐步扩大。

对外公开短链还需要稳定的域名、HTTPS 证书、DNS 配置、恶意链接处理机制、数据备份及恢复演练。

Go 二进制能成功监听端口,只说明部署的第一步完成了,远远谈不上已经具备运营能力。

如果周末真的只有两天,我会怎样分配时间?

周六的目标不是"十万",而是拥有一个可信的基线。

上午冻结接口语义和验收口径,下午完成随机码、持久化、幂等、过期与 HTTP 响应,晚上把正常、重复、并发和恢复测试跑通。

不要因为还没接 Redis 就觉得进度慢:

如果一个系统连重复请求应该返回什么都没说清楚,即使能跑得很快,之后也只是更高效地产生难以解释的数据。

周日才把精力放在测量。

先固定数据集、冷启动方式和网络路径,再逐步增加速率,记录每个档位的成功吞吐、尾延迟、资源曲线与错误原因。

发现拐点以后,每次只改变一个主要因素:

比如先移除热路径日志、再验证内存结构、再调整连接复用、最后考察多实例共享存储。

改完都要重新跑正确性测试,并且把可复现的命令与配置写进实验记录。

如果最后没有达到十万,收获也不会是零。

你仍然可以回答:哪条路径先饱和、瓶颈是否在压测端、给它增加 CPU 是否有效、换一种短码分布吞吐下降多少、缓存冷启动会怎样影响结果,以及下一轮投入 PostgreSQL 和 Redis 是为了可靠性、容量还是管理便利。

反过来,如果某次短暂峰值超过目标,也应该把硬件、部署、速率、窗口和错误率一起写出来,而不是只保留最大那个数字。

这种分阶段验证还有一个维护上的收益:

团队能够知道每一层存在的理由。

做短链并不需要一开始就把服务拆成一套庞大的微服务矩阵;但当多租户、统计、风控与多区域路由真的发展到不同的扩缩容和一致性需求时,再拆分也不会显得牵强。

架构可以演进,评估依据不要随流行词一起变化。

结语:十万 QPS 是实验终点,还是工程起点?

回到那个周末挑战:一个跳转接口足够简单,我们确实能够用它认真练习高并发。

单机内存读取、不可变映射、随机短码和小而确定的 HTTP 响应,让核心读路径有机会做得很轻;请求重试、日志持久化、过期判断和故障恢复,则让这个实验不至于沦为只会输出 302 的玩具。

但十万 QPS 从来不是一道只看吞吐量的选择题。

没定义正确响应,就容易把错误请求算成成功;不观察压测端,就可能把计划速率当成到达速率;不区分单机和多实例,就可能把内存命中误写成分布式架构能力;不说明缓存一致性与业务安全,就可能让性能优化成为后续维护的负担。

真正有价值的周末结果,不一定是一张十万的截图,而是一份可以被别人复现、反驳、比较和继续改进的实验记录。

跑得快值得开心;知道为什么快、什么时候会慢,以及慢下来时会不会把数据弄错,才是工程能力。

🧧🧧 文末福利,等你来拿!🧧🧧

专栏介绍:《Java实战(精通版)》

  欢迎来到《Java实战(高阶版)》专栏!这个专栏不仅仅是简单的教程,它是一条为你打通 Java 实战技能的进阶之路。从基础的语法知识到高级的性能优化,专栏通过大量真实项目中的问题和解决方案,为你提供了一个全面、深入的学习体验。无论你是初学者,还是有一定经验的开发者,这里都有适合你的内容。

解读专栏

  "Java实战"代表的是我们专注于实际开发中的技术问题,而非单纯的理论学习。我们通过大量真实项目中的实例,带你一步一步掌握 Java 开发的核心技术,帮助你更好地应对实际工作中的挑战。"精通版"则意味着我们不仅会覆盖基础的 Java 知识,还将深入到高级的编程技巧、架构设计以及性能优化等方面。目标是帮助你成为一名真正的 Java 精通者。
  专栏内容涉及 Java 编程语言的各个层面,从面向对象编程、常见设计模式,到并发编程、JVM 调优等高级主题。每一篇文章都基于我在真实项目开发过程中遇到的问题和解决方案,旨在为你提供最实用、最具操作性的知识。
  在《Java实战(精通版)》专栏中,你将学到:

  • 如何使用 Java 语言构建高效、可扩展的应用程序。
  • 深入理解 Java 中的核心概念与原理,并在实际项目中应用。
  • 解决开发过程中常见的 Bug 和性能瓶颈。
  • 掌握高效的代码优化技巧与常见设计模式的实践应用。 ...

开启你的实战之旅

  本专栏旨在帮助你打下扎实的基础,同时培养你面对复杂问题时的解决能力。通过分析和解答实际项目中的问题,我们让你不仅学到技术,还能提高解决问题的思维方式和应对复杂情境的能力。
  如果你希望成为一名 Java 实战高手,加入我一起学习吧!你不仅能从专栏中学到实际可用的技术,还能跟随我一起分析复杂的技术难题,掌握行业内最前沿的开发技巧。
  码字不易,如果这篇文章对你有所帮助,别忘了给 bug菌 来个一键三连(关注、点赞、收藏),你的支持是我持续写作《Java实战(精通版)》专栏的最大动力!
  同时推荐大家关注我的技术公众号:「猿圈奇妙屋」,你将能第一时间获取专栏更新、技术书籍资源、面试真题等福利,让我们一起在技术的道路上不断前行!

🫵 Who am I?

我是 bug菌:

更多高质量技术内容与成长资料,可查看合集入口:

👉 点击查看 👈️

硬核技术号 「猿圈奇妙屋」 期待你的加入,一起进阶、一起打怪升级。

- End -

相关推荐
撸串-研究生2 小时前
springboot福利院管理系统73979
java·spring boot·后端·福利院管理系统
tachibana22 小时前
Go 语言里的 Context 是什么?
开发语言·后端·golang·go·语言
Linux实战笔记2 小时前
sync.Mutex还是Channel?Go并发场景选型对比与goroutine泄漏实战
go·channel·sync.mutex·goroutine泄漏·race detector
不得了zz3 小时前
04-Spring AI Alibaba ReactAgent 生产级实践:工具调用、结构化输出、HITL 与上下文管理
java·ai编程·springaialibaba
猿究院_夏燊3 小时前
SpringMVC 到底是怎么干活的?
java·网络·职场和发展
鶴哥只手遮天3 小时前
Spring Framework 7.0.9 深度剖析-05-配置元数据解析:XML、注解与 JavaConfig
java·后端·开源
NGINX开源社区3 小时前
与我们交流:NGINX Gateway Fabric 与 NGINX Ingress Controller 社区会议
java·服务器·前端
鶴哥只手遮天3 小时前
Spring Framework 7.0.9 深度剖析-16-异步与调度
java·后端·开源
十年Java程序媛3 小时前
Java 多线程基础与锁机制|进程线程、synchronized、volatile、Lock 面试全梳理
java·spring boot·面试