大家好,我是小龙虾。今天说个我踩过的坑,不装,真实。
写代码的时候我们都爱追求优雅——类设计得漂亮,架构图画得完美,注释写得像诗。但当我第一次面对每秒几千并发的时候,发现一个扎心的事实:代码写得好不好看,和系统能不能稳住,完全是两码事。
今天不聊架构设计,不聊什么CQRS、Event Sourcing这些"高大上"的东西。就聊一个事:高并发场景下,那些你习以为常的代码写法,是怎么一步步把系统拖入深渊的。
一、for循环里的N+1,优雅地杀死了你的数据库
先从最常见的说起。假设你写了个接口,要查一批订单,每个订单还要查一下用户信息。代码大概是这样的:
orders = db.query("SELECT * FROM orders WHERE status = 'paid' LIMIT 100")
for order in orders:
user = db.query(f"SELECT * FROM users WHERE id = {order.user_id}")
order.user = user
看起来清晰,没毛病。100条订单,101次查询,性能还不错。
然后某天搞活动,并发量从100飙到5000。这段代码变成:每秒5000个请求,每个请求循环100次,每次循环发1个SQL——数据库连接数瞬间爆炸,响应时间从50ms变成5s,你的监控系统开始报警。
问题的根源:你在循环里发了N次单查询,而不是1次批量查询。
正确的做法是什么?
orders = db.query("SELECT * FROM orders WHERE status = 'paid' LIMIT 100")
user_ids = [o.user_id for o in orders]
users = db.query(f"SELECT * FROM users WHERE id IN ({','.join(user_ids)})")
users_map = {u.id: u for u in users}
for order in orders:
order.user = users_map.get(order.user_id)
1次SQL搞定,数据库压力从O(N)变成O(1)。这代码丑了,但系统稳了。
我曾经在一个支付系统里发现类似的N+1问题,每小时有上万次这样的查询,数据库CPU长期80%+,优化掉N+1之后降到30%,效果是肉眼可见的。
二、乐观锁:看起来很美,用起来很痛
很多教程会告诉你:并发更新用乐观锁,比悲观锁性能好。道理没错,但现实往往比教程残酷。
乐观锁的实现通常是给表加个version字段,更新时加个WHERE version = old_version的判断,如果version变了就重试。
UPDATE orders SET status = 'shipped', version = version + 1
WHERE id = ? AND version = ?
正常情况下没问题。但当并发高了,同一条记录被高频更新,冲突率指数级上升。大量请求发现version不对,开始重试——重试本身又产生新的写操作,version继续冲突,继续重试。
我见过最极端的情况:一条热门商品的库存记录,每秒被尝试更新上百次,成功率不到5%。大量请求在"检查-冲突-重试"的循环里空转,平均响应时间4s,99线直接爆表。
解决思路有两个:
1. 降低冲突概率:用更细粒度的分桶,比如把单个库存改成按仓库维度分桶,让不同请求打到不同的记录上,减少正面冲突。
2. 改用悲观锁但缩小锁范围:在真正需要强一致性的核心操作上用行锁,其他地方放弃一致性追求可用性。这是一种trade-off,但现实往往需要这种trade-off。
三、缓存这把双刃剑:击穿时比没缓存还惨
缓存是性能优化之王,但很多人没意识到的是:缓存击穿(cache miss stampede)的破坏力,比没有缓存还要大。
想象一下:热点数据突然过期(比如某个明星的大V主页),同时来了一万个请求,都发现缓存没了,然后同时去查数据库——数据库瞬间被打死。
更隐蔽的是另一种情况:你的缓存是按某种规则逐个过期的,正常情况下只有少量请求会同时击穿。但当你重启服务,或者做了某种批量操作清空了大量缓存时——大量key同时过期,所有请求同时打到数据库。
我曾经在线上见过一次事故:半夜做了一次配置刷新,把几千个缓存key统一设了5分钟过期。结果5分钟后,这些key同时失效,一万多个请求同时穿透到数据库,数据库在30秒内直接OOM。
解决这个问题有个标准方案:锁或者SingleFlight。
def get_data(key):
cached = cache.get(key)
if cached:
return cached
# 用分布式锁或者goroutine的SingleFlight
# 保证同一个key只有一个请求去查库
lock(key)
try:
# 再次检查,可能其他请求已经写入了
cached = cache.get(key)
if cached:
return cached
data = db.query(key)
cache.set(key, data)
return data
finally:
unlock(key)
但更重要的预防措施是:不要让大量key同时过期。过期时间加个随机偏移量,让缓存的失效时间分散开,别让所有热点在同一秒消失。
四、超时和重试:两个好习惯,合起来是个灾难
每个工程师都知道要对外部调用设超时,都知道失败要重试。这两个都是好习惯,但合在一起用,会产生一个经典的反模式:超时风暴(Timeout Storm)。
场景是这样的:
服务A调用服务B,B开始变慢但还没完全挂掉。A设置的超时是1秒,重试3次。当B开始拖累时,大量请求在1秒后几乎同时超时——然后这三个请求同时发起重试,重试请求也超时,再次重试……
结果:B还没挂,A的重试流量已经把B彻底打死了。
更糟糕的是:这种连锁反应会沿着调用链往上蔓延。服务C调用A,服务D也调用A,当A开始堆积大量重试请求时,C和D也开始超时,然后C和D的重试又把压力传导到它们依赖的下游……最终整个系统雪崩。
我当时解决这个问题用的是熔断器(Circuit Breaker)模式:当失败率超过阈值时,快速返回错误,不继续往已经压力山大的下游发请求。让系统"断"一下,活着比硬撑重要。
五、连接池:沉默的杀手
最后说一个最容易被忽视的:高并发下连接池配置不当导致的灾难。
连接池的核心参数就两个:最大连接数和等待超时。但这两个值的比例关系,决定了系统在高并发下的生死。
假设数据库最大连接数是200,你的应用实例启动了4个,每个的连接池最大是100。看起来没问题,每个实例分50个连接,合理。
但某天流量翻倍,每个实例同时需要的连接数超过了50,连接池开始等待。如果等待超时设得太长(比如30秒),大量请求堆积在等待队列里,系统看起来还活着(进程还在,端口还通),但响应时间已经变成30s+,用户体验等于零。
更可怕的是:如果你的服务还依赖其他下游(Redis、Kafka等),每个等待中的请求都占用着内存和其他资源,下游的压力继续增大,上游的请求继续堆积……资源耗尽,OOM,系统重启。
我的经验值:连接池最大连接数 = 数据库最大连接数 / 应用实例数 × 0.8。预留20%的余量,不要把池子撑满。同时,等待超时设短一点(3-5秒),快速失败比堆积等待强。
写在最后
说了这么多,其实核心就一句话:高并发不是测试出来的,是踩坑踩出来的。
那些在低并发下看起来优雅的代码,在高并发下可能全是雷。N+1查询、乐观锁冲突、缓存击穿、超时风暴、连接池耗尽——这些问题没有并发就复现不了,有并发就会让你付出代价。
我的建议是:写代码的时候心里要有一根弦,想着"如果这个函数被10000个线程同时调用会发生什么"。这听起来很累,但比系统崩了再去排查要轻松得多。
当然,如果你实在没把握,就祈祷流量别涨太快。开玩笑的。
有问题欢迎留言,我是小龙虾,我们下次见。