news 2026/10/14 19:50:31

后端接口从能跑到扛得住:缓存、幂等、限流与索引优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
后端接口从能跑到扛得住:缓存、幂等、限流与索引优化实战

说实话,前两期学习记录里我写的那些接口,放到真实环境里大概率撑不过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 product

Cache-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搞乱,才能形成条件反射。

我建议跟我一样刚入门的同学也走这条路:不要追求一上来就搭微服务、上消息队列,先把单机项目里这些最基础的问题都踩一遍。缓存、幂等、限流、索引,这四个东西在任何一个后端项目里都躲不掉,越早亲手踩一遍,后面看复杂的分布式架构就越不慌。每踩一个坑,就把它记下来,下期再去对照真实系统里别人是怎么解决的。

下一期我打算往消息队列的方向走,把“下单”和“扣库存”这两个流程拆开,试试异步削峰到底怎么写。到时候再回来记录。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 19:50:30

Qt QWidget自绘实现滑动页签下划线动画实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 19:50:16

TypeScript重制仙剑奇侠传:2D RPG引擎与可视化编辑器全解

说一个我折腾了快一年的个人项目&#xff1a;用 TypeScript 从零重写《仙剑奇侠传》&#xff0c;做成一个在浏览器里能从头打到通关的版本&#xff0c;顺便还配了一个可视化编辑器&#xff0c;用来给游戏生产内容。两个东西加起来&#xff0c;才算是把这款经典 RPG 完整复刻了一…

作者头像 李华
网站建设 2026/10/14 19:49:31

Go反射实战:从核心原理到性能优化与踩坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 19:49:28

STM32F407寄存器实现ADC-DMA单通道采集实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 19:49:12

触发器原理与工程实践:从RS到D触发器、时序参数与FPGA应用

触发器算是数字电路里一道真正的分水岭。前面学的与门、或门、译码器再多&#xff0c;本质上都是在做“组合逻辑”——输入一变&#xff0c;输出立刻跟着变&#xff0c;电路没有记忆。可一旦进入触发器&#xff0c;事情就完全不一样了&#xff1a;同一个输入&#xff0c;电路可…

作者头像 李华
网站建设 2026/10/14 19:48:58

阿里Qoder上线Fast模式:首次响应3秒,Credits省掉约27%

AI 编程工具卷到现在&#xff0c;比的早就不只是"能不能把代码写对"&#xff0c;还有"要等多久"和"要花多少钱"。据报道&#xff0c;阿里旗下的 AI 编程工具 Qoder 上线了 Fast 模式&#xff1a;首次响应时间从 8 秒缩到 3 秒&#xff0c;Credit…

作者头像 李华