高并发系统设计:我在跳槽前刷题时悟出的实战心得
上周五晚上十一点半,我正一边调试一个诡异的 goroutine 泄漏问题,一边在 LeetCode 上刷“设计 Twitter”那道经典面试题。突然收到 HR 的微信:“下周一面,对方是做高并发交易平台的,Go 技术栈为主。” 我手一抖,差点把咖啡泼到机械键盘上——这不就是我最近啃源码、压测、调优折腾了三个月的方向吗?
作为一枚混迹 DevOps 场子多年的工程师,平时既要给 CI/CD 流水线“打补丁”,又要帮后端同学排查线上慢查询,还得抽空研究 Kubernetes 调度器源码(别问,问就是想跳槽)。但说实话,高并发系统设计这个话题,以前总觉得是架构师的事,直到去年双11我们系统被流量冲垮,我才意识到:不懂高并发,连运维都做不踏实。
一次“史诗级”故障引发的思考
事情得从公司搞大促说起。产品经理拍脑袋说要搞“秒杀活动”,技术总监直接甩锅给我们中间件组:“扛不住就优化!” 结果当天零点一到,API 网关直接 502,数据库 CPU 打满,Redis 连接池耗尽,监控告警响成交响乐。我当时坐在工位上看着 Grafana 曲线直冲云霄,内心 OS:这哪是秒杀,这是自杀式压测吧?
复盘会上,CTO 冷冷一句:“你们连缓存穿透和雪崩的区别都说不清,还敢上线?” 那一刻,我默默打开了《Designing Data-Intensive Applications》,并决定:跳槽前,必须把高并发这块硬骨头啃下来。
而 Go,成了我首选的实验语言——简洁、高并发模型优雅、标准库强大,关键是,现在大厂后端岗几乎都要求会 Go。于是我一边写自动化部署脚本,一边用 Go 搭了个迷你高并发系统练手。
从一道面试题开始:如何设计一个短链服务?
最近刷题时遇到这么一道高频 面试题挑战:
设计一个高性能短链接生成与跳转服务,支持每秒 10 万次请求。
乍一看简单,但细想全是坑:ID 生成怎么保证全局唯一且高性能?跳转接口怎么扛住突发流量?数据库会不会成为瓶颈?
我第一反应是用自增 ID + Base62 编码,但马上意识到:单点数据库主键自增在分布式环境下根本不可靠。于是翻了下 Twitter 的 Snowflake 源码,结合 Go 的 atomic 和 time 包,自己撸了个分布式 ID 生成器:
type Snowflake struct {
mu sync.Mutex
timestamp int64
sequence int64
nodeID int64 // 机器 ID,部署时注入
}
func (s *Snowflake) Generate() int64 {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UnixNano() / 1e6
if s.timestamp == now {
s.sequence = (s.sequence + 1) & 0xfff
if s.sequence == 0 {
// 同一毫秒内序列号用完,等待下一毫秒
for now <= s.timestamp {
now = time.Now().UnixNano() / 1e6
}
}
} else {
s.sequence = 0
}
s.timestamp = now
return (now << 22) | (s.nodeID << 12) | s.sequence
}
这段代码在线上跑了一周,QPS 稳稳 30w+,内存占用不到 50MB。Go 的轻量级 goroutine 和高效的调度器,真不是吹的。
缓存策略:别让 Redis 成为你的阿喀琉斯之踵
短链的核心是“查”,所以缓存是关键。但缓存用不好,反而会拖垮系统。
我一开始天真地以为“加个 Redis 就行了”,结果模拟压测时发现:当大量请求同时查询同一个不存在的短码(比如用户乱输 URL),所有请求都会穿透到数据库——这就是典型的缓存穿透。
解决办法?布隆过滤器(Bloom Filter)!但 Go 标准库没有,于是用了 github.com/bits-and-blooms/bloom/v3:
bf := bloom.NewWithEstimates(1000000, 0.01) // 预估100万条,误判率1%
// 写入时:bf.Add([]byte(shortCode))
// 查询前先判断:if !bf.Test([]byte(shortCode)) { return 404 }
另外,缓存雪崩也得防。我把热点 key 的过期时间加了随机偏移:
baseTTL := 3600 // 1小时
randomOffset := rand.Intn(300) // ±5分钟抖动
ttl := baseTTL + randomOffset
redis.Set(ctx, key, value, time.Duration(ttl)*time.Second)
运维经验告诉我:线上事故往往不是技术不行,而是边界情况没考虑到。
数据库设计:别再用 INT 做主键了!
很多人图省事,短链表直接这么建:
CREATE TABLE short_urls (
id INT AUTO_INCREMENT PRIMARY KEY,
long_url VARCHAR(2048) NOT NULL,
created_at TIMESTAMP
);
但问题来了:INT 最大才 21 亿,按每天 1 亿条算,半年就爆了。而且自增 ID 容易被爬虫遍历。
我改用 BIGINT 存 Snowflake ID,并加了复合索引:
CREATE TABLE short_urls (
id BIGINT PRIMARY KEY, -- 来自 Snowflake
short_code CHAR(8) UNIQUE NOT NULL, -- Base62 编码后的字符串
long_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_created (created_at)
);
同时,为了减少数据库压力,读写分离 + 分库分表也安排上了。用 Go 的 database/sql 配合 sharding-sphere 中间件,轻松实现按 ID 范围分片。
接口设计与限流:保护你的服务不被玩坏
高并发不只是“快”,更是“稳”。再牛的系统,也扛不住恶意刷量。
我在 API 网关层(用 Go 写的轻量级网关)加了令牌桶限流:
limiter := rate.NewLimiter(rate.Every(time.Second/1000), 100) // 每秒1000令牌,突发100
func redirectHandler(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
// ... 正常处理逻辑
}
另外,跳转接口必须做到幂等且无状态,HTTP 状态码用 302(临时重定向),避免 SEO 问题。同时,响应头加上 Cache-Control: public, max-age=31536000,让浏览器和 CDN 缓存一年——能不上服务器的请求,就别让它上来。
压测与监控:上线前的最后一道防线
光写代码不够,得验证。我用 hey(Go 版 wrk)做压测:
hey -z 5m -c 5000 -q 200 http://localhost:8080/abc123
配合 Prometheus + Grafana 监控关键指标:
| 指标 | 阈值 | 说明 |
|---|---|---|
| QPS | ≥ 100,000 | 目标吞吐量 |
| P99 延迟 | ≤ 50ms | 用户可感知延迟 |
| DB CPU | ≤ 70% | 避免资源争抢 |
| Goroutine 数 | 稳定 | 防止泄漏 |
有一次压测发现 goroutine 数持续上涨,最后定位到是 HTTP Client 没设置超时,连接一直 hang 住。Go 的并发虽爽,但资源管理不能懒。
跳槽前的顿悟:高并发不是堆技术,而是权衡
折腾这套系统的过程中,我逐渐明白:高并发设计的本质,是在成本、复杂度、性能之间找平衡。
- 不是所有场景都需要分库分表,先优化 SQL 和索引;
- 不是所有数据都要强一致,最终一致性往往更实用;
- 不是缓存越多越好,缓存一致性维护成本可能更高。
上周面试时,面试官问:“如果让你重新设计,会改什么?”
我答:“我会先砍掉 80% 的功能,聚焦核心路径。毕竟,能活着的系统,才是好系统。”
写在最后
如今我的小短链系统已经跑在公司测试环境,QPS 稳定在 12w+,P99 延迟 28ms。虽然离真正的工业级还有距离,但至少,我不再怕高并发相关的 面试题 了。
如果你也在准备跳槽,或者被线上高并发问题折磨,不妨从一道 面试题挑战 开始,用 Go 动手造个轮子。纸上谈兵终觉浅,绝知此事要 coding。
对了,前端同事看我折腾这么久,问我能不能加个酷炫的粒子动画展示跳转过程……我翻了个白眼:“你先让产品别半夜改需求,我再考虑给你加 Lottie。”
(完)

评论 0