diff --git a/notes/pics/im-arch.png b/notes/pics/im-arch.png new file mode 100644 index 00000000..0b367b76 Binary files /dev/null and b/notes/pics/im-arch.png differ diff --git a/notes/pics/im-message.png b/notes/pics/im-message.png new file mode 100644 index 00000000..2b9ffa0c Binary files /dev/null and b/notes/pics/im-message.png differ diff --git a/notes/系统设计基础.md b/notes/系统设计基础.md index 8f077d82..685d3735 100644 --- a/notes/系统设计基础.md +++ b/notes/系统设计基础.md @@ -4,15 +4,17 @@ * [一、性能](#一性能) * [二、伸缩性](#二伸缩性) * [三、扩展性](#三扩展性) - * [四、可用性](#四可用性) + * [四、可用性](#四可用性) 🔥🔥 * [五、安全性](#五安全性) - * [六、秒杀系统](#六秒杀系统) + * [六、秒杀系统](#六秒杀系统) 🔥🔥 * [七、海量数据处理](#七海量数据处理) * [八、短链系统](#八短链系统) - * [九、Feed 流](#九feed-流) - * [十、高并发系统的设计](#十高并发系统的设计) + * [九、Feed 流](#九feed-流) 🔥 + * [十、高并发系统的设计](#十高并发系统的设计) 🔥🔥 * [十一、大文件上传](#十一大文件上传) * [十二、权限系统](#十二权限系统) + * [十三、系统设计题的思路](#十三系统设计题的思路) + * [十四、即时通讯](#十四即时通讯) * [附录:实测](#附录实测) * [参考资料](#参考资料) @@ -26,13 +28,13 @@ 指某个请求从发出到接收到响应消耗的时间。 -在对响应时间进行测试时,通常采用重复请求的方式,然后计算平均响应时间。平均值会掩盖少数很慢的请求,因此也常用 P99 等分位值来衡量。 +在对响应时间进行测试时,通常采用重复请求的方式,然后计算平均响应时间。平均值会掩盖少数很慢的请求,因此也常用 P99 等分位值来衡量。P99(也写作 TP99)指 99% 的请求的响应时间都不超过的值。 一个用户请求通常需要调用多个后端服务,只要其中一个慢,整个请求就慢,后端调用越多,受慢请求影响的用户比例越高,这称为尾部延迟放大。 #### 2. 吞吐量 -指系统在单位时间内可以处理的请求数量,通常使用每秒的请求数(QPS)来衡量。 +指系统在单位时间内可以处理的请求数量,通常使用每秒的请求数(QPS)来衡量,处理的是事务时也用每秒事务数(TPS)。 #### 3. 并发用户数 @@ -95,7 +97,7 @@ - 使用消息队列进行解耦,应用之间通过消息传递进行通信; - 使用分布式服务将业务和可复用的服务分离开来,业务使用分布式服务框架调用可复用的服务。新增的产品可以通过调用可复用的服务来实现业务逻辑,对其它产品没有影响。 -## 四、可用性 +## 四、可用性 🔥🔥 可用性用系统可用时间占总时间的比值来衡量,4 个 9 表示系统 99.99% 的时间是可用的,一年的不可用时间不超过 53 分钟。 @@ -252,7 +254,64 @@ Sentinel 的半开状态只放行一个请求,这个请求成功就结束熔 要求系统在应对各种攻击手段时能够有可靠的应对措施,常见的攻击手段和防范方法见 [攻击技术](攻击技术.md)。 -## 六、秒杀系统 +### 接口防刷 + +接口防刷指的是拦截格式合法、但由脚本发出的大量请求,例如脚本抢购、批量注册、撞库登录,或者利用发送短信验证码的接口给别人的手机号连续发短信。这些请求单独看都是正常的,只能按来源和频率识别。 + +以发送短信验证码的接口为例,每条短信都要付费,被刷时既浪费钱,又骚扰收到短信的人。常用的做法是按多个维度限制频率: + +- 同一个手机号 60 秒内只能发一次,每天最多 10 次; +- 同一个 IP 每小时最多 20 次,防止一台机器换着手机号刷; +- 超过阈值的设备或账号,先要求完成图形或滑块验证码。 + +判断和计数要原子地完成,否则并发的请求会同时通过检查,所以用一个 Lua 脚本实现: + +```lua +-- KEYS[1]:手机号的冷却键,KEYS[2]:手机号当天的计数,KEYS[3]:IP 这一小时的计数 +-- ARGV[1]:冷却秒数,ARGV[2]:手机号每天的上限,ARGV[3]:IP 每小时的上限 +if redis.call('EXISTS', KEYS[1]) == 1 then + return 1 -- 60 秒内已经发过 +end +if tonumber(redis.call('GET', KEYS[2]) or '0') >= tonumber(ARGV[2]) then + return 2 -- 这个手机号今天的次数用完 +end +if tonumber(redis.call('GET', KEYS[3]) or '0') >= tonumber(ARGV[3]) then + return 3 -- 这个 IP 请求太多 +end +redis.call('SET', KEYS[1], 1, 'EX', ARGV[1]) +if redis.call('INCR', KEYS[2]) == 1 then redis.call('EXPIRE', KEYS[2], 86400) end +if redis.call('INCR', KEYS[3]) == 1 then redis.call('EXPIRE', KEYS[3], 3600) end +return 0 -- 可以发送 +``` + +同一个手机号 1 秒内请求 5 次,只有第 1 次发送了短信;把冷却时间改为 1 秒后连续请求 12 次,第 11、12 次因为当天的次数用完被拒绝;同一个 IP 给 30 个手机号发验证码,从第 21 个开始被拒绝(Redis 7.4 实测,见[附录 A20](#a20-接口防刷))。按小时、按天计数是固定窗口,窗口边界附近可能短时间内放过两倍的请求,需要更精确时改用滑动窗口,见 [限流算法](#1-限流算法)。 + +验证码本身也要防止被猜中。6 位数字只有 100 万种,不限制输错次数时,在本机直接调用校验脚本逐个尝试,第 835770 次猜中,只用了 8 秒(见附录 A20);通过网络调用接口会慢一些,但按每秒 1000 次计算,平均约 8 分钟也能猜中。所以校验时: + +- 验证码保存在 Redis 中,5 分钟后过期,多个服务实例都能读到; +- 输对后立即删除,一个验证码只能用一次; +- 同一个验证码输错 5 次就作废,要重新发送。 + +加上发送的限制,一个手机号每天最多能猜 50 次,猜中的概率只有 50 / 106 = 0.005%。 + +开放给合作方的接口还要防止请求被篡改或者重放。重放指的是把截获的合法请求原样再发一次,例如把一次退款请求重复提交。做法是双方事先约定一个密钥,调用方把请求的关键字段和时间戳、随机数(nonce)拼在一起,用密钥计算 HMAC 签名: + +```text +签名原文:POST\n/api/refund\n1793000000\nn-8f3a2c\n{"order": 1024, "amount": 3900} +签名:HMAC-SHA256(密钥, 签名原文) +``` + +服务端依次检查: + +- 时间戳与当前时间相差不超过 5 分钟,很久以前的请求直接拒绝; +- 用同样的密钥重新计算签名并比较,请求中任何一个字段被改过都对不上; +- 执行 SET nonce:n-8f3a2c 1 NX EX 600 记录随机数,失败说明这个请求已经处理过。随机数保存的时间要覆盖时间戳允许的范围,否则过期后又能重放。 + +原样重放的请求因为随机数已经用过被拒绝,改掉金额的请求签名对不上,10 分钟后再重放则时间戳超出了范围(见附录 A20)。需要注意的是,签名只适合密钥保存在服务端的调用,例如开放平台和支付回调;写在 App 或网页中的密钥可以被逆向取出,只能增加刷接口的成本。 + +识别出的刷接口来源,还可以在网关中加入黑名单,例如 SET ban:ip:203.0.113.7 1 EX 3600,一小时内直接拒绝它的请求。规模更大的系统会接入风控,根据设备指纹、操作轨迹等判断请求是否来自机器,可疑时要求验证码。图形和滑块验证码能被打码平台破解,同样只能提高成本,所以要和限频、黑名单一起使用。 + +## 六、秒杀系统 🔥🔥 秒杀指的是少量商品在某一时刻开售,大量用户同时抢购,例如 100 件商品在开售瞬间收到 10 万个请求。难点在于: @@ -529,7 +588,7 @@ CREATE TABLE short_url ( 短链读多写少,热门链接的访问又很集中,适合用 Redis 缓存短码到长链接的映射。不存在的短码会穿透到数据库,可以缓存空值或者使用布隆过滤器,见 [缓存穿透](缓存.md#缓存穿透)。点击统计不要在跳转时同步写数据库,而是把点击事件写入消息队列,异步汇总。 -## 九、Feed 流 +## 九、Feed 流 🔥 Feed 流指的是把关注的人发布的内容按时间倒序合并后展示,例如朋友圈和微博的首页。每个用户有一个发件箱,保存自己发的帖子;难点在于怎样把几百个人的发件箱合成一个首页。 @@ -562,7 +621,7 @@ p20 p19 p18 ... p11 左括号表示不包含 21 本身,这是 Redis 6.2 起的写法,相当于旧的 ZREVRANGEBYSCORE。分值相同的帖子可能被跳过,可以把分值设为帖子 ID 这类唯一且递增的值,例如雪花算法生成的 ID 本身就按时间递增,见 [分布式 ID](分布式.md#八分布式-id)。 -## 十、高并发系统的设计 +## 十、高并发系统的设计 🔥🔥 高并发指的是系统在某段时间内要同时处理大量请求。设计时通常先估算峰值 QPS,再让绝大部分请求在到达数据库之前被挡住,最后用异步、限流和降级保证剩下的请求不会压垮系统。 @@ -967,6 +1026,206 @@ RBAC 还可以扩展两类约束。角色继承(role hierarchy)指高级角 Agent 调用工具时,模型只输出调用意图,是否真正执行由权限检查决定。最小权限的做法是:为每个 Agent 只配置完成任务所需的工具和数据范围,读操作与写操作分开授权,高风险操作需要人工审批,见 [大模型应用](大模型应用.md#安全)。 +## 十三、系统设计题的思路 + +系统设计题通常只给一句话,例如“设计一个点赞系统”,没有标准答案。面试官看的是能否把模糊的需求变成具体的方案,以及能否说清每个选择的代价。可以按以下顺序展开: + +1. 明确需求:要支持哪些功能、规模多大、哪些数据不能出错; +2. 估算规模:算出读写的峰值和存储量,判断需要什么量级的方案; +3. 定义接口和数据模型; +4. 先给出一个能工作的简单方案; +5. 按估算找出瓶颈,逐个改进; +6. 说明一致性、故障时的表现和取舍。 + +以点赞系统为例走一遍。 + +### 明确需求 + +功能:用户可以给视频点赞和取消,视频下方显示点赞数和自己是否点过赞,“我赞过的视频”按时间倒序列出。 + +非功能需求决定了后面的方案,要和面试官确认: + +- 同一个用户对同一个视频只能计一次; +- 点赞数允许延迟几秒,但用户自己点过赞,刷新页面后必须显示已点赞; +- 热门视频可能在短时间内收到大量点赞。 + +直播间的点赞是另一种需求:只累计次数,不记录谁点过,同一个人可以连续点很多下,见下文。 + +### 估算规模 + +假设日活用户 2000 万,每人每天看 100 个视频、点赞 5 次,晚高峰一小时占全天流量的 6%(与 [容量估算](#容量估算) 相同): + +- 写:每天 1 亿次点赞,峰值约 1 亿 × 6% ÷ 3600 ≈ 1700 次/秒; +- 读:每看一个视频都要读点赞数和“是否点过”,每天 20 亿次,峰值约 3.3 万次/秒; +- 存储:点赞关系表每行约 112 字节(含二级索引,MySQL 8.0 实测,见[附录 A21](#a21-点赞系统)),每年约 365 亿行、4 TB。 + +由此可以得出:读远多于写,适合加缓存;每秒 1700 次写入单库能承受,但几年的数据量单库放不下,要分库分表。 + +### 接口与数据模型 + +```text +POST /videos/{id}/like 点赞,重复调用只计一次 +DELETE /videos/{id}/like 取消点赞 +GET /videos/like-status?ids=1024,1025 一页视频的点赞数和是否点过 +``` + +```sql +CREATE TABLE video_like ( + user_id BIGINT NOT NULL, + video_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, video_id), -- 同一个用户对同一个视频只能有一行 + KEY idx_user_time (user_id, created_at) -- 我赞过的视频 +); + +CREATE TABLE video_stat ( + video_id BIGINT PRIMARY KEY, + like_count BIGINT NOT NULL DEFAULT 0 +); +``` + +点赞关系以 (user_id, video_id) 为主键,重复点赞时插入失败(ERROR 1062 Duplicate entry),接口自然是幂等的;查询一页视频是否点过,只需要一条按 user_id 的查询。点赞数单独保存,显示时不必 COUNT(*)。 + +### 简单方案 + +单库时在一个事务中完成: + +```sql +BEGIN; +INSERT INTO video_like (user_id, video_id) VALUES (1001, 1024); -- 重复点赞时报错 1062,回滚 +UPDATE video_stat SET like_count = like_count + 1 WHERE video_id = 1024; +COMMIT; +``` + +两张表在同一个事务中修改,点赞关系和点赞数不会不一致,规模不大时这样就够了。面试中先给出这个方案,再说明它在估算的规模下哪里会出问题。 + +### 找出瓶颈并改进 + +#### 1. 热门视频的计数 + +点赞同一个视频的请求都要更新 video_stat 中的同一行,行锁让它们只能一个接一个地执行。对同一行执行 UPDATE ... + 1,每秒只有 5000~7000 次(见 [Redis 的计数器](Redis.md#计数器)),一个视频同时被几十万人点赞时,请求会排队直至超时。 + +改进的办法是把计数移出点赞的事务: + +- 点赞数放在 Redis 中用 INCR 增加,单个键每秒能处理约 10 万次;单个键仍然太热时,拆成多个键分别累加,读取时求和; +- 数据库中的点赞数改为异步合并写入,例如每秒把视频 1024 这一秒的增量合并成一条 UPDATE ... + 37。 + +这样点赞关系和点赞数不在一个事务中,可能出现关系写入成功、点赞数没有加上的情况。以点赞关系为准,点赞数是由它派生出来的:更新失败的计数通过消息重试,再定期对热门视频重新统计 COUNT(*) 校正。 + +#### 2. 读的压力 + +点赞数直接从 Redis 中读。“是否点过”按用户查询,一页 10 个视频用一条按 user_id 的查询就能查完,再缓存用户最近点过赞的视频,大部分请求不需要访问数据库。 + +用 Redis 的 SET 保存每个视频的点赞用户看起来更直接,但热门视频的集合很大:100 万个用户 ID 约占 30 MB(Redis 7.4 实测,见附录 A21),1000 万个就是约 300 MB 的大 Key,删除和迁移时都会阻塞 Redis,见 [大 Key 与热 Key](缓存.md#大-key-与热-key)。所以“谁赞过什么”按用户保存,而不是按视频保存。 + +#### 3. 数据量 + +一年约 4 TB,需要分库分表,见 [水平切分](MySQL.md#水平切分)。分片键选 user_id:“是否点过”和“我赞过的视频”都按用户查询,按 user_id 分片后每次只访问一个分片;按 video_id 分片的话,这两类查询都要访问所有分片。需要按视频列出点赞的用户时(例如“谁赞了我的视频”),另外异步写一份按 video_id 分片的副本。 + +#### 4. 直播间的点赞 + +直播间的点赞只累计次数。观众连续点了 10 下,客户端不必发 10 个请求,而是攒 1 秒后发送一次“+10”;服务端再把同一个直播间的增量在内存中累加,每秒用 INCRBY 写入 Redis 一次。几万人同时点赞时,这个键每秒只被每个服务实例更新一次,观众看到的数字比实际晚 1~2 秒,这在直播间中可以接受。 + +### 说明取舍 + +最后说明方案的取舍和故障时的表现: + +- 点赞关系是准确的,点赞数是最终一致的,可能延迟几秒; +- 点赞关系同步写入数据库,并更新这个用户的缓存,所以用户刷新页面后能看到自己的点赞; +- Redis 宕机时,点赞数改为从数据库的 video_stat 中读取,没有写回的增量通过与点赞关系对账补回。 + +前面的秒杀、短链和 Feed 流也可以按这个顺序复述:先问清需求,算出量级,再从简单方案出发逐步改进。 + +## 十四、即时通讯 + +即时通讯(Instant Messaging, IM)指的是聊天这类实时收发消息的系统,例如用户 1001 问用户 1002“订单 1024 到了吗”,1002 的手机要马上收到。它要解决三个问题:服务器要能主动把消息推给客户端;消息不能丢、不能重复,顺序不能乱;大量用户同时在线,每个用户都要保持一条连接。 + +### 长连接与整体结构 + +HTTP 只能由客户端发起请求,服务器要主动推送,就要让客户端和服务器之间保持一条长连接:网页中通常用 [WebSocket](HTTP.md#websocket),App 中也可以在 TCP 上使用自己定义的协议。App 退到后台后,连接可能被系统断开,这时通过手机厂商的推送通道(例如苹果的 APNs)发送通知。 + +有了长连接,服务器就要记住每个用户连在哪台机器上。通常把维护连接和处理业务分成两层: + +

+ +- 连接网关:维护长连接、收发心跳,不包含业务逻辑。用户连上网关时,把“用户 → 网关”写入路由表,例如 route:1002 → gw-3; +- 消息服务:无状态,负责鉴权、存储和投递消息。投递给 1002 时,先查路由表找到 gw-3,再把消息交给 gw-3,由它写到 1002 的连接上。 + +分开的原因是长连接有状态:重启网关会断开上面所有用户的连接,这些用户会同时重连。经常改动的业务代码放在消息服务中,发布时不会断开用户的连接,网关则很少需要发布。网关扩容后,新的连接写入新网关的地址,消息服务按路由表投递,不需要知道有多少个网关。 + +### 消息的可靠投递 + +TCP 只保证一条连接内的数据不丢、有序,但连接会断开、服务会重启,消息可能在任何一个环节丢失或者重复,所以应用层要自己做确认、去重和排序: + +

+ +1. 发送方为每条消息生成唯一的 msg_id,例如 c-77。消息服务存储成功后回复确认(ACK),发送方超时没有收到 ACK,就用同一个 msg_id 重发,消息服务按 msg_id 去重,见 [幂等](分布式.md#九幂等); +2. 消息服务为每个会话分配递增的序号 seq,例如这条是 1001 和 1002 的会话中的第 58 条。接收方按 seq 排序显示,而不是按发送时间,因为各台机器的时钟并不一致; +3. 推送给 1002 后,1002 回复已经收到的 seq; +4. 1002 断线期间的第 59、60 条推送失败,但消息已经存储。1002 重连后带上自己收到的最大 seq 58,拉取之后的消息。收到第 61 条而本地只有第 58 条时,同样说明中间缺了消息,要按 seq 补拉。 + +所以推送只负责及时通知,消息是否收全以 seq 为准,离线消息就是用户上线后按 seq 拉取到的这部分消息。seq 只需要在会话内递增,可以用 Redis 的 INCR 为每个会话生成。 + +消息按会话保存,单聊和群聊都是一个会话: + +```sql +CREATE TABLE message ( + conv_id BIGINT NOT NULL, -- 会话 ID + seq BIGINT NOT NULL, -- 会话内递增的序号 + sender_id BIGINT NOT NULL, + msg_id VARCHAR(32) NOT NULL, -- 发送方生成,用于去重 + content TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (conv_id, seq), + UNIQUE KEY uk_sender_msg (sender_id, msg_id) +); + +CREATE TABLE conv_member ( + conv_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + read_seq BIGINT NOT NULL DEFAULT 0, -- 读到了第几条 + PRIMARY KEY (conv_id, user_id), + KEY idx_user (user_id) +); +``` + +拉取第 58 条之后的消息是一次主键上的范围查询:SELECT ... FROM message WHERE conv_id = ? AND seq > 58 ORDER BY seq LIMIT 100。重发的消息因为 (sender_id, msg_id) 已经存在而插入失败。 + +### 群聊 + +群聊消息的投递和 [Feed 流](#九feed-流) 一样有两种方式: + +- 写扩散:为每个成员保存一份,500 人的群,一条消息要写 500 次,读取时每个人只读自己的消息列表; +- 读扩散:群消息只保存一份,按群分配 seq,每个成员只记录自己读到的位置,即上面的 read_seq。 + +大群通常用读扩散。例如群 88 的成员 1002 读到第 1 条后下线,群里又发了 9 条消息,最大的 seq 变为 10。1002 上线时,未读数就是 10 − 1 = 9,再按 seq > 1 拉取这 9 条,不需要逐条记录谁读了哪条消息(MySQL 8.0 实测,见[附录 A22](#a22-长连接与心跳))。推送时也只推给在线的成员,并且可以只推送“群 88 有新消息”的通知,由客户端自己拉取。 + +### 心跳与重连 + +连接断开时两端不一定能收到通知,例如手机切换网络或者进入电梯,中间的 NAT 和代理也可能删除长时间没有数据的连接。TCP 自带的保活机制(keepalive)默认空闲 2 小时后才开始探测,每 75 秒探测一次、共 9 次,要 2 小时 11 分钟才能发现断线(Ubuntu 24.04 的默认值,见附录 A22);而且它由内核回复,只能说明对方的系统还在,不能说明对方的程序还能处理消息。所以应用层要自己发心跳: + +- 客户端每隔一段时间(例如 30 秒)发送一个心跳包,服务端回复; +- 服务端记录每个连接最后一次收到数据的时间,超过 3 个心跳周期(例如 90 秒)没有收到就关闭连接,并删除路由表中的记录; +- 客户端连续几次收不到回复,就认为连接已经断开,主动重连。 + +10 万个连接每 30 秒发一次心跳,服务端每秒要处理约 3300 个心跳包,单线程的 NIO 服务约占用一个 CPU 核的 11%;其中 1000 个连接不发心跳,超过 90 秒后被服务端全部关闭(JDK 21 实测,见附录 A22)。心跳间隔是一种取舍:间隔越短,越早发现断线,但手机越耗电,服务端负担也越重。 + +重连时要避免所有客户端同时重连。一个网关重启时,上面的几万个用户会在同一时刻重连,可能压垮其它网关。所以重连的间隔按指数增长(1 秒、2 秒、4 秒……),再加上随机的抖动,把重连分散开。重连成功后,先按 seq 拉取断线期间的消息。 + +### 单机能支撑多少连接 + +一个常见的误解是一台服务器最多只能有 65535 个连接。TCP 连接由四元组(源 IP、源端口、目的 IP、目的端口)区分,服务端的所有连接共用一个监听端口,只要客户端的地址或者端口不同,就是不同的连接。 + +端口数的限制在发起连接的一方:从同一个源 IP 连接同一个目的地址和端口,可用的源端口只有 ip_local_port_range 规定的范围,Ubuntu 默认是 32768~60999,实测第 28233 个连接失败,报错 Cannot assign requested address(见附录 A22)。网关连接后端服务、压测的客户端都可能遇到这个限制。 + +服务端的限制主要是: + +- 文件描述符:每个连接占用一个,要调大 ulimit -n,见 [epoll](Socket.md#epoll); +- 内存:10 万个空闲连接,Java 进程的内存增加约 120 MB,内核中每个套接字约 4 KB,合计每个连接约 5 KB,100 万个连接约需 5 GB; +- CPU 和带宽:取决于消息量,空闲的连接只需要处理心跳。 + +管理这么多连接要用 epoll 这类 [I/O 复用](Socket.md#二io-复用):实测中一个线程就管理了全部 10 万个连接,每个连接一个线程的话需要 10 万个线程。 + ## 附录:实测 环境为 Apple M1 Pro、macOS,Redis 7.4.1 使用默认配置,客户端为 redis-py。 @@ -1825,6 +2084,149 @@ def conditional(uid): # 条件更新 条件更新 stock > 0 请求 150 成功 100 售罄 50 冲突 0 剩余库存 0 订单 100 66 ms ``` +### A20 接口防刷 + +Redis 7.4.1,Apple M1 Pro,macOS,默认配置。发送限制的脚本与正文相同,校验的脚本如下,客户端用 redis-py 调用: + +```lua +-- KEYS[1]:验证码,KEYS[2]:输错的次数;ARGV[1]:用户输入,ARGV[2]:最多输错几次 +local code = redis.call('GET', KEYS[1]) +if not code then + return -1 -- 过期或已经作废 +end +if code == ARGV[1] then + redis.call('DEL', KEYS[1], KEYS[2]) -- 验证通过,验证码只能用一次 + return 1 +end +local n = redis.call('INCR', KEYS[2]) +if n == 1 then redis.call('EXPIRE', KEYS[2], 300) end +if n >= tonumber(ARGV[2]) then + redis.call('DEL', KEYS[1], KEYS[2]) -- 输错太多次,作废 + return -2 +end +return 0 -- 输错,还可以再试 +``` + +实验 B 为了不等太久,把冷却时间改为 1 秒;实验 D 先用限制 5 次的脚本猜 10 次,再把上限改为 109,用流水线每批 1000 次从 000000 开始穷举。签名用 HMAC-SHA256,服务端依次检查时间戳(前后 5 分钟)、签名和 nonce(SET NX EX 600): + +```text +== A 同一个手机号 1 秒内请求 5 次 +['发送', '冷却中', '冷却中', '冷却中', '冷却中'] + sms:cd 的剩余秒数 60 +== B 冷却改为 1 秒,同一个手机号每隔 1.1 秒请求一次,共 12 次(每天上限 10 次) +['发送', '发送', '发送', '发送', '发送', '发送', '发送', '发送', '发送', '发送', '今天次数已用完', '今天次数已用完'] +== C 同一个 IP 依次给 30 个不同的手机号发验证码(每小时上限 20 次) +发送 20 次;被拒绝 10 次,从第 21 个开始 +== D 穷举验证码 +真实验证码 835769 +限制输错 5 次:前 10 次猜测的返回值 [0, 0, 0, 0, -2, -1, -1, -1, -1, -1] (0 输错,-2 作废,-1 已不存在) +不限制输错次数:第 835770 次猜中 835769,用时 8.1 秒(本机,流水线每批 1000 次) +限制后的上限:每个验证码 5 次 × 每天 10 个验证码 = 50 次,每天猜中的概率 50 / 10^6 = 0.005% +== E 签名与防重放 +签名原文: 'POST\n/api/refund\n1793000000\nn-8f3a2c\n{"order": 1024, "amount": 3900}' +签名 2f908ffdaf053064... +第一次请求: 通过 +原样重放: 拒绝:nonce 已经用过 +改掉金额再发: 拒绝:签名不对 +10 分钟后重放: 拒绝:时间戳超过 5 分钟 +``` + +### A21 点赞系统 + +MySQL 8.0.44,Apple M1 Pro,默认配置。video_like 表与正文相同,10 万个用户各点赞 10 个随机的视频,按随机顺序分批插入,再用 ANALYZE TABLE 更新统计信息: + +```text +插入 999997 行,用时 52.9 秒 +COUNT(*) = 999997,data_length = 68.6 MB,index_length = 38.6 MB,每行合计 112 字节 +表空间文件 116.0 MB,每行 122 字节 +重复点赞: (1062, "Duplicate entry '10064451-51075055' for key 'video_like.PRIMARY'") +INSERT IGNORE 重复点赞,影响行数 0 +一次查询 5 个视频是否点过: (None, 'ref', 'PRIMARY,idx_user_time', 'idx_user_time', '8', 'const', 10) +``` + +最后一行是 EXPLAIN 的 type、possible_keys、key、key_len、ref、rows 列:查一个用户对 5 个视频是否点过赞,用 user_id 等值查询,扫描 10 行。 + +Redis 7.4.1,同一台机器。向一个 SET 中加入 n 个 8 位数字的用户 ID,记录 INFO memory 中 used_memory 的增量;MEMORY USAGE 是估算值,偏大: + +```text +SET 中 100 个用户 ID:编码 intset,used_memory 增加 592 字节(每个 6 字节),MEMORY USAGE 488 字节 +SET 中 10000 个用户 ID:编码 hashtable,used_memory 增加 410704 字节(每个 41 字节),MEMORY USAGE 596736 字节 +SET 中 1000000 个用户 ID:编码 hashtable,used_memory 增加 30070800 字节(每个 30 字节),MEMORY USAGE 48388736 字节 +``` + +### A22 长连接与心跳 + +Docker Desktop 中的 Ubuntu 24.04 容器(内核 6.10.14-linuxkit,aarch64,--privileged,ulimit -n 为 300000),JDK 21。服务端和客户端在同一个容器中: + +- 服务端:一个线程、一个 Selector 管理所有连接;收到 PING 回复 PONG,并记录连接最后一次收到数据的时间;每 5 秒扫描一次,关闭超过 90 秒没有收到数据的连接; +- 客户端:先不指定源地址,连接 127.0.0.1:9000,直到失败;然后把 ip_local_port_range 改为 10000~65000(第一次实验用默认范围连接 4 个目的地址、每个 2.5 万个连接,用了 233 秒才建立完),依次连接 127.0.0.2~127.0.0.9 共 8 个目的地址,建立 10 万个连接,每个连接每 30 秒发一次心跳,前 1000 个连接不发; +- Slab 是整个虚拟机的内核内存,包括两端的套接字,10 万个连接共 20 万个套接字。 + +```text +== 内核参数 +ip_local_port_range: 32768 60999 +tcp_keepalive_time/intvl/probes: 7200 75 9 +ulimit -n: 300000 +(第一部分)ip_local_port_range: 32768 60999 +同一个目的地址:成功 28232 个,第 28233 个失败:java.net.BindException: Cannot assign requested address +== 服务端启动后 +VmRSS: 41632 kB +Slab: 67152 kB +TCP: inuse 0 orphan 0 tw 0 alloc 26 mem 767 +连接 8 个目的地址,建立 100000 个连接,用时 5.6 秒 +== 10 万个连接建立后 +VmRSS: 162056 kB +Slab: 910068 kB +TCP: inuse 0 orphan 0 tw 0 alloc 200026 mem 1023 +== 之后 60 秒(心跳),服务端 CPU 时间 688 个时钟周期(CLK_TCK=100) +== 心跳 60 秒后 +VmRSS: 173692 kB +Slab: 910820 kB +TCP: inuse 0 orphan 0 tw 0 alloc 200026 mem 1535 +全部建立后又运行 150 秒:累计收到 PONG 506741 个,被服务端关闭的连接 1000 个 +== 服务端日志(节选) + 10 s 连接 100000 收到心跳累计 19662 因超时关闭 0 堆已用 79 MB + 30 s 连接 100000 收到心跳累计 85662 因超时关闭 0 堆已用 80 MB + 90 s 连接 100000 收到心跳累计 283703 因超时关闭 0 堆已用 77 MB + 100 s 连接 99000 收到心跳累计 316976 因超时关闭 1000 堆已用 79 MB + 150 s 连接 99000 收到心跳累计 482088 因超时关闭 1000 堆已用 87 MB +``` + +由此计算:服务端进程的 VmRSS 增加约 120 MB,每个连接约 1.2 KB;Slab 增加 843 MB,每个套接字约 4.2 KB;心跳阶段服务端 60 秒内用了 6.88 秒 CPU,约为一个核的 11%。 + +群聊的未读数和拉取,表结构与正文相同,群 88 的成员 1002 读到第 1 条,群里共有 10 条消息: + +```text ++-----------------+ +| resend_affected | ++-----------------+ +| 0 | ++-----------------+ ++--------+ +| unread | ++--------+ +| 9 | ++--------+ ++-----+---------+ +| seq | content | ++-----+---------+ +| 2 | 2 | +| 3 | 3 | +| 4 | 4 | +| 5 | 5 | +| 6 | 6 | +| 7 | 7 | +| 8 | 8 | +| 9 | 9 | +| 10 | 10 | ++-----+---------+ ++----+-------------+---------+------------+-------+---------------+---------+---------+------+------+----------+-------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+---------+------------+-------+---------------+---------+---------+------+------+----------+-------------+ +| 1 | SIMPLE | message | NULL | range | PRIMARY | PRIMARY | 16 | NULL | 9 | 100.00 | Using where | ++----+-------------+---------+------------+-------+---------------+---------+---------+------+------+----------+-------------+ +``` + ## 参考资料 - [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/)