For the complete documentation index, see llms.txt. This page is also available as Markdown.

业务场景

1. 强制用户下线,让其无法再次登录怎么设计?

会话管理: 当用户登录成功时,你可以在服务器端为该用户生成一个唯一的会话 ID,并且在用户的设备上设置一个相应的 cookie。用户后续每次发起请求时,服务器都会检查这个 cookie 来确认用户的身份。当你想要强制用户下线时,只需要在服务器上销毁这个会话 ID,然后用户的设备就不能再用这个 cookie 来验证自己了。

更改用户状态: 在用户的数据库记录中添加一个字段,比如叫做 " 是否被禁止 "。当你想要强制用户下线时,将这个字段设置为 " 是 ",那么下次用户试图登录时,系统会因为这个字段是 " 是 " 而拒绝用户的登录请求。

当黑名单用户由运营配置时可以使用配置中心配置黑名单用户

2. 多个客户端单一账号怎么禁止登录?

  1. 核心思想是使用 Redis 来存储每个账户的 SessionID。当用户登录时,服务器验证其凭证,并生成一个新的 SessionID。

  2. 当服务器收到客户端的请求时,它会检查该请求的 SessionID。如果 SessionID 匹配存储在服务器上的 ID,那么请求将被处理;否则,请求将被拒绝。

  3. 当服务端接收到一个新的登录请求时,它会创建一个新的 SessionID,并改变存储在服务器上的 SessionID。这意味着旧的客户端将不能再通过旧的 SessionID 发送请求,因此它被迫注销。

这种方法的一个潜在问题是,如果旧客户端由于无法访问网络而没有收到注销信号,那么它可能会持续认为自己仍在登录状态。为解决此问题,客户端应设计为在检测到网络恢复后尝试向服务器发送心跳。如果心跳被拒,那么客户端应自动注销,并提示用户重新登录。

3. 限流器的设计

固定窗口

利用 Redis 的原子自增和过期淘汰策略

- 初次调用时直接设置为 1,并设置过期时间为 1s。在这一秒以内的后续调用,每次都自增 1,客户端拿到自增后的值如果没有超过限制 10000 就放行。 - 细节实现:为避免超限后无谓的 redis 调用,第一次发现超限时可以记录该值的 TTL 时间,例如只过去 100ms 就有 1w 个请求过来,剩下的 900ms 就不用请求 redis 而是直接返回超限即可。 - 精度可调节。假如限流阈值很大,比如 100w,可以改用 INCRBY 每次累加 100(客户端攒批),那么 Redis 的访问 QPS 直接降低 100 倍,为 1w QPS(代价是限流精度变为 100 的粒度)

优化

  • 滑动窗口 你可以使用 Redis 提供的有序集合(sorted set)数据结构来存储每一次请求的时间戳,用 score 来表示请求的时间。每次来新的请求,都添加到有序集合中。然后,根据当前的时间戳,删除窗口之外的请求记录,并查看当前窗口内的请求数量是否超过阈值。

  • 令牌桶 可以设定每秒钟往桶中放入 100 个令牌,如果有新的请求进来,就从桶中拿走一个令牌,如果桶中没有令牌了,新的请求就需要等待或者被丢弃,这样就可以保护系统不会被突然的大流量压垮。

超高 QPS 优化

使用令牌桶 + 本地缓存:每次按固定步长从 Redis 批量取一批令牌到本地,本地令牌消耗完后再到 Redis 中补充,大幅减少 Redis 压力(本质是本地预取 + 异步补充)。如果是突发的高 QPS 流量,可以按实时速率动态调整步长。

4. 短链接系统设计

  1. 长网址到短网址转换: 主流方案是"发号器 + 进制转换":由发号器为每个长网址分配一个唯一的数值 ID,再把 ID 转换成 62 进制(0-9、a-z、A-Z 共 62 个字符)的字符串作为短码,例如 ID=1000 对应短码 "g8"。另一种思路是使用哈希算法(MD5/Sha256)截取前若干位作为短码,但这种方式存在问题:一是不同长网址哈希截取后可能碰撞(MD5 截取前 6 位只有 16^6 种组合,长网址一多必然冲突);二是短码可被枚举/猜测,存在可预测性问题。因此哈希截断只适合小型或原型系统,生产环境建议采用发号器方案。

  2. 发号器设计:为每个长地址分配一个号码 ID。主流做法是数据库自增 ID 或号段模式(如美团 Leaf,一次批量取一段号,避免每次请求都访问数据库),也可以用雪花算法生成分布式 ID;发号后把短码与长网址的映射写入数据库并建立短码唯一索引,防止短码冲突和同一长网址重复生成。

  3. 二义性(碰撞)检查:生成短码后需要确认该短码未被占用。可以用布隆过滤器快速判断短码是否已存在(已存在则重新发号),由于布隆过滤器有误判率,最终要以数据库短码唯一索引为准。

  4. 短网址到长网址转换: 需要创建一个数据库或者使用缓存服务器来存储长网址和短网址的映射关系。当用户访问短网址时,系统将查找对应的长网址并进行跳转。

