后端接口从能跑到扛得住:缓存、幂等、限流与索引优化实战 说实话前两期学习记录里我写的那些接口放到真实环境里大概率撑不过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(host127.0.0.1, port6379, decode_responsesTrue) def get_product(product_id): cache_key fproduct:{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), ex600) else: # 防缓存穿透不存在的商品也缓存一个空值只放60秒 r.set(cache_key, , ex60) return productCache-Aside看着简单落地时有一堆边界情况。加了缓存以后我做了第二轮压测响应时间确实降下去了但新的问题又冒出来了整个Redis里的key全是product:1、product:2这种干巴巴的格式没有前缀区分业务模块等以后加订单、购物车缓存光靠key根本看不出哪是哪。后来统一改成业务:对象:标识符的命名规则再配上一个统一的缓存工具类才舒服了。2.2 三个“缓存经典事故”逐一排除缓存失效瞬间的打崩问题是后端经典考点——缓存穿透、击穿、雪崩。这三个词长得像原因完全不同我在项目里是分开排查的。穿透指查询一个根本不存在的数据。比如用户传了个product_id999999数据库没有这条记录每次请求都会绕过缓存打到数据库恶意用户甚至可以拿这个手段打空数据库。解决思路是上面代码里写的缓存空值。更彻底的办法是加布隆过滤器把所有合法商品ID加载进过滤器请求来了先判断ID是否存在不存在直接挡回去。练习项目数据量小我用空值缓存就够了但布隆过滤器的原理值得单独看一遍。击穿指一个热点key在某个瞬间过期大量并发同时打到数据库。比如秒杀场景里的热门商品缓存一过期上千个请求同时去查MySQL。解决办法是互斥锁只让一个请求去数据库加载数据其他请求等它加载完直接从缓存取。Redis实现互斥锁就是SET NX拿到锁的线程负责查库和回填拿不到锁的线程短暂重试def get_hot_product(product_id): cache_key fproduct:{product_id} cached r.get(cache_key) if cached is not None: return json.loads(cached) lock_key flock:product:{product_id} token str(uuid.uuid4()) # nxTrue 保证只有一个线程拿到锁px是锁的过期时间毫秒 if r.set(lock_key, token, nxTrue, px5000): 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), ex600) 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_ms3000): # SET key value NX PX 3000原子地“加锁并设置过期时间” return r.set(flock:order:{key}, token, nxTrue, pxexpire_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, flock: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(capacity100, refill_rate10) 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, limit10, window60): key frate:user:{user_id} count r.incr(key) if count 1: # 第一次计数顺便设置窗口过期时间 r.expire(key, window) return count limit这个方案用Redis的INCR实现原子自增第一次请求时设下过期时间作为窗口边界。虽然固定窗口在窗口切换瞬间允许双倍流量但实现简单、性能好作为用户维度的粗粒度限流完全够用。坑在于极端情况下incr执行了expire却因为网络抖动没执行成功这个key就变成永不过期计数器越积越大把用户永久限流住。解决方式是先判断是否是第一次初始化甚至直接在一个Lua脚本里完成incr和expirelocal 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秒。接口根本不是什么复杂逻辑就一条SQLSELECT * FROM orders WHERE user_id 12345 AND created_at 2024-01-01 ORDER BY created_at DESC;问题在于这张订单表当时只在主键ID上建了索引其他字段都是裸字段。MySQL要找到用户的订单只能全表扫描20万行数据一行一行过滤。这就是EXPLAIN里看到的typeALL全表扫描最差劲的访问方式。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 ALLkey是实际用到的索引rows是预估扫描行数Extra里藏着额外信息比如有没有Using filesort。当时这条SQL的type是ALLrows是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在索引里不是最左字段。建完索引再跑EXPLAINtype从ALL变成了rangerows从20万降到几千Extra里的Using filesort也消失了查询耗时从1.8秒降到30毫秒。这是整期学习记录里最爽的一个瞬间也让我彻底记住了最左前缀这个原则。5.4 回表、覆盖索引、filesortExtra信息里的隐藏成本索引优化做完一轮我又被一个问题难住为什么同样的查询只查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搞乱才能形成条件反射。我建议跟我一样刚入门的同学也走这条路不要追求一上来就搭微服务、上消息队列先把单机项目里这些最基础的问题都踩一遍。缓存、幂等、限流、索引这四个东西在任何一个后端项目里都躲不掉越早亲手踩一遍后面看复杂的分布式架构就越不慌。每踩一个坑就把它记下来下期再去对照真实系统里别人是怎么解决的。下一期我打算往消息队列的方向走把“下单”和“扣库存”这两个流程拆开试试异步削峰到底怎么写。到时候再回来记录。