高并发系统设计:从理论到实践

锁表受害者
2025-12-14 03:25
阅读 613

上周五晚上十一点半,我坐在工位上,盯着 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,用户体验直接起飞。


血泪教训:那些年我踩过的坑

  1. 缓存雪崩:所有缓存同时过期,DB 瞬间被打爆。
    解法:给缓存过期时间加随机值(比如 120±20 秒)

  2. Redis 连接泄漏:忘了 close pipeline,连接数涨到 5000+。
    解法:用 with redis.pipeline() as pipe: 自动管理

  3. 前端没处理 loading 状态:用户狂点按钮,发起 N 个并发请求。
    解法:JS 加全局 loading 锁,请求期间禁用按钮

  4. 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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