高并发系统设计:从理论到实践
上周五晚上十一点半,我坐在工位上,盯着 Grafana 面板里那个还在往上蹿的 QPS 曲线,手里的冰美式早就凉透了。产品经理在群里@我说:“老大,明天上午十点前能不能把活动页面上线?大老板说这是今年最重要的营销战役。”
我回了个“OK”,然后默默打开了 Vim。
我是杭州一家三线互联网公司的技术负责人,公司不大不小,百来号人,主要做本地生活服务。阿里和网易就在隔壁园区,我们天天被“大厂光环”照着,但资源、人手、预算都得精打细算。平时开发主力是 Python(后端)和 JavaScript(前端),Vim 是我的命——别笑,IDE 启动太慢,开个 VS Code 我都能写完两个函数了。
去年双11期间,我们搞了个“限时秒杀”活动,结果刚上线十分钟,数据库 CPU 直接飙到 100%,Redis 连接池爆满,用户页面卡成 PPT。运维老哥半夜打电话给我:“你这代码是不是没加缓存?还是直接查库?”
我当时真想砸电脑。
从那以后,我就下定决心:高并发不是大厂的专利,小团队也得有自己的解法。这篇文章,就是我踩坑、填坑、再踩新坑之后的一点心得。不讲教科书理论,只聊实战中怎么用有限的资源,扛住流量洪峰。
起因:一个“简单”的需求,差点搞崩系统
事情发生在三个月前。产品提了个“看起来很简单”的需求:用户进入首页时,实时展示附近 5 公里内的商家列表,并按距离排序。听起来平平无奇对吧?但问题在于——我们有 200 万注册用户,日活 30 万+。
初始方案很 naive:
- 前端发请求
/api/nearby?lat=30.2741&lng=120.1551 - 后端用 Python + Django ORM,直接
SELECT * FROM shop WHERE ST_Distance(...) < 5000 ORDER BY distance LIMIT 20 - 没缓存,没限流,没异步
结果压力测试一跑,QPS 到 800 就开始大量 502。数据库 I/O 瓶颈,CPU 打满,连接数耗尽。更惨的是,前端为了“体验流畅”,每 10 秒轮询一次……等于每个活跃用户每分钟要发起 6 次请求。
我看着监控图,心里默念:“产品经理不懂技术不可怕,可怕的是他觉得‘加个索引就行’。”
第一步:前端先扛住,别让垃圾流量进后端
高并发的第一原则:能不让请求进后端,就别让它进。
我和前端同学(一个 JS 老炮,React/Vue 都熟)开了个紧急会。他说:“其实位置变化没那么频繁,我们可以加个节流 + 缓存。”
于是我们在 JavaScript 里做了两件事:
// 前端:防抖 + 本地缓存
const locationCache = new Map();
function fetchNearbyShops(lat, lng) {
const key = `${Math.floor(lat * 100)}_${Math.floor(lng * 100)}`; // 粗粒度哈希
if (locationCache.has(key)) {
return Promise.resolve(locationCache.get(key));
}
// 防抖:5秒内相同区域不重复请求
if (window.lastFetchTime && Date.now() - window.lastFetchTime < 5000) {
return Promise.reject('Too frequent');
}
return fetch(`/api/nearby?lat=${lat}&lng=${lng}`)
.then(res => res.json())
.then(data => {
locationCache.set(key, data);
window.lastFetchTime = Date.now();
return data;
});
}
效果立竿见影:前端拦截了 70% 的重复请求。用户滑动地图时不再疯狂刷接口,而是等他停下来再查。而且粗粒度坐标哈希让“差不多位置”的请求命中同一缓存,进一步减少后端压力。
这招成本几乎为零,但收益巨大。高并发优化,有时候真不在后端。
第二步:后端重构——缓存 + 异步 + 分页
光靠前端不够,后端必须硬刚。
1. Redis 缓存热点数据
我们把“附近商家”结果按地理网格缓存。比如以 500 米为单位划分网格,每个网格 ID 作为 Redis Key:
# Python 后端:使用 geohash 做空间索引
import redis
import geohash2
def get_nearby_shops(lat, lng):
# 生成粗粒度 geohash(精度约 500m)
grid_key = geohash2.encode(lat, lng, precision=6)
cache_key = f"nearby_shops:{grid_key}"
# 先查缓存
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 查 DB(带空间索引!)
shops = Shop.objects.extra(
where=["ST_DWithin(location, ST_Point(%s, %s)::geography, 5000)"],
params=[lng, lat]
).order_by('distance')[:20]
# 写缓存,过期时间 2 分钟(商家变动不频繁)
redis_client.setex(cache_key, 120, json.dumps(shops))
return shops
关键点:
- PostgreSQL 必须建 GIST 索引:
CREATE INDEX idx_shop_location ON shop USING GIST (location);- 缓存过期时间不能太长,否则商家下架了用户还看得到,会被投诉
2. 数据库读写分离 + 连接池优化
我们把主库只用于写操作,读请求全部走只读副本。同时调整了 SQLAlchemy 的连接池参数:
# settings.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'OPTIONS': {
'MAX_CONNS': 20, # 单实例最大连接数
'MIN_CONNS': 5, # 最小空闲连接
'CONN_TIMEOUT': 30, # 连接超时
}
}
}
以前默认连接池是 10,高峰期直接爆掉。现在配合 gunicorn 的 worker 数(我们用 4 个 sync worker + gevent),稳如老狗。
3. 接口限流:别让恶意用户搞垮你
用 Redis + 令牌桶做接口级限流:
from redis import Redis
def is_allowed(user_id, rate=10, per=60):
"""每用户每分钟最多 10 次请求"""
key = f"rate_limit:{user_id}"
pipe = redis.pipeline()
pipe.incr(key)
pipe.expire(key, per)
count = pipe.execute()[0]
return count <= rate
接入中间件,非法请求直接返回 429。宁可让用户等,也不能让系统崩。
第三步:资源调度——小公司也要会“抠门”
我们没有阿里云那么多机器,所以必须精打细算。
资源分配策略
| 组件 | 实例规格 | 数量 | 用途说明 |
|---|---|---|---|
| Web 服务器 | 4C8G | 2 | 跑 Django + Gunicorn |
| DB 主库 | 8C16G + SSD | 1 | 写操作 + 小量读 |
| DB 只读 | 4C8G + SSD | 2 | 分担 90% 读请求 |
| Redis | 4C8G | 1 | 缓存 + 限流 |
总成本每月不到 3000 块(阿里云华东2区)。小团队玩高并发,核心是“用缓存换计算,用异步换实时”。
异步任务处理
像“用户进入首页”这种场景,其实不需要强一致性。我们把部分非核心逻辑扔给 Celery:
# 用户访问首页时,异步记录行为日志
@app.task
def log_user_visit(user_id, lat, lng):
UserVisitLog.objects.create(user_id=user_id, lat=lat, lng=lng)
# views.py
def homepage(request):
shops = get_nearby_shops(lat, lng)
log_user_visit.delay(request.user.id, lat, lng) # 不阻塞主流程
return JsonResponse(shops)
这样主接口响应时间从 320ms 降到 140ms,用户体验直接起飞。
血泪教训:那些年我踩过的坑
缓存雪崩:所有缓存同时过期,DB 瞬间被打爆。
解法:给缓存过期时间加随机值(比如 120±20 秒)Redis 连接泄漏:忘了 close pipeline,连接数涨到 5000+。
解法:用with redis.pipeline() as pipe:自动管理前端没处理 loading 状态:用户狂点按钮,发起 N 个并发请求。
解法:JS 加全局 loading 锁,请求期间禁用按钮PostgreSQL 的 ST_DWithin 没用 geography 类型:距离算错,用户骂街。
解法:字段类型必须是geography(POINT, 4326),不是 geometry!
效果:从 800 QPS 到 5000+,稳了
经过三周迭代(中间熬了两个通宵),最终压测结果:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 最大 QPS | 800 | 5200 | 6.5x |
| 平均响应时间 | 320ms | 98ms | 3.3x ↓ |
| DB CPU 峰值 | 100% | 45% | —— |
| 错误率 | 12% | 0.2% | —— |
上线那天,我特意等到晚高峰(18:00-20:00)盯着监控。QPS 最高冲到 4100,系统稳如泰山。运维老哥在群里发了个“牛逼”,产品经理请我喝了杯瑞幸。
写在最后:小团队的高并发哲学
很多人觉得高并发是大厂的事,我们这种三线公司搞不了。但我想说:高并发的本质,是对资源的敬畏。
你没有 100 台机器,那就用 1 台机器榨出 10 倍性能;
你没有专职 SRE,那就自己写监控脚本 + 告警;
你没有 fancy 的 Service Mesh,那就用好 Nginx + Redis + PostgreSQL。
技术不分贵贱,能解决问题的就是好技术。
就像我这个 Vim 党,虽然被 IDE 党嘲笑“复古”,但只要代码跑得快,用户不卡顿,就是胜利。
下次再有“简单需求”,我会笑着对产品说:“行,不过得加钱——加服务器的钱。”
(完)
附:工具链清单
- 后端:Python 3.9 + Django 4.2 + PostgreSQL 14 + Redis 7
- 前端:Vue 3 + Axios + Lodash(节流用)
- 监控:Prometheus + Grafana + Sentry
- 部署:Docker + Nginx + Gunicorn + Celery
所有配置文件我都整理好了,需要的话可以私信我(虽然可能懒得回 😅)

评论 0