5. 文章浏览量计数系统设计

使用 Redis 存储统计数据: 后端首先判断 Redis 里是否已有当前 ip 对这篇文章的浏览记录,这个 key 为:isViewed:articleId:ip。如果有,就说明之前浏览过,就什么也不做,直接返回。如果没有就加上这个 key,并在 Redis 里给这篇文章的浏览量 +1 和记录时间戳(Hash 类型)。Redis 支持原子操作,所以不用担心并发问题。key 为 viewCount:articleId,value 为缓存的浏览量。

定时任务同步数据库:每 5 分钟,去 Redis 里拿缓存的浏览量,拿到后就更新到数据库里,并把 Redis 的数据清零。为了防止并发带来的问题,这里应该是拿到 m,就在 Redis 里减去 m,而不是直接设置为 0。顺便删除超过十天未再更新的 key。

对于大并发的写入压力问题可以采用如下策略:

消息队列缓冲: 对于大量的更新统计数据请求,我们可以先放入消息队列中,然后由后台服务逐渐消费这些请求,这样可以平滑处理瞬间的大量请求。

对于去重问题,我们可以采用以下方案:

使用布隆过滤器: 对于同一个用户发起的重复的计数请求,我们可以使用布隆过滤器进行去重。布隆过滤器是一种空间效率极高的概率型数据结构,能够判别一个元素是否在集合中。

6. IM 消息服务设计

从新手到专家:如何设计一套亿级消息量的分布式IM系统

读写扩散

读扩散的优点(时间线模型):

  • 1)写操作(发消息)很轻量,不管是单聊还是群聊,只需要往自己的发件时间线写一次就好了;

  • 2)读扩散模型天然保存了完整消息流,可以方便地查看聊天记录和进行搜索。

读扩散的缺点: 读操作(读消息)很重,在复杂业务下,读扩散需要把多个会话时间线里的消息合并、排序后才是一条完整的时间线,合并逻辑复杂。

写扩散优点:

  • 1)读操作很轻量;

  • 2)可以很方便地做消息的多终端同步。

写扩散缺点: 写操作很重,尤其是对于群聊来说(因为如果群成员很多的话,1 条消息源要扩散写成“成员数 -1”条目标消息,这是很恐怖的)。

推拉模式

  • 推模式:有新消息时服务器主动推给所有端(iOS、Android、PC 等);

  • 拉模式:由前端主动发起拉取消息的请求,为了保证消息的实时性,一般采用推模式,拉模式一般用于获取历史消息;

  • 推拉结合模式:有新消息时服务器会先推一个有新消息的通知给前端,前端接收到通知后就向服务器拉取消息。

可以使用推拉结合模式解决推模式可能会丢消息的问题: 即在用户发新消息时服务器推送一个通知,然后前端请求最新消息列表,为了防止有消息丢失,可以再每隔一段时间主动请求一次。可以看出,使用推拉结合模式最好是用写扩散,因为写扩散只需要拉一条时间线的个人信箱就好了,而读扩散有 N 条时间线(每个信箱一条),如果也定时拉取的话性能会很差。

7. 秒杀系统设计

用户层:用户发出请求后,首先到达系统的前端,我们可以在这个环节加入图形验证码、滑动验证码等反爬虫措施,防止恶意刷单和机器人参与抢购。

限流层:用户请求通过反爬虫措施后,接着到达限流层。在这个阶段,我们可以采用比如令牌桶算法来限制流量。如果请求超出了设定的阈值,那么超出部分的请求将不会被处理,直接返回系统繁忙的消息给用户。

队列层:限流层通过后,用户请求会进入到队列层, 我们可以使用消息队列(如 Kafka, RabbitMQ) 等技术,异步处理用户请求,缓解高并发带来的压力。

业务处理层:这个是秒杀业务处理的核心部分。从队列中取出用户请求,进行库存判断。判断成功后,进行减库操作,并生成订单等业务操作。判断库存和减库操作需要保证原子性,可以采用数据库事务进行处理。为了保证数据一致性,我们可以引入乐观锁或者悲观锁的概念。

库存判断与减库操作

为了保证库存判断和减库操作的原子性,我们需要在数据库级别或者服务级别做控制。

  1. 数据库事务: 利用数据库事务的原子性,来保证库存的查询和减库在一个事务中完成。简单说,我们可以先查询库存,然后判断库存数量,最后减少库存,这一整个过程在一个事务中,不会受到其他操作的干扰。此外,此操作中应使用悲观锁或者乐观锁保障并发操作下减库的正确性。

  2. 借助 Redis 单线程执行 + Lua 脚本中的逻辑可以在一次执行中顺序完成的特性达到原子性。

  3. 乐观锁:每次先获取商品记录版本号,然后减库操作时候带上版本号。只有当版本号和服务器版本号一致时,才会减库,减库同时升级版本号。因此,只有最早获取版本号的线程可以成功扣库。

  4. 悲观锁:悲观锁与乐观锁相对,认为并发冲突大概率发生,因此"先加锁、再操作"。秒杀减库场景的典型实现是 SELECT ... FOR UPDATE 对库存行加排他锁,锁住后再判断库存并更新,其他事务必须等待锁释放。注意:读锁(共享锁)和写锁(排他锁)是数据库锁的类型划分,并不等同于悲观锁本身;悲观锁强调的是一种"先取锁后操作"的策略。

