diff --git a/notes/pics/massive-topk.png b/notes/pics/massive-topk.png new file mode 100644 index 00000000..2d340f47 Binary files /dev/null and b/notes/pics/massive-topk.png differ diff --git a/notes/pics/seckill.png b/notes/pics/seckill.png new file mode 100644 index 00000000..6ea98b97 Binary files /dev/null and b/notes/pics/seckill.png differ diff --git a/notes/系统设计基础.md b/notes/系统设计基础.md index 84e0dc5a..4e6cfce1 100644 --- a/notes/系统设计基础.md +++ b/notes/系统设计基础.md @@ -6,6 +6,8 @@ * [三、扩展性](#三扩展性) * [四、可用性](#四可用性) * [五、安全性](#五安全性) + * [六、秒杀系统](#六秒杀系统) + * [七、海量数据处理](#七海量数据处理) * [参考资料](#参考资料) @@ -123,7 +125,91 @@ 要求系统在应对各种攻击手段时能够有可靠的应对措施,常见的攻击手段和防范方法见 [攻击技术](攻击技术.md)。 +## 六、秒杀系统 + +秒杀指的是少量商品在某一时刻开售,大量用户同时抢购,例如 100 件商品在开售瞬间收到 10 万个请求。难点在于: + +- 瞬时流量远超平时,但绝大多数请求注定失败; +- 库存不能超卖,一个用户只能买一件; +- 抢购不能拖垮正常的业务。 + +解决思路是层层过滤,使最终到达数据库的请求接近库存数量: + +

+ +1. 前端和网关:开售前按钮置灰,提交后禁止重复点击,网关按用户和接口限流,拦截刷单请求; +2. 库存预扣:开售前把库存加载到 Redis 中,用 Lua 脚本在 Redis 中判断并扣减库存,没有库存的请求直接返回已抢完; +3. 异步下单:扣减成功的请求写入消息队列,订单服务按自己的处理能力消费,扣减数据库中的库存并创建订单,用户通过轮询等方式查询结果; +4. 隔离:秒杀服务独立部署,避免影响正常的业务。 + +预扣库存的 Lua 脚本如下: + +```lua +-- KEYS[1]:库存,KEYS[2]:已购用户集合,ARGV[1]:用户 ID +if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then + return 2 -- 一人一单,重复购买 +end +if tonumber(redis.call('GET', KEYS[1])) <= 0 then + return 1 -- 库存不足 +end +redis.call('DECR', KEYS[1]) +redis.call('SADD', KEYS[2], ARGV[1]) +return 0 -- 抢购成功 +``` + +Redis 执行 Lua 脚本时不会穿插执行其它命令,判断是否已购、判断库存、扣减库存和记录用户这四步作为一个整体完成。如果在应用中先 GET 库存、判断大于 0 之后再 DECR,两个请求可能同时读到库存为 1,并且都扣减成功。用 64 个并发客户端实测,库存为 100、1000 个用户各请求 2 次:Lua 脚本恰好卖出 100 件;先查询后扣减的写法卖出了 104 件,库存变成了 -4。 + +数据库也要兜底。扣减库存时把库存大于 0 作为条件,影响的行数为 0 说明库存不足: + +```sql +UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0; +``` + +一人一单可以用订单表上(用户 ID,商品 ID)的唯一索引保证,消息被重复消费时插入失败,也就实现了幂等。Redis 中扣减成功、但订单最终创建失败时,需要把 Redis 中的库存和购买记录恢复,并定期与数据库对账。 + +## 七、海量数据处理 + +这类问题的特点是数据量远大于内存,例如“100 GB 的文件,内存只有 4 GB”。常用的思路是分而治之:先把数据切成能放进内存的小块,分别处理后再合并结果。 + +### Top K + +从 1 亿个整数中找出最大的 10 个:维护一个大小为 10 的小顶堆,堆顶是当前第 10 大的数。依次读入每个数,比堆顶大就替换堆顶并调整堆,最后堆中的 10 个数就是答案。时间复杂度为 O(NlogK),内存中只需保存 10 个数,数据可以一边读一边处理;而全部排序需要 O(NlogN) 的时间,还要把 400 MB 的数据都读入内存。Java 实现见 [Leetcode 题解 - 排序](Leetcode%20题解%20-%20排序.md#1-kth-element)。 + +### 出现次数最多的 K 个 + +100 GB 的日志文件中每行是一个 URL,内存只有 4 GB,找出出现次数最多的 10 个 URL。直接用 HashMap 统计所有 URL 会超出内存,需要先按哈希切分: + +

+ +1. 逐行读取,按 hash(url) % 100 写入 100 个小文件,平均每个约 1 GB; +2. 依次把每个小文件读入内存,用 HashMap 统计每个 URL 的次数,再用小顶堆得到这个文件中出现次数最多的 10 个; +3. 用一个小顶堆从 100 × 10 个候选中选出全局的前 10 个。 + +这样做的关键是相同的 URL 一定落在同一个小文件中,所以每个文件单独统计出的次数就是这个 URL 的总次数。HashMap 占用的内存取决于文件中不同 URL 的数量,如果某个文件中不同的 URL 仍然太多,可以换一个哈希函数对它再次切分。 + +### 两个大文件的交集 + +两个文件各有 50 亿个 URL,每个 URL 64 字节,找出两个文件共同的 URL。每个文件有 320 GB,用同一个哈希函数把两个文件各切成 1000 份,每份约 320 MB。相同的 URL 一定落在编号相同的一对小文件中,只需要依次把 ai 读入 HashSet,再逐行检查 bi。 + +如果允许少量误判,可以用 [布隆过滤器](缓存.md#缓存穿透):把 a 中的 URL 都加入布隆过滤器,再逐个检查 b 中的 URL。4 GB 内存约有 344 亿位,每个 URL 平均 6.9 位,使用 5 个哈希函数时误判率约为 3.7%;要把误判率降到 1%,每个 URL 需要约 9.6 位,共需 5.6 GB。 + +### 去重与判断是否存在 + +数据是整数时可以用位图,每个整数只用 1 位表示是否出现。int 共有 232 个取值,位图只需要 512 MB。 + +例如在 2 亿个整数中找出只出现一次的数,可以给每个整数分配 2 位:00 表示没出现,01 表示出现一次,10 表示出现多次,共需 1 GB。读完所有数据后扫描位图,取出状态为 01 的数。 + +### 外部排序 + +100 GB 的数据排序,内存只有 1 GB: + +1. 分段排序:每次读入 1 GB 数据,在内存中排序后写回磁盘,得到 100 个有序的文件; +2. 多路归并:同时打开这 100 个文件,用大小为 100 的小顶堆,每次取出最小的元素写入结果文件,再从它所在的文件读入下一个元素。 + +如果数据的取值范围很小,例如 1 亿个 10000 到 20000 之间的整数,一共只有 10001 种取值,用长度为 10001 的数组统计每个值出现的次数,一次扫描就能完成排序,也就是计数排序。 + ## 参考资料 +- [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/) - 大型网站技术架构:核心原理与案例分析 - [Designing Data-Intensive Applications 中文版](https://github.com/vonng/ddia) diff --git a/tools/diagram/src/massive-topk.svg b/tools/diagram/src/massive-topk.svg new file mode 100644 index 00000000..0e3bef50 --- /dev/null +++ b/tools/diagram/src/massive-topk.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + 100 GB 日志中出现次数最多的 10 个 URL(内存 4 GB) + + + 100 GB 日志 + 每行一个 URL + hash(url) % 100 + + + 文件 0 + + + HashMap 计数 + + + + 前 10 + + + + + 文件 1 + + + HashMap 计数 + + + + 前 10 + + + ⋮ + + + 文件 99 + + + HashMap 计数 + + + + 前 10 + + + + + 小顶堆 + 全局前 10 + ① 约 1 GB / 份 + ② 每次只把一份读入内存 + ③ 合并 100 × 10 个候选 + 相同的 URL 一定落在同一份中,所以每份单独统计的次数就是它的总次数 + diff --git a/tools/diagram/src/seckill.svg b/tools/diagram/src/seckill.svg new file mode 100644 index 00000000..68fdfcea --- /dev/null +++ b/tools/diagram/src/seckill.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + 秒杀:层层过滤请求 + + + 10 万个请求 + 同时到达 + + + 前端与网关 + 按钮防重复提交 + 按用户限流 + + + Redis + Lua + 判断库存和是否已购 + 原子扣减,库存 100 → 0 + + + 消息队列 + 100 条 + + + 订单服务 + 扣数据库库存 + 创建订单 + + + + 成功 + + + 其余请求 + + 直接返回“已抢完” + + + 拦截重复和超限请求 + 库存大于 0 才扣减 + 唯一索引保证一人一单 + 到达数据库的请求接近库存数量;订单最终创建失败时,要把 Redis 中的库存和购买记录恢复。 +