[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$flUW8zq0JcaH_dbbmKaFI_UlcD000D0s4nL2vR24nZzQ":3,"$f5cwVJJWNSNmA19OnTFW2eyLPdtFd-sghRuTamt8ZZqM":40},[4],{"id":5,"type":6,"title":7,"slug":8,"summary":9,"body":10,"coverUrl":11,"productScreenshots":12,"productLinks":13,"authorName":14,"authorUrl":15,"authorSubject":16,"category":17,"tags":22,"sourceLabel":31,"sourceName":32,"sourceUrl":32,"status":33,"seoTitle":32,"seoDescription":32,"canonicalUrl":32,"isFeatured":34,"sno":35,"sortOrder":36,"publishedAt":37,"updatedAt":38,"createdAt":39},"a04c10a4-3fe8-4537-81bd-354103c1078f","article","WebGPU：浏览器为什么能跑 3D、滤镜和部分 AI","webgpu-browser-gpu-computing-explained","WebGPU 让网页能更直接地使用 GPU 做渲染与通用计算。本文从 CPU 与 GPU 的分工讲起，拆解适配器、设备、缓冲区、WGSL 着色器和命令队列，并说明什么时候并行计算真的值得。","浏览器里的 3D 游戏、照片滤镜、视频特效，甚至一部分端侧 AI 推理，都在做同一件事：把大量相似的小计算同时交给 GPU。以前网页主要通过 WebGL 接触 GPU，WebGPU 则提供了更现代、更明确的图形与通用计算接口。W3C 对它的定义很直接：WebGPU 暴露了在 GPU 上进行渲染和计算的 API。[WebGPU 规范](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fwebgpu\u002Fall\u002F)\n\n![一块图形处理器](https:\u002F\u002Fcommons.wikimedia.org\u002Fwiki\u002FSpecial:FilePath\u002FGraphics%20Processing%20Unit.JPG)\n\n## GPU 快，不是因为它有一个更快的 CPU\n\nCPU 像一位很聪明的总管，擅长处理复杂、分支很多的任务；GPU 更像一座拥有大量工位的工厂，擅长让很多工位同时执行相似的步骤。\n\n把一张图片变成黑白图，每个像素都可以独立计算：读取红、绿、蓝三个通道，再按同一套公式得到灰度值。CPU 可以一个个像素处理，GPU 则能把成千上万个像素分发给并行执行单元。\n\n但“并行”不等于“所有任务都该上 GPU”。如果任务只有几次字符串拼接，调度 GPU 的准备成本反而可能比计算本身还高。WebGPU 的价值是让开发者能更直接地表达大批量、规则明确的工作。\n\n## 从 JavaScript 到 GPU，中间发生了什么\n\n一个 WebGPU 程序通常经过这条链路：\n\n1. 通过 `navigator.gpu` 请求适配器，了解浏览器能使用的 GPU 能力。\n2. 从适配器申请逻辑设备和命令队列。\n3. 创建缓冲区、纹理、采样器等 GPU 资源。\n4. 编写 WGSL 着色器，描述每个并行线程要做什么。\n5. 把计算或绘制命令编码出来，提交到队列。\n6. 等 GPU 执行完，再把结果显示到画布或读回 CPU。\n\n```mermaid\nflowchart LR\n    A[JavaScript 组织数据] --> B[创建 GPU 资源]\n    B --> C[WGSL 着色器]\n    C --> D[编码命令]\n    D --> E[提交到队列]\n    E --> F[GPU 并行执行]\n    F --> G[画布显示或读回结果]\n```\n\n这里最重要的概念不是“调用一个神奇函数”，而是资源和命令的边界。JavaScript 负责准备数据，着色器负责定义大量相同的计算，队列负责把命令送给 GPU。GPU 不会理解你的业务对象，它只看缓冲区、纹理和指令。\n\n## 着色器到底在写什么\n\n下面是一个简化的 WGSL 计算着色器。它让每个 GPU 线程把输入数组中的一个数字乘以 2：\n\n```wgsl\n@group(0) @binding(0)\nvar\u003Cstorage, read> input: array\u003Cf32>;\n\n@group(0) @binding(1)\nvar\u003Cstorage, read_write> output: array\u003Cf32>;\n\n@compute @workgroup_size(64)\nfn double_value(@builtin(global_invocation_id) id: vec3\u003Cu32>) {\n  output[id.x] = input[id.x] * 2.0;\n}\n```\n\n`global_invocation_id` 可以理解成当前线程的编号。线程 0 处理第 0 个元素，线程 1 处理第 1 个元素，彼此不需要等待。图像卷积、矩阵乘法和粒子模拟都可以用类似思想拆成大量小工作。\n\n当然，真正的性能取决于很多细节：数据是否连续、线程之间是否需要同步、显存访问是否规律、工作组大小是否适合硬件，以及 JavaScript 和 GPU 之间是否频繁搬运数据。把数据来回复制几次，可能轻易吃掉并行计算带来的收益。\n\n## WebGPU 和 WebGL 的关键区别\n\nWebGL 更接近“浏览器替你管理很多状态”的旧式接口。WebGPU 把设备、资源、绑定和命令编码讲得更清楚，允许浏览器在提交前做更严格的验证，也更贴近现代图形 API 的资源模型。\n\n这带来两面性：\n\n- 好处是状态更明确，复杂项目更容易组织，通用计算也更自然。\n- 代价是学习曲线更陡，要理解缓冲区、绑定组、管线和着色器。\n- 浏览器不能把底层 GPU 的所有细节原样暴露出来，所以仍然要经过权限、能力和安全验证。\n- 不同设备的 GPU 能力不同，不能默认所有格式、精度和特性都存在。\n\nWebGPU 也不是“在网页里直接运行 CUDA”。它是一套跨平台 Web API，浏览器会把它映射到系统可用的图形后端，同时限制资源访问，避免网页拿到任意设备内存。\n\n## 什么时候值得用\n\n最适合 WebGPU 的任务有三个特征：数据量大、单个元素的计算相似、结果能在 GPU 上连续使用。比如实时图像处理、粒子特效、3D 场景、矩阵运算和部分机器学习推理。\n\n如果页面只是渲染几十个按钮，WebGPU 是明显的过度设计。若任务需要频繁读取 GPU 中的每一个小结果，或者算法分支极多、数据量很小，CPU 可能更合适。工程上应先测量：上传数据、执行命令、同步等待和读回结果各花了多少时间，而不是只看 GPU 的理论算力。\n\n## 一份实用的落地清单\n\n- 把大数组和纹理尽量批量上传，减少频繁的小提交。\n- 让 GPU 上的计算形成连续流水线，避免每一步都读回 CPU。\n- 对设备能力做探测和降级，准备 WebGL 或 CPU 路径。\n- 将 WGSL 着色器当成独立模块测试，验证边界和越界访问。\n- 用浏览器开发者工具观察 GPU 时间，而不是用 JavaScript 函数耗时替代它。\n\n## 一句话带走\n\nWebGPU 的核心不是“网页终于有了一个更酷的画布”，而是浏览器开始允许你把一大批相似工作打包交给 GPU。真正的难点也从“能不能画出来”变成了“数据如何布局、什么时候同步、怎样让并行真的值得”。\n\n## 延伸阅读\n\n- [W3C WebGPU 规范](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fwebgpu\u002Fall\u002F)\n- [GPU for the Web 工作组](https:\u002F\u002Fwww.w3.org\u002Fgroups\u002Fwg\u002Fgpu\u002F)\n- [WebGPU Shading Language 规范](https:\u002F\u002Fwww.w3.org\u002FTR\u002FWGSL\u002F)","\u002Fuploads\u002F2026-08-06\u002F05f26f06-cda2-4ed3-b1f2-5f26c82c2a37.jpg",[],[],"Foundit","https:\u002F\u002Ffoundit.cn","f39339b1-aaa6-4e86-b0c2-a6e6a21113b5",{"id":18,"name":19,"slug":20,"description":21},"6179d3b6-dc34-4483-9ded-3cd9f1b37a47","科普","abbreviation","介绍各领域新兴概念",[23,27],{"id":24,"name":25,"slug":26},"7c76bfc2-f80f-4ee0-a95d-27bd8708b434","技术","slug",{"id":28,"name":29,"slug":30},"4c2bbea6-eab7-40a8-8447-1de478ff7749","分析","analyse","资料来源",null,"published",false,62,0,"2026-08-06T00:00:00.000Z","2026-08-06T05:54:23.873Z","2026-08-06T05:38:22.834Z",[41,57,72],{"id":42,"type":6,"title":43,"slug":44,"summary":45,"body":46,"coverUrl":47,"productScreenshots":48,"productLinks":49,"authorName":14,"authorUrl":15,"authorSubject":16,"category":50,"tags":51,"sourceLabel":31,"sourceName":32,"sourceUrl":32,"status":33,"seoTitle":32,"seoDescription":32,"canonicalUrl":32,"isFeatured":34,"sno":54,"sortOrder":36,"publishedAt":37,"updatedAt":55,"createdAt":56},"d6ff32b0-f499-453a-b1e3-c31a4a7a5b16","布隆过滤器：系统如何快速判断一个东西肯定不存在","bloom-filter-probabilistic-membership-explained","布隆过滤器用位数组和多个哈希函数，以极少内存快速筛掉肯定不存在的对象。本文讲清误报与漏报、误报率、删除难题，以及它在缓存、数据库和去重系统中的正确用法。","如果一个网站已经处理过一千万个 URL，现在来了一个新 URL，系统想知道“它是不是见过”。最直接的做法是把所有 URL 放进集合里查找，但集合会占用内存，冷缓存或远程查询还会拖慢请求。布隆过滤器提供了一个很特别的答案：它可以非常快地告诉你“肯定没见过”，但当它说“见过”时，仍然可能认错。\n\n![布隆过滤器的位数组与哈希映射示意](https:\u002F\u002Fcommons.wikimedia.org\u002Fwiki\u002FSpecial:FilePath\u002FBloom%20filter.svg)\n\n布隆过滤器是一种概率型成员查询结构。它不保存原始对象，只保存一个位数组和若干哈希结果。原始论文讨论的正是如何用更少空间测试一个消息是否属于某个集合，并允许可控的错误概率。[Bloom 的原始论文](https:\u002F\u002Fdoi.org\u002F10.1145\u002F362686.362692)\n\n## 它为什么能说“肯定不存在”\n\n先准备一个全是 0 的位数组，再选择 `k` 个哈希函数。加入字符串 `apple` 时，分别计算 `h1(apple)`、`h2(apple)`……，把这些位置设成 1。\n\n查询 `apple` 时，重新计算这几个位置：\n\n- 只要有一个位置还是 0，`apple` 就不可能被加入过，因为加入时那个位置一定会被设为 1。\n- 如果所有位置都是 1，`apple` 可能加入过，也可能只是恰好撞上了其他对象留下的 1。\n\n这就是它的核心不对称性：没有误报时，它可以断言“肯定不存在”；出现误报时，它只能说“可能存在”。\n\n```mermaid\nflowchart TD\n    A[输入一个对象] --> B[计算多个哈希位置]\n    B --> C{检查位数组}\n    C -->|任意位置为 0| D[肯定不存在]\n    C -->|所有位置为 1| E[可能存在]\n    E --> F[再查真实集合或数据库]\n```\n\n## 一个小例子：用 16 位记录三个单词\n\n假设位数组只有 16 位。加入三个单词后，可能变成：\n\n```text\n0001011000101100\n```\n\n查询新单词时，如果它的三个哈希位置分别落在第 2、5、9 位，其中第 5 位是 0，那么可以立刻拒绝它，不必访问数据库。如果三个位置恰好都是 1，系统不能直接把它当成已存在，而应该再做一次真实查询。\n\n布隆过滤器的正确用法通常是“便宜的前置筛选器”：\n\n```js\nif (!filter.mightContain(key)) {\n  return notFound()\n}\n\nreturn database.lookup(key)\n```\n\n它不是权威数据源，不能用来返回真实对象，也不能把“可能存在”当作最终事实。\n\n## 误报率从哪里来\n\n设位数组长度为 `m`，加入的元素数量为 `n`，每个元素使用 `k` 个哈希函数。元素越多，被设为 1 的位置越多；数组越小，哈希碰撞越密集；哈希函数越多，单次加入会点亮更多位置。三者共同决定误报率。\n\n常见的近似公式是：\n\n```text\np ≈ (1 - e^(-kn\u002Fm))^k\n```\n\n不需要把它当成考试公式，只要记住两个方向：想降低误报率，要增加位数；在固定空间下，哈希数量也存在一个合适区间，太少会让不同元素容易碰撞，太多则会过快把位数组填满。\n\n当目标误报率是 `p`、预计元素数量是 `n` 时，可以先估算需要的位数，再选择哈希数量。工程上还要为增长留余量，因为过滤器通常不适合无限追加数据。\n\n## 删除为什么麻烦\n\n普通布隆过滤器只记录 0 和 1。两个对象可能共同把某一位设成 1，删除其中一个对象时，系统不知道这位是否还被另一个对象使用，所以不能安全地把它改回 0。\n\n有几种常见处理方式：\n\n- 不支持删除，只把过滤器当作生命周期固定的快照。\n- 使用 Counting Bloom Filter，把每一位换成小计数器。\n- 按时间窗口创建多个过滤器，到期后整体丢弃。\n- 使用支持扩容或删除的其他近似成员结构。\n\nCounting Bloom Filter 能删除，但每个位置需要更多空间，也会带来计数器溢出和并发更新问题。所谓“支持删除”不是免费升级，而是改变了成本模型。\n\n## 它适合挡在哪里\n\n在 LSM-Tree 数据库中，布隆过滤器可以先判断某个 SSTable 是否不可能包含目标键，减少磁盘读取；在缓存前，可以先挡住明显不存在的键，降低缓存穿透；在 URL 去重、爬虫任务和数据导入中，它能快速过滤大量重复候选。\n\n但它不适合单独承担安全决策。比如“黑名单过滤器说用户不在黑名单里”只能作为快速路径，不能替代权威权限表；误报只会带来额外查询，漏报却可能导致错误放行，因此要先确认业务能承受它的错误方向。\n\n## 设计时要问的五个问题\n\n1. 元素数量是固定的，还是会持续增长？\n2. 能否接受误报？误报会带来一次慢查询，还是直接给用户错误结果？\n3. 是否需要删除？如果需要，是否值得付出计数器空间？\n4. 过滤器是否需要持久化，还是重启后可以重建？\n5. 过滤器失效时，真实数据路径是否仍然正确？\n\n最后一个问题最重要。布隆过滤器的设计哲学是“优化慢路径”，不是“替代真相”。只要真实查询仍然是正确的，过滤器即使误报，也只是多做了一次工作。\n\n## 一句话带走\n\n布隆过滤器用少量内存换取一次快速判断，但它故意只保证一件事：发现 0，就能确定不存在。理解这种“允许误报、不允许漏报”的取舍，也就理解了很多大型系统为什么会先用一个看似不可靠的小结构挡在真正数据库前面。\n\n## 延伸阅读\n\n- [Bloom 的原始论文：Space\u002Ftime trade-offs in hash coding with allowable errors](https:\u002F\u002Fdoi.org\u002F10.1145\u002F362686.362692)\n- [Wikimedia Commons：Bloom filter 示例图](https:\u002F\u002Fcommons.wikimedia.org\u002Fwiki\u002FFile:Bloom_filter.svg)\n- [Bloom filter 主题分类](https:\u002F\u002Fcommons.wikimedia.org\u002Fwiki\u002FCategory:Bloom_filter)","\u002Fuploads\u002F2026-08-06\u002F234915f6-2ef2-4820-8523-1a98544732f9.jpg",[],[],{"id":18,"name":19,"slug":20,"description":21},[52,53],{"id":24,"name":25,"slug":26},{"id":28,"name":29,"slug":30},61,"2026-08-06T05:55:43.308Z","2026-08-06T05:38:25.381Z",{"id":58,"type":6,"title":59,"slug":60,"summary":61,"body":62,"coverUrl":63,"productScreenshots":64,"productLinks":65,"authorName":14,"authorUrl":15,"authorSubject":16,"category":66,"tags":67,"sourceLabel":31,"sourceName":32,"sourceUrl":32,"status":33,"seoTitle":32,"seoDescription":32,"canonicalUrl":32,"isFeatured":34,"sno":35,"sortOrder":36,"publishedAt":37,"updatedAt":70,"createdAt":71},"23cd6a50-6716-4620-99f3-d5307511c8ee","CRDT：两个人同时改同一段文字，为什么不会互相覆盖","crdt-collaborative-editing-explained","CRDT 允许多个副本先本地修改，再通过可合并的数据结构最终收敛。本文用计数器和协同编辑讲清 CvRDT、CmRDT、并发插入、删除标记、元数据成本与业务语义冲突。","两个人同时编辑同一段文字时，最朴素的同步方式是“谁最后提交，谁覆盖谁”。网络一抖、有人离线，冲突就会变成一场人工抢救。CRDT 的思路更像是：允许每个副本先本地修改，网络恢复后再合并，并且让合并规则保证所有副本最终收敛到同一个状态。\n\n![分布式网络中的节点与连接](https:\u002F\u002Fcommons.wikimedia.org\u002Fwiki\u002FSpecial:FilePath\u002FDistributed-networks.svg)\n\nCRDT 是 Conflict-free Replicated Data Type 的缩写，中文通常译为“无冲突复制数据类型”。它不是某一个数据库，也不是一条网络协议，而是一类带有数学性质的数据结构。经典研究把它分成两条路线：基于状态合并的 CvRDT，以及基于操作传播的 CmRDT。[CRDT 研究论文](https:\u002F\u002Fdsf.berkeley.edu\u002Fcs286\u002Fpapers\u002Fcrdt-tr2011.pdf)\n\n## 先看一个不会让人头疼的例子：计数器\n\n假设 Alice 和 Bob 都看到计数器是 10。Alice 离线加 1，得到 11；Bob 同时加 1，也得到 11。若直接传“新值是 11”，服务器无法知道两次加法都发生过，最后可能只剩 11。\n\nCRDT 计数器不把更新表达成“把值改成 11”，而是表达成“我的副本增加了 1”。每个副本维护自己的增量，合并时把各副本的增量相加。Alice 的增量和 Bob 的增量都被保留下来，最终结果是 12。\n\n这个例子体现了一个重要原则：同步的不是容易覆盖的最终快照，而是能安全合并的事实。\n\n## CRDT 的“无冲突”到底是什么意思\n\n它并不是说任何业务冲突都能神奇消失，而是说：在数据结构规定的操作范围内，只要不同副本最终收到同一批更新，它们会按照确定的规则得到同一个状态。\n\n对于状态型 CRDT，合并函数通常需要满足：\n\n- 交换律：先合并 Alice 再合并 Bob，与反过来相同。\n- 结合律：分组方式不影响最终结果。\n- 幂等性：同一个状态重复收到，结果不会越来越大。\n\n这让网络层可以重试、乱序甚至重复发送更新，而不会因为传输细节把副本推向不同结果。操作型 CRDT 则把重点放在操作可交换，以及传输层满足必要的投递和因果关系条件。\n\n```mermaid\nflowchart LR\n    A[副本 Alice] -->|本地操作| C[可合并更新]\n    B[副本 Bob] -->|本地操作| C\n    C --> D[网络异步传播]\n    D --> E[副本 Alice 合并]\n    D --> F[副本 Bob 合并]\n    E --> G[最终状态收敛]\n    F --> G\n```\n\n## 文本编辑为什么比计数器难得多\n\n计数器的“加 1”没有位置问题，文本却有。Alice 和 Bob 都在字符串 `ABC` 的 `B` 前面插入字符，如果更新只记录“在位置 1 插入”，网络乱序时两个操作就没有稳定身份。\n\n文本 CRDT 通常会给每个字符或元素分配唯一标识，并记录它与其他元素的关系。插入操作不再是“插到第几个格子”，而是“插到某个已知元素附近，并带有唯一 ID”。当两个元素竞争同一个位置时，系统使用确定的排序规则，让所有副本看到同样的顺序。\n\n删除也不是简单地把字符从数组中抹掉。某个副本可能还没收到插入操作，另一个副本已经删除它；为了让迟到的更新仍然能被识别，很多实现会保留删除标记或墓碑。这样做换来了正确合并，却带来元数据膨胀、压缩和垃圾回收问题。\n\n因此，“CRDT 不需要冲突解决”这句话不够准确。它把一部分冲突处理从人工步骤提前搬到了数据结构设计中。设计者仍然要回答：并发插入如何排序？删除和编辑同时发生怎么办？撤销操作如何定义？格式化和权限是否也要合并？\n\n## CvRDT 与 CmRDT：两种合并风格\n\n### 基于状态的 CvRDT\n\n每个副本可以直接发送自己的状态，接收方通过合并函数计算新状态。优点是实现直观、重复发送通常安全；缺点是状态可能很大，频繁传输浪费带宽。\n\n### 基于操作的 CmRDT\n\n副本发送“发生了什么操作”，接收方重放这些操作。优点是更新小，适合增量同步；缺点是操作需要唯一 ID、去重机制和一定的因果处理，不能把任意乱序消息直接当作安全输入。\n\n现实项目常常把两者结合：首次加入房间时发送压缩后的状态，之后发送操作更新；离线时间较长时，再通过状态向量或快照补齐缺口。\n\n## CRDT 不会替你解决的三类问题\n\n第一，语义冲突仍然存在。两个人同时把商品库存从 1 改成 0 和 10，数学上可以收敛，但业务上哪个结果合法，需要库存规则决定。\n\n第二，元数据有成本。为了处理并发、因果和删除，CRDT 往往比最终文本多保存不少信息。移动端、长文档和大量历史记录尤其需要压缩策略。\n\n第三，权限不能靠“最终一致”解决。一个没有权限的客户端如果可以产生任意更新，CRDT 只会忠实地把恶意操作合并到所有副本。权限校验、签名、撤销和服务端策略仍然要单独设计。\n\n## 什么时候值得选 CRDT\n\nCRDT 特别适合离线优先、多人实时协作、节点经常断线、希望本地操作立即响应的场景，例如共享笔记、白板、表单草稿和部分游戏状态。如果业务必须每一步都经过中心服务器批准，或者数据量极小、单主写入已经足够，CRDT 的复杂度可能不值得。\n\n落地时可以按这个顺序思考：先确定需要合并的抽象数据类型，再定义并发操作的语义，然后验证交换、结合和幂等性质，最后才选择具体库。不要因为“协同编辑”四个字就直接把一个文本 CRDT 塞进所有数据模型。\n\n## 一句话带走\n\nCRDT 的魔法不是让冲突不存在，而是把数据表示成“可以安全合并的事实”。它让每个副本先行动、网络随后同步成为可能，但代价是更多元数据、更复杂的撤销与权限设计，以及对业务语义的更严格建模。\n\n## 延伸阅读\n\n- [A comprehensive study of CRDTs](https:\u002F\u002Fdsf.berkeley.edu\u002Fcs286\u002Fpapers\u002Fcrdt-tr2011.pdf)\n- [CRDT 论文索引](https:\u002F\u002Fcrdt.tech\u002Fpapers.html)\n- [CRDTs: Consistency without concurrency control](https:\u002F\u002Farxiv.org\u002Fabs\u002F0907.0929)","\u002Fuploads\u002F2026-08-06\u002F50641649-08d8-4cac-94f0-647eabe9fc5f.jpg",[],[],{"id":18,"name":19,"slug":20,"description":21},[68,69],{"id":24,"name":25,"slug":26},{"id":28,"name":29,"slug":30},"2026-08-06T05:55:11.614Z","2026-08-06T05:38:23.928Z",{"id":73,"type":6,"title":74,"slug":75,"summary":76,"body":77,"coverUrl":78,"productScreenshots":79,"productLinks":80,"authorName":14,"authorUrl":15,"authorSubject":16,"category":81,"tags":82,"sourceLabel":31,"sourceName":32,"sourceUrl":32,"status":33,"seoTitle":32,"seoDescription":32,"canonicalUrl":32,"isFeatured":34,"sno":85,"sortOrder":36,"publishedAt":37,"updatedAt":86,"createdAt":87},"86b63309-e12a-41b4-9d9e-db95480617b0","令牌桶限流：API 如何把流量洪峰变成可控波浪","token-bucket-rate-limiting-explained","令牌桶把长期平均速率和短时突发容量分开，是 API 限流中最常见的模型之一。本文从令牌补充、请求成本和 429 响应讲起，比较固定窗口、滑动窗口与漏桶，并拆解分布式限流的真实难点。","API 最怕的不是“有一个请求很慢”，而是某一秒突然涌进几十万请求。数据库连接池被占满，线程开始排队，重试又制造更多请求，最后一个本来健康的服务被拖成雪崩。令牌桶限流的做法很直观：先往桶里按固定速度放令牌，每个请求来时拿走一个；没有令牌，就等待、拒绝或降级。\n\n![令牌桶算法示意图](https:\u002F\u002Fcommons.wikimedia.org\u002Fwiki\u002FSpecial:FilePath\u002FQoS%20tocken%20bucket.svg)\n\n令牌桶属于一类流量整形与速率控制算法。IETF 的相关规范也使用“令牌桶”描述可用容量、补充速率和到达流量之间的关系。[RFC 2697](https:\u002F\u002Fwww.rfc-editor.org\u002Frfc\u002Frfc2697.html)；当 HTTP 服务拒绝过快的请求时，通常会返回 `429 Too Many Requests`，这个状态码定义在 [RFC 6585](https:\u002F\u002Fwww.rfc-editor.org\u002Frfc\u002Frfc6585.html)。\n\n## 桶里到底有什么\n\n令牌桶至少有两个参数：\n\n- `capacity`：桶最多能存多少令牌，决定允许多大的瞬时突发。\n- `refillRate`：每秒补充多少令牌，决定长期平均速率。\n\n假设桶容量是 5，每秒补充 2 个令牌，每个请求消耗 1 个令牌：\n\n- 长时间平均下来，最多放行约 2 个请求\u002F秒。\n- 如果桶之前攒满了，瞬间可以连续放行 5 个请求。\n- 5 个令牌用完后，后续请求要等补充，或者直接收到 429。\n\n这解释了它比“每秒固定只能两个请求”更灵活：正常情况下允许一小段突发，但不会让突发无限延长。\n\n## 令牌桶的核心算法\n\n每次请求到达时，系统先按照距离上次计算的时间补充令牌，再判断余额够不够。补充不能超过桶容量。\n\n```text\ntokens = min(capacity,\n             tokens + (now - lastTime) * refillRate)\nlastTime = now\n\nif tokens >= requestCost:\n    tokens -= requestCost\n    allow()\nelse:\n    reject_or_wait()\n```\n\n实际代码要处理小数令牌、时间精度和并发更新。若一个请求消耗的资源差异很大，可以让读取接口消耗 1 个令牌、导出报表消耗 10 个令牌，而不是所有请求一视同仁。\n\n```mermaid\nflowchart TD\n    A[请求到达] --> B[按时间补充令牌]\n    B --> C{令牌够请求成本吗}\n    C -->|够| D[扣除令牌并放行]\n    C -->|不够| E{策略选择}\n    E -->|等待| F[排队后重试]\n    E -->|拒绝| G[返回 429]\n    E -->|降级| H[返回缓存或简化结果]\n```\n\n## 它和其他限流算法有什么区别\n\n### 固定窗口：简单，但窗口边界会放大突发\n\n“每分钟最多 60 次”很容易实现，但用户在 12:00:59 发 60 次，12:01:00 再发 60 次，短时间内可能通过 120 次。固定窗口适合粗粒度保护，却不擅长平滑流量。\n\n### 滑动窗口：更精确，但需要保存更多请求记录\n\n滑动窗口会观察最近一段时间的请求，更公平，但高并发下需要维护计数或时间桶，存储和计算成本高于固定窗口。\n\n### 漏桶：输出更平滑\n\n漏桶更强调固定速度地处理流量，像一个底部开口大小固定的桶。它适合需要平滑输出的队列，但如果业务希望“攒一会儿后允许短突发”，令牌桶通常更贴合。\n\n## 真正难的是“按谁限流”\n\n算法本身不复杂，难点是限流键和状态放在哪里。常见限流维度包括用户 ID、API Key、IP、租户、路由和全局服务。只按 IP 限制可能误伤公司网络后的所有用户，只按用户限制又挡不住匿名攻击者；生产系统经常组合多个维度。\n\n单机内存里的令牌桶只能保护一个进程。如果服务有多个实例，每个实例都允许 2 次\u002F秒，集群总量可能变成实例数乘以 2。要得到全局上限，就需要共享计数状态或让流量先经过统一网关。共享状态又带来原子更新、网络延迟和故障降级问题。\n\n一个常见实现会把 `tokens` 和 `lastTime` 放在 Redis 里，用 Lua 脚本一次完成补充和扣除，避免两个请求同时读到同一份余额。也可以按租户把请求路由到固定分片，减少共享状态，但这会牺牲部分弹性。\n\n## 429 不是一句“太快了”就结束\n\n服务返回 429 时，客户端需要知道如何恢复。响应可以附带 `Retry-After`，告诉客户端等待多久再试；客户端也应该使用指数退避和随机抖动，避免所有请求在同一时间再次冲回来。[RFC 6585 对 429 和 Retry-After 的说明](https:\u002F\u002Fwww.rfc-editor.org\u002Frfc\u002Frfc6585.html)\n\n服务端还要区分：\n\n- 这是某个用户的配额耗尽，还是整个服务过载？\n- 请求是可以排队，还是必须立即失败？\n- 是否有缓存、只读副本或简化结果可以降级？\n- 限流状态本身故障时，是偏向放行还是偏向拒绝？\n\n“限流成功”不代表所有请求都被拒绝得很漂亮，而是系统在压力下仍然能保住核心路径，并给调用方一个可恢复的信号。\n\n## 容易踩的坑\n\n第一，时钟不一致。分布式实例用本地时间计算会产生边界误差，需要统一时间来源或接受一定误差。\n\n第二，忽视请求成本。上传大文件和读取一个短字段都扣一个令牌，往往会让限流失去保护意义。\n\n第三，只在应用代码里限流。请求已经占用了连接、TLS 和线程后才被拒绝，数据库可能仍然承受了压力。更早的网关、连接层和资源池保护通常更有效。\n\n第四，把限流当成队列。令牌桶可以控制准入，但不自动解决任务执行时间；长任务仍然需要队列、超时、取消和幂等设计。\n\n## 一句话带走\n\n令牌桶的精髓是把“长期平均速率”和“短时突发容量”分开：桶的大小负责弹性，补充速度负责纪律。把它部署到正确的边界、配上明确的 429 与退避策略，才真正能把流量洪峰变成系统可以承受的波浪。\n\n## 延伸阅读\n\n- [RFC 2697：A Single Rate Three Color Marker](https:\u002F\u002Fwww.rfc-editor.org\u002Frfc\u002Frfc2697.html)\n- [RFC 6585：Additional HTTP Status Codes](https:\u002F\u002Fwww.rfc-editor.org\u002Frfc\u002Frfc6585.html)\n- [Wikimedia Commons：Token bucket 示例图](https:\u002F\u002Fcommons.wikimedia.org\u002Fwiki\u002FFile:QoS_tocken_bucket.svg)","\u002Fuploads\u002F2026-08-06\u002F952eedca-0e08-4c7c-a8a6-d07f1acd5291.jpg",[],[],{"id":18,"name":19,"slug":20,"description":21},[83,84],{"id":24,"name":25,"slug":26},{"id":28,"name":29,"slug":30},63,"2026-08-06T05:53:22.597Z","2026-08-06T05:38:26.532Z"]