说实话,前两期学习记录里我写的那些接口,放到真实环境里大概率撑不过10分钟。第2期把某个练习商城的表结构和基础接口搭完,自测返回数据一切正常,我一度觉得自己已经懂后端了。直到某导师丢给我一句话:“你去看看,那个商品详情接口,两个人同时访问会怎样。”我当场愣住。因为我压根没想过这个问题——接口能跑,和系统能扛,是两码事。
这期互联网后端学习记录,就是想把这中间的差距补上。整期内容围绕四个主题展开:缓存层到底该怎么设计、接口幂等和并发安全怎么落地、限流该用哪种算法、一条慢SQL是怎么把我折磨到深夜的。每个主题都不是我从书上搬来的理论,而是真真切切在练习项目里撞出来的问题。适合跟我一样,已经能写基础接口、但对“系统稳定性”还没概念的后端学习者。
1. 第3期补课路线:把接口从“能跑”推向“扛得住”
先交代背景。前两期我把商城项目的基本骨架搭完,用户、商品、订单三张主表建好,REST风格的增删改查也都通了。当时觉得挺得意,直到被问到“两个人同时访问会怎样”,我才意识到自己其实一直在写单机模式的代码——默认同一时刻只有一个请求在执行,这在练习环境里当然没问题,但只要有一点真实流量,各种问题立刻现原形。
所以我给这期定了一条很具体的补课路线,不贪多,就围绕“请求进来之后会发生什么”这条主线来拆:
- 请求先经过限流层,挡住突发流量;
- 再进业务层,接口要做幂等处理,防止重复提交;
- 查数据时走缓存层,避免每次都打到数据库;
- 最终落到数据库的SQL,得保证索引走对,不然再强的机器也扛不住慢查询。
这条链路其实就是单机版“流量治理”的缩影。限流解决入口问题,幂等解决业务问题,缓存解决读取问题,索引解决写入和查询的效率问题。把这四件事做完,我才觉得这个项目从“玩具”往“工具”的方向挪了一步。
配套的工具选择也说明了为什么要这么安排。缓存用的是Redis,理由很实际:它是后端生态里事实上的标准,几乎所有岗位的技术栈里都有它,而且redis-py客户端足够简单,适合练手。数据库还是沿用原来的MySQL,索引优化这件事离不开真实的数据量和EXPLAIN输出,用MySQL最方便对照资料。至于限流,先自己写一个令牌桶理解原理,再去纠结现成框架。
这一期的学习方式我也调整了。前两期我是照着视频和文档敲代码,这期改成“先制造故障,再修复故障”。比如故意用脚本并发请求同一个接口,观察重复下单怎么发生;故意不建索引,把表数据灌到几十万行,再看查询延迟。自己把系统搞坏一次,比看十遍教程都记得牢。
2. Redis缓存实战:缓存穿透、击穿、雪崩一个都没少踩
2.1 为什么不是“加个缓存”这么简单
商品详情是这个练习项目里最典型的读多写少场景。接口逻辑不复杂:根据商品ID查数据库,返回详情JSON。压测工具一开,100个并发打过来,数据库连接直接飙到上限,接口响应时间从50毫秒涨到2秒。这时候加缓存是最直接的解法,也就是先查Redis,没有再去查MySQL,查完再把结果写回Redis。
这个模式有个正式名字,叫Cache-Aside(旁路缓存)。读请求的路径是:先取缓存,命中直接返回;未命中则查数据库,回填缓存。写请求的路径是:更新数据库,然后删除缓存,而不是更新缓存。删除缓存这个细节我一开始不理解,后来才明白——更新缓存是非原子的,两个并发写请求可能把旧值覆盖到新值上,而删掉缓存让下次读请求重新加载,反而能避免脏数据。
import json import redis r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True) def get_product(product_id): cache_key = f"product:{product_id}" cached = r.get(cache_key) # 命中缓存直接返回,注意空值也被缓存了,所以要用 is not None 判断 if cached is not None: return json.loads(cached) product = query_db("SELECT * FROM products WHERE id = %s", product_id) if product: # 缓存10分钟,具体看业务对数据新鲜度的容忍度 r.set(cache_key, json.dumps(product), ex=600) else: # 防缓存穿透:不存在的商品也缓存一个空值,只放60秒 r.set(cache_key, "", ex=60) return productCache-Aside看着简单,落地时有一堆边界情况。加了缓存以后我做了第二轮压测,响应时间确实降下去了,但新的问题又冒出来了:整个Redis里的key全是product:1、product:2这种干巴巴的格式,没有前缀区分业务模块,等以后加订单、购物车缓存,光靠key根本看不出哪是哪。后来统一改成业务:对象:标识符的命名规则,再配上一个统一的缓存工具类,才舒服了。
2.2 三个“缓存经典事故”逐一排除
缓存失效瞬间的打崩问题,是后端经典考点——缓存穿透、击穿、雪崩。这三个词长得像,原因完全不同,我在项目里是分开排查的。
穿透指查询一个根本不存在的数据。比如用户传了个product_id=999999,数据库没有这条记录,每次请求都会绕过缓存打到数据库,恶意用户甚至可以拿这个手段打空数据库。解决思路是上面代码里写的:缓存空值。更彻底的办法是加布隆过滤器,把所有合法商品ID加载进过滤器,请求来了先判断ID是否存在,不存在直接挡回去。练习项目数据量小,我用空值缓存就够了,但布隆过滤器的原理值得单独看一遍。
击穿指一个热点key在某个瞬间过期,大量并发同时打到数据库。比如秒杀场景里的热门商品,缓存一过期,上千个请求同时去查MySQL。解决办法是互斥锁:只让一个请求去数据库加载数据,其他请求等它加载完,直接从缓存取。Redis实现互斥锁就是SET NX,拿到锁的线程负责查库和回填,拿不到锁的线程短暂重试:
def get_hot_product(product_id): cache_key = f"product:{product_id}" cached = r.get(cache_key) if cached is not None: return json.loads(cached) lock_key = f"lock:product:{product_id}" token = str(uuid.uuid4()) # nx=True 保证只有一个线程拿到锁,px是锁的过期时间(毫秒) if r.set(lock_key, token, nx=True, px=5000): try: # 拿到锁后二次检查,防止前一个线程已经回填了缓存 cached = r.get(cache_key) if cached is not None: return json.loads(cached) product = query_db("SELECT * FROM products WHERE id = %s", product_id) r.set(cache_key, json.dumps(product), ex=600) return product finally: release_lock(lock_key, token) else: # 没拿到锁的先睡50毫秒,再递归重试,让拿到锁的线程有时间回填 time.sleep(0.05) return get_hot_product(product_id)雪崩指大量key在同一时间一起过期,导致数据库瞬时压力暴增。比如上架一批商品,缓存过期时间全设成10分钟,10分钟后这批商品同时失效,请求集体打到数据库。解决方式很粗暴也很有用:过期时间加一个随机数,比如600 + random.randint(0, 300),让过期时间分散开。进一步还可以做多级缓存(本地缓存+CaaS缓存),但练习项目用随机过期时间已经足够。
2.3 学完缓存后我给自己定的三条铁律
缓存不是银弹,用得不好反而添乱。我把这期踩过的坑总结成三条铁律,会在后续项目里一直遵守。
第一,只给热点数据加缓存。一开始我给订单表也套了Cache-Aside,后来发现订单查询条件多变,缓存命中率不到10%,白白耗内存,最后把订单缓存撤了,改用下面第三点里会说的索引优化方案。判断标准是:命中率低于30%的数据不值得缓存。
第二,缓存必须有明确的过期时间,宁短勿长。凡是没设TTL的缓存key,都是事故隐患。我遇到过缓存数据和数据库不一致,排查半天发现是某个key根本没设过期时间导致的。
第三,监控缓存命中率,而不是只盯着接口延迟。延迟降了可能是缓存生效了,也可能是某个热点key突然涨流量。Redis的INFO命令里就有keyspace_hits和keyspace_misses,算一下命中率,比拍脑袋判断“缓存有没有用”靠谱得多。
提示:缓存击穿的互斥锁方案里,锁的过期时间要大于“查库+回填”的总耗时,否则锁提前释放,依然会有多个请求同时查库。判断方式很简单,看一眼自己代码里查询最慢的那个表要多久,给出1.5到2倍的余量。
3. 重复点击搞出的重复订单:幂等性和分布式锁的实战复盘
3.1 事故复现:两个请求同时进来,下单接口生成了两条记录
缓存那关过了以后,我开始做下单接口。逻辑也不复杂:检查库存、扣减库存、生成订单。我信心满满地写完后,随手写了个脚本模拟用户双击“提交订单”,并发发两个完全相同的下单请求。结果数据库里出现了两条一模一样的订单,库存却只扣了一次。
这个问题的本质是接口不幂等——同一个请求执行多次,效果不等于执行一次。真实场景里比双击更常见的诱因是网络重试:客户端因为超时重发请求,或者消息队列重复投递。反正只要网络还在,重复请求就是必然事件,所以接口幂等是后端必须处理的默认问题,而不是“偶尔会遇到”的小概率事件。
3.2 三种幂等姿势,我从浅到深都试了一遍
**第一种:数据库唯一约束兜底。**这是最硬核的防线。给订单表加一个request_id字段,设置唯一索引,下单接口把客户端传来的请求号写进去。重复请求插入时数据库直接报唯一键冲突,接口捕获冲突后返回“订单已提交”的结果。
CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, user_id INT NOT NULL, product_id INT NOT NULL, amount DECIMAL(10, 2), status TINYINT DEFAULT 0, UNIQUE KEY uk_request_id (request_id) );def place_order(request_id, user_id, product_id, amount): try: insert_into_orders(request_id, user_id, product_id, amount) return {"code": 0, "msg": "下单成功"} except IntegrityError: order = query_order_by_request_id(request_id) return {"code": 0, "msg": "重复请求,订单已存在", "order_id": order.id}**第二种:乐观锁解决库存扣减的并发问题。**但光有唯一约束还不够——下单接口里“查库存、扣库存”这两个操作之间有时间差,两个请求同时读到库存=10,然后同时执行stock - 1,结果库存变成9而不是8。这就是丢失更新。我改用乐观锁,在扣减语句里带上版本号条件:
UPDATE inventory SET stock = stock - 1, version = version + 1 WHERE product_id = %s AND version = %s;执行后检查受影响行数,为0说明版本号变了,也就是有人抢先扣了库存,这次请求重试或返回库存不足。正是这个版本的代码让我理解了为什么UPDATE语句带WHERE条件这么重要——条件不光是筛选数据,还兼有并发控制的作用。
**第三种:分布式锁包住关键操作。**唯一的数据库约束虽然能防重复,但解决不了“查库存+扣库存+建订单”整段操作的原子性问题。某导师建议我在这段业务外面加一把Redis分布式锁,保证同一时刻只有一个线程在操作同一个商品的库存和订单创建。
def acquire_lock(key, token, expire_ms=3000): # SET key value NX PX 3000:原子地“加锁并设置过期时间” return r.set(f"lock:order:{key}", token, nx=True, px=expire_ms) def release_lock(key, token): # 释放锁时必须校验持有者,避免误删别人的锁 script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ return r.eval(script, 1, f"lock:order:{key}", token)3.3 分布式锁的三个细节:过期时间、释放校验、双重检查
这段代码看起来短,坑都在细节里。第一个坑是锁要设置过期时间。不加过期时间,万一拿到锁的线程在执行中崩掉,锁永远不会释放,后续所有请求都得卡死。加了过期时间又引出第二个坑:业务执行时间超过锁的过期时间,锁自动失效,另一个线程拿到锁进来,两个线程同时跑。要缓解这个问题,要么把过期时间设得足够长,要么用Redisson那种“看门狗”机制自动续期,练习项目里我会把过期时间设置为查询+写入极端耗时的3倍以上,并打日志监控锁的等待时间。
第二个坑是释放锁必须校验持有者。释放锁的动作是“GET判断+DEL删除”两步,不原子化就会出现一个问题:线程A的锁过期了,线程B拿到新锁,这时候线程A执行完了,它如果直接DEL,会把线程B的锁删掉。所以删除前要先GET锁的value,确认是自己的token再删——而且这两步必须用Lua脚本包成原子操作,否则判断和删除之间依然有间隙。
第三个坑是拿到锁之后还要双重检查缓存或状态。下单这个场景里,线程A拿到锁处理完订单,线程B拿到锁进来,如果不检查订单是否已存在,B还是会重复创建订单。所以正确顺序是:加锁 → 查订单是否存在 → 不存在才执行建单 → 释放锁。
提示:对练习项目来说,加了数据库唯一约束其实已经能兜底了,分布式锁是为了学原理。真正上生产环境,每个方案都有代价——锁增加响应延迟,唯一约束依赖数据库主键冲突的报错处理。选型时先想清楚重复请求的概率有多大,再决定要不要上锁。
4. 限流不只是“拒绝请求”:令牌桶和滑动窗口我各写了一遍
4.1 被脚本打挂的接口,让我认了限流这门课
有一阵子我拿压测脚本反复打一个接口调试分页功能,顺手把参数调成了1000个并发。结果整个项目直接卡住,数据库连接池满,连Redis里的监控命令都超时。这时候我才意识到,一个没有任何限流的系统,跟没锁的门一样,谁来都能进,进多了就把房子挤爆。
限流的目标不是拒绝用户,而是把请求速率控制在一个系统能承受的范围内。比如我把某个接口的限流值设置为每秒100个请求,超过的请求要么排队等待,要么直接返回“请求过于频繁”。它的本质是牺牲一小部分请求的响应,换取整个系统的稳定。
4.2 几种限流算法背后其实是“对突发流量怎么看”
这块我花了两天时间。网上一搜限流算法,四五个名词排队等着:固定窗口、滑动窗口、漏桶、令牌桶。光看定义容易混,我把它们的核心区别梳理成一个表:
| 算法 | 核心思想 | 允许突发流量 | 实现难度 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 每个时间窗口内计数,窗口结束归零 | 窗口边界可能放过双倍流量 | 最容易 | 练手、简单接口 |
| 滑动窗口 | 时间窗按请求到达时刻滑动,统计粒度更细 | 相对可控 | 中 | Web接口限流 |
| 漏桶 | 请求先进桶,以固定速率流出 | 不允许突发 | 中等 | 保护数据库等下游 |
| 令牌桶 | 按固定速率往桶里放令牌,请求取令牌才能通过 | 允许有限突发 | 中 | 网关、API服务限流 |
读到这里我明白了,选哪种算法不只是看所谓“性能优劣”,本质是回答一个问题:你的系统允不允许突发流量。漏桶把请求流速抹平,适合下游能力固定的场景;令牌桶的桶里可以积攒最多一定数量的令牌,意味着系统可以承受短时间内的流量高峰,这是互联网API最常见的诉求。所以我先实现了令牌桶。
4.3 自己写一个令牌桶,比看十遍原理都有用
import time class TokenBucket: def __init__(self, capacity, refill_rate): self.capacity = capacity # 桶容量,也就是最多积攒多少个令牌 self.refill_rate = refill_rate # 每秒补充速率 self.tokens = capacity # 初始时桶是满的 self.updated_at = time.time() def try_acquire(self): now = time.time() # 先按时间流逝补充令牌,但不能超过容量上限 self.tokens = min(self.capacity, self.tokens + (now - self.updated_at) * self.refill_rate) self.updated_at = now if self.tokens >= 1: self.tokens -= 1 return True return False bucket = TokenBucket(capacity=100, refill_rate=10) def api_request(user_id): if not bucket.try_acquire(): return {"code": 429, "msg": "请求过于频繁,请稍后再试"} # 正常业务逻辑 return {"code": 0, "data": "ok"}令牌桶算法的微妙之处在于“桶容量”这个参数。容量越大,允许的突发量越大;补发速率决定长期的平均速率。比如容量100、速率10,意味着系统接受了最坏情况下100个请求瞬间涌入,但之后10秒内最多再放行约等于补发数量的请求。调参数的时候,容量和速率不是拍脑袋定的,先压测出系统每秒能处理多少请求,再把速率设为这个值的80%,留出余量。
4.4 用Redis实现固定窗口时踩到的坑
单机令牌桶理解原理可以,但真实项目往往有多个实例,每个实例各持一个令牌桶,限流效果就打了折扣。所以我又用Redis实现了一版固定窗口限流,让所有实例共享同一个计数器:
def is_allowed(user_id, limit=10, window=60): key = f"rate:user:{user_id}" count = r.incr(key) if count == 1: # 第一次计数,顺便设置窗口过期时间 r.expire(key, window) return count <= limit这个方案用Redis的INCR实现原子自增,第一次请求时设下过期时间作为窗口边界。虽然固定窗口在窗口切换瞬间允许双倍流量,但实现简单、性能好,作为用户维度的粗粒度限流完全够用。坑在于:极端情况下incr执行了,expire却因为网络抖动没执行成功,这个key就变成永不过期,计数器越积越大,把用户永久限流住。解决方式是先判断是否是第一次初始化,甚至直接在一个Lua脚本里完成incr和expire:
local count = redis.call("incr", KEYS[1]) if count == 1 then redis.call("expire", KEYS[1], ARGV[1]) end return count限流粒度上我也总结了一套简单规则:对关键写接口(下单、支付回调)做接口级限流;对恶意刷取风险较高的接口(登录、验证码)做用户级限流;对公开接口再做IP级限流。限流触发时返回HTTP 429,同时带上Retry-After响应头告诉客户端多久后再试,比单纯报错体验好得多。
5. 一条慢SQL逼我重学了一遍索引:从ALL到ref的改造过程
5.1 事故现场:订单列表页接口慢到怀疑人生
缓存和限流都有眉目之后,我把订单表的数据灌到了20万行,用来模拟真实数据量。结果订单列表接口直接崩了——单次查询耗时1.8秒。接口根本不是什么复杂逻辑,就一条SQL:
SELECT * FROM orders WHERE user_id = 12345 AND created_at >= '2024-01-01' ORDER BY created_at DESC;问题在于这张订单表当时只在主键ID上建了索引,其他字段都是裸字段。MySQL要找到用户的订单,只能全表扫描,20万行数据一行一行过滤。这就是EXPLAIN里看到的type=ALL,全表扫描,最差劲的访问方式。
5.2 先用EXPLAIN确定病根,别猜
排查慢SQL我学到的第一件事是:不要靠猜,先用EXPLAIN看执行计划。
EXPLAIN SELECT * FROM orders WHERE user_id = 12345 AND created_at >= '2024-01-01' ORDER BY created_at DESC;输出结果里我重点看四列。type是访问类型,从好到差大概是system > const > eq_ref > ref > range > index > ALL;key是实际用到的索引;rows是预估扫描行数;Extra里藏着额外信息,比如有没有Using filesort。当时这条SQL的type是ALL,rows是20万,Extra里还有Using filesort——意思是排序是用临时文件做的,没有借助索引。
5.3 复合索引的最左前缀原则,被我亲手验证了
诊断结果是浅显的:user_id和created_at都属于查询条件,应该建一个复合索引。我在订单表上执行:
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at DESC);为什么把user_id放前面?因为复合索引遵循最左前缀原则——索引里字段的顺序,决定了它能被哪些查询用到。idx_user_created本质上先按user_id排序,再按created_at排序,所以它能支撑WHERE user_id = ?、以及WHERE user_id = ? AND created_at >= ?,也能支撑ORDER BY created_at DESC,因为created_at已经在这个索引的有序序列里。但如果只写WHERE created_at >= ?,MySQL无法使用这个索引,因为created_at在索引里不是最左字段。
建完索引再跑EXPLAIN,type从ALL变成了range,rows从20万降到几千,Extra里的Using filesort也消失了,查询耗时从1.8秒降到30毫秒。这是整期学习记录里最爽的一个瞬间,也让我彻底记住了最左前缀这个原则。
5.4 回表、覆盖索引、filesort:Extra信息里的隐藏成本
索引优化做完一轮,我又被一个问题难住:为什么同样的查询,只查user_id、status两个字段时,Extra里会出现Using index,而SELECT *的时候没有?
这就要说回表了。MySQL的辅助索引(二级索引)里只存储索引字段和主键值,查询的时候如果需要索引以外的字段,就得拿着主键再去聚簇索引里查一次完整行,这就是回表。回表次数多了,查询自然变慢。如果查询需要的所有字段都已经包含在索引里,MySQL就可以只扫索引不碰表数据,这叫覆盖索引,Extra显示的Using index就是它的标志。
-- 假如有复合索引 (user_id, status),这条SQL就能实现覆盖索引 SELECT user_id, status FROM orders WHERE user_id = 12345 AND status = 1;所以索引设计不是“见一个查询条件就建一个索引”这么简单,而是要考虑查询到底取哪些字段,能不能让常用查询走覆盖索引。当然,“能用覆盖索引就尽量不用SELECT *”是理想情况,实际业务里返回字段一大堆,不可能全塞进索引。我的落地原则是:给最核心、最频繁的查询建立覆盖索引,其余查询保证type至少是range级别。
5.5 索引不是越多越好:这期最后悔的一次优化
学到这我觉得自己天下无敌,一口气给订单表建了五个索引,字段组合覆盖各种查询条件。然后某导师顺手给我看了一眼监控:每次插入订单,MySQL要同时维护主键索引加五个二级索引,写入性能明显下降。有些二级索引因为重复度高(比如性别字段),根本不会被查询优化器选中,纯属浪费磁盘和写入开销。
这让我意识到索引优化的正确节奏是:先统计真实的慢查询日志,找到最慢那几条SQL,针对性建索引,而不是基于想象把所有可能都覆盖一遍。删除无用索引同样重要,我后来用SHOW INDEX FROM orders和慢查询日志对照,把三个从来不用的索引逐个删掉,写入性能才恢复正常。
6. 本期踩坑清单和下一阶段的个人计划
6.1 故障、原因与对策的对照表
这期内容多而杂,我把实际踩过的关键问题整理成一张表,方便随时回来对照:
| 实际问题 | 根因 | 我的解法 | 后续改进方向 |
|---|---|---|---|
| 商品详情接口并发一高就超时 | 查询全部打到数据库 | 加Redis Cache-Aside缓存 | 加布隆过滤器彻底防穿透 |
| 热点商品缓存失效后数据库被打满 | 缓存击穿 | Redis互斥锁+二次检查 | 尝试逻辑过期方案 |
| 用户双击下单产生重复订单 | 接口不幂等 | 数据库唯一约束+乐观锁 | 推广到支付回调等所有写接口 |
| 多个请求同时扣库存导致数据不一致 | 并发下丢失更新 | 乐观锁+分布式锁 | 考虑串行化处理热点商品 |
| 压测脚本把接口彻底打挂 | 完全没有限流 | 令牌桶+Redis固定窗口 | 接入网关统一限流 |
| 订单列表查询20万行数据耗时1.8秒 | 无索引,全表扫描 | 复合索引(user_id, created_at) | 按慢查询日志持续扫描 |
| 索引过多导致写入变慢 | 过度索引 | 删掉无用的三个二级索引 | 建立索引评审习惯 |
6.2 关于学习方法的一点个人体会
回看整期,我最大的收获不是记住了一个算法或者一条SQL优化技巧,而是开始在写代码之前问自己“这个接口会不会被并发访问”“数据量大了以后这条SQL还跑不跑得动”。这种思维转变靠看教程培养不起来,只有真正把自己的项目压炸、把数据库查崩、把Redis key搞乱,才能形成条件反射。
我建议跟我一样刚入门的同学也走这条路:不要追求一上来就搭微服务、上消息队列,先把单机项目里这些最基础的问题都踩一遍。缓存、幂等、限流、索引,这四个东西在任何一个后端项目里都躲不掉,越早亲手踩一遍,后面看复杂的分布式架构就越不慌。每踩一个坑,就把它记下来,下期再去对照真实系统里别人是怎么解决的。
下一期我打算往消息队列的方向走,把“下单”和“扣库存”这两个流程拆开,试试异步削峰到底怎么写。到时候再回来记录。