重复下单问题

MQ 在整个秒杀流程中扮演了很重要的角色,因为下单数据全部暂存在 MQ 中,一旦消费者重复消费,就有可能出现一个用户秒杀到两个商品的重复下单情况。

解决方案:实现幂等,利用 Redis,发送消息时指定消息的全局唯一 id;收到消息后查询 Redis 是否有该 id,有则说明是重复消息。然后立即将 id 存入 Redis 中。

业务校验:生成订单时校验用户是否已经秒杀过该商品。(MySQL 的唯一索引)

下单消息丢失

生产者丢失消息

(1)使用事务; (2)使用 confirm 模式:在这种模式下,一旦消息发送至服务器,服务器会返回一个确认给生产者,这样生产者知道消息已成功达到目的地。如果没有收到确认,生产者知道消息没有送达,可以尝试重新发送。 (3)异步监听确认模式:这是一种特殊的 Confirm 模式,其中生产者不等待每个消息的确认,而是将它们发送出去并继续处理其他事务。同时,它们在监听确认——如果有未确认的消息,则会重新发送。

MQ 丢失消息

开启消息持久化,消息将被写入在服务器崩溃情况下能够保存的地方,比如硬盘。这样,即使服务器崩溃,消息也不会丢失,并且在服务器恢复时可以被重新加载到队列中。

消费者丢失消息

消费者消费完成后回执确认,如果一段时间后 MQ 没有收到消费者的回执确认,MQ 就认为消息没有被成功消费,会将消息重新发送给其他消费者。

如何处理恶意下单请求

  1. 首先保证刷单者最多也只能刷到一件商品:真正的下单逻辑校验一个用户只能购买一件商品,可以把用户 id商品 id作为联合主键索引存储到数据库中,重复购买会自动报错;

  2. 重复提交校验;

  3. 验证码机制;

8. 海量评论系统

  1. 数据分片方案 一种策略是使用一致性哈希算法进行数据分片:将评论 ID 哈希后映射到不同的存储节点上。当添加或删除节点时,只需要迁移少量数据(涉及到的重新分配的数据量很小),避免了大范围 rehash 导致的数据迁移问题。

  2. 索引和翻页优化 为了提高翻页效率,可以使用二级索引,将父 ID 和子 ID 关联起来,比如你可以使用<PostID, CommentID 列表>的形式存储评论。然后,对评论进行排序以便于分页。当然,前几页的数据访问频率最高,如果在内存中缓存它们,可以进一步提高读取性能。你还可以利用 Redis 的 LRU 淘汰策略来管理缓存。

  3. 冷热数据处理 我们可以设定一个基准让访问频次高于这个基准的数据被标记为热数据,针对读取频繁的热数据,可以利用 Redis,把热门数据缓存起来,提高数据读取速度。

  4. 突发流量的处理 对突发流量这种场景,我们可以考虑使用消息队列,比如 RabbitMQ,Kafka 等。这样,当瞬时流量超过系统处理能力时,请求可以先进入队列中,然后系统按自己的处理能力进行处理,避免系统因为突然的流量增加而崩溃。

  5. 按照热度排序 对于按照热度排序,可以依靠 Redis 的 Zset 进行处理。Zset 中的元素是唯一的,对于每一篇文章,可以建立一个 Zset,评论 ID 作为 member,热度作为 score 进行存储,这样就能快速地获取热度排名的评论。

  6. 处理评论删除 对评论的删除,我们可以采取标记删除的方式,也就是并不真实删除数据,只是添加一个标记。等到业务高峰过去,系统空闲的时候,进行删除操作。对于索引的更新,也遵循相同的原则,将修改标记下来,在系统空闲时进行更新。

9. 推送去重系统设计

使用位图 (BitMap) 进行数据存储,若用户池子是一千万,一千万 bit ≈ 1.25MB(10,000,000 bit ÷ 8 = 1.25MB)。为了节约更多存储,我们还可以进一步压缩 bitmap 的大小。使用的时候算一下 hash(userId) % size(bitmap) 即可。在存储上当然是越小越好的。但是小了会冲突严重,大了会浪费存储。

布隆过滤为了降低 hash 碰撞,引入了多个 hash 函数。插入元素时,字符串经过 k 次 hash 函数计算 (可以是不同 hash 函数),将映射到 bitmap 相应位置的 bit 位置为 1。查询时同样需要 k 个位置的 bit 为 1,元素才算存在。

针对数据清除的问题,我们可以采取定期清理的策略,如每天清理一次,将无效的、过期的数据清除出去,保持数据的实时性。

最后更新于