系统设计题

366 阅读29分钟

1G 约等于 10亿

1M 约等于 100万

每个程序员都应该知道的操作耗时

image.png

通用思路

  • 确定关键需求。
  • 用户量流量等 封底估算

kiss原则

Keep It Simple, Stupid

1.1 傻瓜式设计

“傻瓜式”并非贬义,而是指设计应该简单到可以被一个“傻瓜”理解和使用。一个好的设计应该是直观的,不需要复杂的解释和学习。

1.2 避免过度工程

KISS原则鼓励避免过度工程,不要在系统中引入不必要的特性和复杂性。只有在真正需要的时候才添加新的功能或复杂性。

接口幂等性实现方案

需要幂等的场景

  • 前端重复提交
  • 接口超时重试
  • 消息重复消费
1. 前端拦截

前端拦截是指通过 Web 站点的页面进行请求拦截,比如在用户点击完“提交”按钮后,我们可以把按钮设置为不可用或者隐藏状态,避免用户重复点击。

token (防止前端同一个请求重复提交)

image.png

  • token特点:要申请,一次有效性,可以限流

注意:

  • 对 redis 中是否存在 token 以及删除的代码逻辑建议用 Lua 脚本实现,保证原子性
    • 所以是先用lua判断并删除,再执行业务逻辑,如果业务逻辑是否执行失败,给前端报错特定错误码
  • 只能解决前端重复提交,不能解决超时重试。超时时,前端不知道业务逻辑是否执行成功
  • 全局唯一 ID 可以用百度的 uid-generator、美团的 Leaf 去生成
2. 数据库实现
  • 加锁,悲观锁、乐观锁
  • 唯一索引
4. 分布式锁实现

在每次执行方法之前先判断是否可以获取到分布式锁,如果可以,则表示为第一次执行方法,否则直接舍弃请求

分布式唯一id

zhuanlan.zhihu.com/p/152179727

uuid

  • 128 bit
  • 16进制表示,uses the numbers 0 through 9 and letters A through F
  • The hexadecimal digits are grouped as 32 hexadecimal characters with four hyphens: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX.

image.png

  • node id 就是 MAC address,MAC地址的长度为48位(6个字节)
  • not suitable when security is a major concern. Instead, some implementations will use 6 random bytes sourced from a cryptographically secure random number generator as a replacement for the node ID.

优点:

  • 生成足够简单,本地生成无网络消耗,具有唯一性 缺点:
  • 无序的字符串,不具备趋势自增特性
  • 没有具体的业务含义
  • 太长还是字符串,存储性能差查询也很耗时

基于数据库自增ID

性能不好,无法满足高并发场景

  • 单机
  • 集群
    • 设置起始值自增步长

基于数据库的号段模式

号段模式是当下分布式ID生成器的主流实现方式之一,号段模式可以理解为从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如 (1,1000] 代表1000个ID,具体的业务服务将本号段,生成1~1000的自增ID并加载到内存

CREATE TABLE id_generator (
  id int(10) NOT NULL,
  max_id bigint(20) NOT NULL COMMENT '当前最大id',
  step int(20) NOT NULL COMMENT '号段的布长',
  biz_type    int(20) NOT NULL COMMENT '业务类型',
  version int(20) NOT NULL COMMENT '版本号',
  PRIMARY KEY (`id`)
) 

biz_type :代表不同业务类型

max_id :当前最大的可用id

step :代表号段的长度

version :是一个乐观锁,每次都更新version,保证并发时数据的正确性

等这批号段ID用完,再次向数据库申请新号段,对max_id字段做一次update操作,update max_id= max_id + step,update成功则说明新号段获取成功,新的号段范围是(max_id ,max_id +step]

update id_generator set max_id = #{max_id+step}, version = version + 1 where version = # {version} and biz_type = XXX

由于多业务端可能同时操作,所以采用版本号version乐观锁方式更新,这种分布式ID生成方式不强依赖于数据库,不会频繁的访问数据库,对数据库的压力小很多。

基于Redis模式

利用redis的 incr命令实现ID的原子性自增

redis实现需要注意一点,要考虑到redis持久化的问题。redis有两种持久化方式RDBAOF

基于ZooKeeper的序列节点

在ZooKeeper上创建一个临时有序节点,节点的名称就可以作为唯一ID。

  • 临时节点, session断掉时会 被删除
  • 有序,monotonicly increasing counter to the end of path. This counter is unique to the parent znode
  • 不适合大规模场景

雪花算法

www.nowcoder.com/discuss/377…

image.png

1.第一位 占用1bit,其值始终是0,没有实际作用。 2.时间戳 占用41bit,精确到毫秒,总共可以容纳约69年的时间。 3.工作机器id 占用10bit,其中高位5bit是数据中心ID,低位5bit是工作节点ID,做多可以容纳1024个节点。 4.序列号 占用12bit,每个节点每毫秒0开始不断累加,最多可以累加到4095,一共可以产生4096个ID。

SnowFlake算法在同一毫秒内最多可以生成多少个全局唯一ID呢:: 同一毫秒的ID数量 = 1024 X 4096 = 4194304

生成过程:

  • 10 bits 的机器号, 在 ID 分配 Worker 启动的时候, 从一个 Zookeeper 集群获取 (保证所有的 Worker 不会有重复的机器号)
    • 也可以与数据库配合使用,需要新增一个WORKER_NODE表。当应用启动时会向数据库表中去插入一条数据,插入成功后返回的自增ID就是该机器的workId数据由host,port组成。(**百度(uid-generator)**的做法)
  • 41 bits 的 Timestamp: 每次要生成一个新 ID 的时候, 都会获取一下当前的 Timestamp, 然后分两种情况生成 sequence number:
  • 如果当前的 Timestamp 和前一个已生成 ID 的 Timestamp 相同 (在同一毫秒中), 就用前一个 ID 的 sequence number + 1 作为新的 sequence number (12 bits); 如果本毫秒内的所有 ID 用完, 等到下一毫秒继续 (这个等待过程中, 不能分配出新的 ID)
  • 如果当前的 Timestamp 比前一个 ID 的 Timestamp 大, 随机生成一个初始 sequence number (12 bits) 作为本毫秒内的第一个sequence number
  • 整个过程中, 只是在 Worker 启动的时候会对外部有依赖 (需要从 Zookeeper 获取 Worker 号), 之后就可以独立工作了, 做到了去中心化.

异常情况讨论:

  • 在获取当前 Timestamp 时, 如果获取到的时间戳比前一个已生成 ID 的 Timestamp 还要小怎么办? Snowflake 的做法是继续获取当前机器的时间, 直到获取到更大的 Timestamp 才能继续工作 (在这个等待过程中, 不能分配出新的 ID)

从这个异常情况可以看出, 如果 Snowflake 所运行的那些机器时钟有大的偏差时, 整个 Snowflake 系统不能正常工作 (偏差得越多, 分配新 ID 时等待的时间越久)

从 Snowflake 的官方文档 (github.com/twitter/sno…) 中也可以看到, 它明确要求 "You should use NTP (Network Time Protocol) to keep your system clock accurate". 而且最好把 NTP 配置成不会向后调整的模式. 也就是说, NTP 纠正时间时, 不会向后回拨机器时钟.

短网址服务

[短链接服务是一种将长链接转化为短链接的技术,早期由于长链接分享不便,短链接服务应运而生]。[短链接服务包含两个部分:短链接生成和通过短链接访问原链接]。

短网址的长度

当前互联网上的网页总数大概是 45亿,45亿超过了 2^{32}=4294967296232=4294967296,但远远小于64位整数的上限值,那么用一个64位整数足够了。

微博的短网址服务用的是长度为7的字符串,这个字符串可以看做是62进制的数,那么最大能表示{62}^7=352161****208627=3521614606208个网址,远远大于45亿。所以长度为7就足够了

一个64位整数如何转化为字符串呢?,假设我们只是用大小写字母加数字,那么可以看做是62进制数,log_{62} {(2^{64}-1)}=10.7log62(264−1)=10.7,即字符串最长11就足够了。

实际生产中,还可以再短一点,比如新浪微博采用的长度就是7,因为 62^7=352161****208627=3521614606208,这个量级远远超过互联网上的URL总数了,绝对够用了。

现代的web服务器(例如Apache, Nginx)大部分都区分URL里的大小写了,所以用大小写字母来区分不同的URL是没问题的。

因此,正确答案:长度不超过7的字符串,由大小写字母加数字共62个字母组成

一对一还是一对多映射?

以这个7位长度的短网址作为唯一ID,这个ID下可以挂各种信息,比如生成该网址的用户名等信息,收集了这些信息,才有可能在后面做大数据分析,挖掘数据的价值。

如何计算短网址

分布式ID生成器

如何存储

可以用传统的关系数据库存起来,例如MySQL, PostgreSQL,也可以用Redis, hbase。

  • 分享链接生成时需要 通过long url 查找 short url,并更新expire time, 便于历史数据清除, 否则每次分享都生成新的short url,没必要

  • 点击链接查看时需要 通过short url 查找 long url

  • 而hbase只支持单rowkey查找,需要在HBase上构建二级索引,可以采用Elasticsearch

缓存方案

  • redis, key short url, value long url
  • redis, key long url , value short url (分享链接生成时用)
  • lru 淘汰算法

如果使用 hbase + es 二级索引 的话,已经各自带有缓存机制 性能上仅次于redis但是存储成本比redis低很多个数量级,存储基于HDFS,写数据的时候会先先写入内存中,只有内存满了会将数据刷入到HFile。LSM 相比于,B+树,写入快,读取慢。 HBase会将最近读取的数据使用LRU算法放入缓存中,如果想增强读能力,可以调大blockCache。

301还是302重定向

这也是一个有意思的问题。这个问题主要是考察你对301和302的理解,以及浏览器缓存机制的理解。

301是永久重定向,302是临时重定向。短地址一经生成就不会变化,所以用301是符合http语义的。但是如果用了301, Google,百度等搜索引擎,搜索的时候会直接展示真实地址,那我们就无法统计到短地址被点击的次数了,也无法收集用户的Cookie, User Agent 等信息,这些信息可以用来做很多有意思的大数据分析,也是短网址服务商的主要盈利来源。

所以,正确答案是302重定向

预防攻击

如果一些别有用心的黑客,短时间内向TinyURL服务器发送大量的请求,会迅速耗光ID,怎么办呢?

这个场景存在的前提是,对于同一个long url的每次分享都会生成一个新的short url....

首先,限制IP的单日请求总数,超过阈值则直接拒绝服务。

光限制IP的请求数还不够,因为黑客一般手里有上百万台肉鸡的,IP地址大大的有,所以光限制IP作用不大。

可以用一台Redis作为缓存服务器,存储的不是 ID->长网址,而是 长网址->ID,仅存储一天以内的数据,用LRU机制进行淘汰。这样,如果黑客大量发同一个长网址过来,直接从缓存服务器里返回短网址即可,他就无法耗光我们的ID了。

ip转int

IPv4地址 转换成 Int数字 的方法如下: 例子:192.168.1.123 3 个点把 IP 地址分成 4 个数字,每个数字的范围都是 0 ~ 255,所以是每个数字是 8 bit, 总共 32 bit,4 个字节,刚好可以用无符号的数据类型 uint 表示。 具体计算过程如下: 192256^3 + 168256^2 + 1256^1 + 123256^0 = 3232235899

最近一个小时内访问频率最高的10个IP

实时输出最近一个小时内访问频率最高的10个IP,要求:

  • 实时输出

  • 从当前时间向前数的1个小时

  • QPS可能会达到10W/s

  • QPS是 10万/秒,即一秒内最高有 10万个请求,那么一个小时内就有3.6亿个请求,也不是很大。我们在内存中建立3600个HashMap<Int,Int>,放在一个数组里,每秒对应一个HashMap,IP地址为key, 出现次数作为value。这样,一个小时内最多有3.6亿个pair,每个pair占8字节,总内存大概是节,即4GB,单机完全可以存下。

  • 同时还要新建一个固定大小为10的小根堆,用于存放当前出现次数最大的10个IP。堆顶是10个IP里频率最小的IP。

  • 每次来一个请求,就把该秒对应的HashMap里对应的IP计数器增1,并查询该IP是否已经在堆中存在,

    • 如果不存在,则把该IP在3600个HashMap的计数器加起来,与堆顶IP的出现次数进行比较,如果大于堆顶元素,则替换掉堆顶元素,如果小于,则什么也不做
    • 如果已经存在,则把堆中该IP的计数器也增1,并调整堆
  • 每过一秒,把最旧的那个HashMap销毁,如果HashMap中的url在堆中,还要把堆中的计数减去,并为当前这一秒新建一个HashMap,这样维持一个一小时的窗口。

  • 每次查询top 10的IP地址时,把堆里10个IP地址返回来即可。

红包系统

developer.aliyun.com/article/936…

比如红包这个系统,需要有如下:

  1. 包红包

  2. 发红包

  3. 抢红包

  4. 拆红包

  5. 不能抢超,也就是说红包个数,金额是有限的,不能超的。

  6. 支持高并发,例如1亿用户凌晨12点开始抢红包。

上面的 4 方面就是关键需求。

  • 包红包:系统为每个红包设置一个 id ,然后将红包发送个用户,这里需要设置 红包金额,红包个数,要发送的用户,存储这些信息。
  • 发红包,设置完红包参数后,微信支付,完成付款,然后收到付款成功通知,红包系统更新红包订单状态,更新为已支付,并写入红包发送记录表。这样用户可以将用户的红包信息和红包的收发记录发出,红包系统调用微信通知,将红包信息发送到微信群。
  • 抢红包,微信群用户收到红包后,点开,红包系统会校验红包是否被抢完,是否过期。
  • 拆红包,拆红包时,要先查询红包订单,判断是否可拆,计算本次拆的红包金额,记录抢红包流水。

表设计

红包表 redpacketId,userid,amount,num,type(均等or随机),groupId(群ID), remain_amount,remain_num,create_time

红包接收表 redpacketId, userid, amount

用户资金流水表 userid,amount(有正负数),create_time

高并发: redis 写入红包接收个数 (均等or随机逻辑不在这儿做),抢的时候 decr 1, <0 则说明超抢了, 用于抗流量, (只是抗流量,一致性不能完全保证)

入库的时候,计算amount, 写入 红包接收表 和 流水表 (具体的转账先不管),并且把 红包表的 remain_amount 和 remain_num 更新,在一个事务里面完成,

  • select * from 红包表 where redpacketId = "xxx" for update;
  • 计算金额 (均等or随机),这个时候确保不超发,即remain_amount >= 0
  • update 红包表, insert 红包接收表, 用户资金流水表

如果mysql操作失败了 redis 计数还要加回来吗?

  • 网络 返回超时 时 不好判断 是否已处理,
  • 加回来,超发的问题在事务里面会再次判断

另外一个思路是提前分割好红包,写入redis list , 每次 lpop, 数据库操作失败 同上,最终的一致性保证仍然要在db层面做

流量很高的情况下 先更新缓存,然后异步更新db, 这种做法只能在最终更新db的时候才能发现超扣问题,然后通知用户失败

防攻击

假设这时候有黑客或黄牛怎么办,在不断的用脚本点击链接或者红包,怎么避免? 链接不能写死,用MD5 进行链接加密,后台验证之后才能通过。否则认为是攻击。(可以加上uid,时间戳等进行验证

支付系统

zhuanlan.zhihu.com/p/58464077 支付系统有几个关键的核心流程:支付流程、对账流程、结算流程

支付流程

pay.weixin.qq.com/docs/mercha…

image.png

支付扣库存

image.png

order服务

order表

  • orderid,userid,skuid,num,status

商品服务

sku表

  • skuid,remain_num,

待支付记录表,用于 定时任务check(每5分钟一次)

  • skuid,orderid,num,create_time

步骤

生成订单

  • 订单状态为待支付,在事务里请求商品接口把remain_num - 1 (购买一个的情况) 并写入 待支付记录表, 如果请求超时或者失败,就把订单置为预扣失败,返回app失败。 (此处做超卖判断)

  • 单条商品的扣减SQL大致如下:

    • update sku set remain_num = remain_num - #{count} where sku_id='123' and remain_num >= #{count}
    • 同一事务中 insert into 待支付记录表
  • 由于是操作同一行的一个字段,很容易Lock wait timeout,这个异步串行更新数据库即可

  • 异步更新的话,这时就只是写入mq, db 层面无法防止超卖,只能在redis层保证

  • 不一致场景1 如果 商品预扣成功,只是返回超时怎么处理?定时任务check待支付记录表的数据,用orderid请求order获取订单状态,如果是预扣失败,则删除此记录并把sku表 remain_num + 1

支付成功

  • order把订单状态改成支付成功,调用商品接口把 待支付记录表 删除, 不需要关注此接口调用的失败情况,因为定时任务会check并删除此种情况的数据

如果支付失败

  • order把订单状态改成支付失败,调用商品接口把 remain_num+1 以及 待支付记录表 删除, 不需要关注此接口调用的失败情况,因为定时任务会check并处理此种情况的数据

如果是秒杀场景,app先请求商品服务redis decr操作, 返回>=0才能继续支付操作,回滚库存时, 把redis 中的计数加回去,这块注意如果 db是异步写入的话, 超时不要重试,避免redis的值加多了

最后需要考虑的是超过redis承载能力的流量控制,redis更新,数据库更新都有自己的并发极限。超过redis极限,就会报超时 redis command timeout,超过数据库极限,就会报Lock wait timeout。我们要做的是,保证流量打到redis不要超过redis的极限,流量打到数据库不要超过数据库的极限。数据库前面有redis这一 decr成功才继续更新,其实已经算是控流了。如果超过了redis的承受极限,就可以直接随机丢弃掉一些流量。

商城秒杀

架构层次

cloud.tencent.com/developer/a…

  • ->用户层(app,h5,pc)
  • --->dns
  • -------->cdn
  • -------->slb
  • ------------>服务层(秒杀交易、风控、折扣、推送)
  • ------------------>基础设施层(数据库、消息中间件、大数据)

流量控制

  • cdn层,将静态内容(如HTML页面、图片、CSS文件等)上传到CDN的服务器上(cloud.tencent.com/developer/a…
  • 反向代理层,在Nginx中配置用户的访问频率 , limit_req 是针对ip的,当多个用户使用一个ip时,这个限制就不准确了
  • 后端服务层,独立部署,做好 扩容,限流、降级、熔断
  • 数据库层,读写分离;如果部署临时活动,使用独立数据库;
  • app先请求商品服务redis decr操作, 返回>=0才能继续支付操作
  • 支付操作时,请求先放入队列,订单模块依据自身的处理速度,从队列中依次获取订单进行“下单扣库存”操作。

高可用

多实例部署负载均衡

排行榜(微信步数等)**

简单做法 用Redis的 HASH 数据结构,存储用户头像、昵称、步数等,排序直接在内存做

点赞数如果和用户之间的好友关系有关,取交集

落表的数据结构为:

  • 日期 uid 步数 点赞uid(逗号分隔)

或者放到redis里面

  • 日期_uid_步数:111//string
  • 日期_uid_点赞uid:[1,2,3]//set

feed

a. 依赖的中间件:网关、数据库、缓存、消息队列等

b. 需要考虑的点:并发、实时推送、消息推拉模式、数据库设计(内容表,关系表,粉丝收件箱表)

c. 加分点:根据用户活跃场景采用推+拉模式

推+拉模式

  1. 推模式(也叫写扩散):和名字一样,就是一种推的方式,发送者发送了一个消息后,立即将这个消息推送给接收者,但是接收者此时不一定在线,那么就需要有一个地方存储这个数据,这个存储的地方我们称为:同步库。推模式也叫写扩散的原因是,一个消息需要发送个多个粉丝,那么这条消息就会复制多份,写放大,所以也叫写扩散。这种模式下,对同步库的要求就是写入能力极强和稳定。读取的时候因为消息已经发到接收者的收件箱了,只需要读一次自己的收件箱即可,读请求的量极小,所以对读的QPS需求不大。归纳下,推模式中对同步库的要求只有一个:写入能力强。

  2. 拉模式(也叫读扩散):这种是一种拉的方式,发送者发送了一条消息后,这条消息不会立即推送给粉丝,而是写入自己的发件箱,当粉丝上线后再去自己关注者的发件箱里面去读取,一条消息的写入只有一次,但是读取最多会和粉丝数一样,读会放大,所以也叫读扩散。拉模式的读写比例刚好和写扩散相反,那么对系统的要求是:读取能力强。另外这里还有一个误区,很多人在最开始设计feed流系统时,首先想到的是拉模式,因为这种和用户的使用体感是一样的,但是在系统设计上这种方式有不少痛点,最大的是每个粉丝需要记录自己上次读到了关注者的哪条消息,如果有1000个关注者,那么这个人需要记录1000个位置信息,这个量和关注量成正比的,远比用户数要大的多,这里要特别注意,虽然在产品前期数据量少的时候这种方式可以应付,但是量大了后就会事倍功半,得不偿失,切记切记。

  3. 推拉结合模式:推模式在单向关系中,因为存在大V,那么一条消息可能会扩散几百万次,但是这些用户中可能有一半多是僵尸,永远不会上线,那么就存在资源浪费。而拉模式下,在系统架构上会很复杂,同时需要记录的位置信息是天量,不好解决,尤其是用户量多了后会成为第一个故障点。基于此,所以有了推拉结合模式,大部分用户的消息都是写扩散,只有大V是读扩散,这样既控制了资源浪费,又减少了系统设计复杂度。但是整体设计复杂度还是要比推模式复杂。

消息系统

a. 依赖的中间件:网关、数据库、缓存、消息队列、冷热库存储

b. 需要考虑的点:如何收发消息(推/拉),消息如何聚合(多条消息聚合成一个通知提醒)

c. 加分点:按照场景存储消息(点赞/私信/广告),冷热库

城市中公交站牌上要展示最近要到的一些车辆信息,系统如何设计。

表设计:

站点 车次 arrive_time

评论系统

缓存设计

我们基于数据库设计进行缓存设计,选用redis作为主力缓存。

  1. reply_index,对应于「查询xxx评论列表」,redis sorted set类型。member是评论id,score对应于ORDER BY的字段,如floor、like_count等。

  2. reply_content,对应于「查询xxx评论基础信息」,存储内容包括同一个评论id对应的reply_index表和reply_content表的两部分字段。

即 一个zset缓存,一个 string 缓存

zset的缓存必须要判定key存在才能增量追加。先判断ttl>3s,再add。此外,缓存的一致性依赖binlog刷新,主要有几个关键细节:

  1. binlog投递到消息队列,分片key选择的是评论区,保证单个评论区和单个评论的更新操作是串行的,消费者顺序执行,保证对同一个member的zadd和zrem操作不会顺序错乱。

  2. 数据库更新后,程序主动写缓存和binlog刷缓存,都采用删除缓存而非直接更新的方式,避免并发写操作时,特别是诸如binlog延迟、网络抖动等异常场景下的数据错乱。那大量写操作后读操作缓存命中率低的问题如何解决呢?此时可以利用singleflight进行控制,防止缓存击穿。

热点事件评论先更新缓存+写入kafka,再异步落库

bloomfilter

Bloomfilter就是在Bitmap之上的扩展而已。对于一个key,用k个hash函数映射到Bitmap上,查找时只需要对要查找的内容同样做k次hash映射,通过查看Bitmap上这k个位置是否都被标记了来判断是否之前被插入过

ip 转 int

把一个IPv4地址的每段可以看成是一个0-255的整数,先把每段拆分成一个二进制形式组合起来,然后把这个二进制数转变成一个长整数。

以10.0.3.193这个IP地址为例:

每段数字相对应的二进制数
1000001010
000000000
300000011
19311000001

组合起来即为:00001010 00000000 00000011 11000001,转换为十进制数就是:167773121

func ip2int(ip string) int {
   segs := strings.Split(ip, ".")
   res := 0

   for i, v := range segs {
      vint, _ := strconv.Atoi(v)
      res += vint << (8 * (3 - i))
   }

   return res
}

有10 亿个 url,每个 url 约 56B,要求去重,内存只给你4G

将url 按 hash(url)%n 拆分到n个文件里, 再针对每个文件用map去重,写入磁盘, 然后合并各个去重后的url文件

订阅付费系统

每小时 01分 跑下小时 到期的数据,自动扣费

异常补偿逻辑,扣费失败是否重试,定时任务失败逻辑 等

搜索自动补全

要获取排名前k的最常被搜索的查询词,步骤如下所述。

1.找到前缀节点。时间复杂度为O(p)。

2.从前缀节点开始遍历子树,获取所有合格的子节点。如果子节点可以形成一个有效的查询字符串,那么它就是一个合格的子节点。时间复杂度为O(c)。

3.对合格的子节点排序,并获取排名前k的子节点。时间复杂度为O(clogc)。

虽然这个算法简单且直接,但是它还是太慢,因为在最坏的情况下,我们需要遍历整个字典树才能获取排名前k的结果。 优化:

•限制前缀的最大长度。

•在每个节点缓存被高频搜索的查询词。

数据收集服务

数据分析日志(Analytics Log) 它存储了查询相关的原始数据。日志是追加写入的(append-only),并且没有建立索引

image.png

根据使用场景,我们可能会用不同的方法聚合数据。对于推特之类的实时应用,因为实时结果很重要,所以我们可能需要在较短的时间间隔内聚合数据。另一方面,对于很多其他使用场景,数据聚合不需要很频繁,比如一周一次可能就足够了。在面试中,要判断实时结果是否重要。这里我们假设字典树每周都重新构建一次。

聚合数据

字典树缓存

为了实现快速读,把字典树保存在内存中。该缓存每周获取一次数据库的快照

字典树数据库

有如下两个存储数据的可用选项。

1.文档存储。因为每周都会构建新的字典树,把序列化后的数据存储在数据库中。MongoDB之类的文档存储数据库适合存储序列化数据。

2.键值存储。字典树可以通过下面的逻辑用哈希表的形式来表示。•字典树的每个前缀都映射为哈希表中的一个键(Key)。•每个字典树节点上的数据都映射为哈希表中的一个值(Value)。

  • 使用浏览器缓存。 对于很多应用来说,自动补全建议在短时间内可能不会发生很大变化。因此,可以将自动补全建议存储在浏览器缓存中,以便之后的请求直接从缓存中获取结果。谷歌搜索引擎使用了相同的缓存机制

更新字典树

方式1:每周更新字典树。一旦创建了新字典树,新的就会替代老的。

方式2:直接更新单个字典树节点。因为这个操作很慢,所以我们尽量避免使用它。但是,如果字典树不大,这也是一个可以接受的方案。当我们更新一个字典树节点时,其直到根节点的祖先节点都必须更新,这是因为祖先节点存储着子节点的最高频查询词。

扩展存储

因为我们的系统只支持英语这一种语言,所以一个简单的方法是根据查询词的第一个字符来做分片

遵循这个逻辑,因为英语有26个字母,所以可以把查询词分到多达26个服务器上。这是第一级分片。当系统需要支持更大规模的数据和更多的查询词时,可能需要超过26个服务器来存储数据。我们可以做第二级甚至第三级分片

一眼看上去这个方法似乎有道理,但是你会发现以字母“c”开头的词比以“x”开头的多很多。这会导致数据分布不均衡。为了减轻数据分布不均衡的问题,我们分析了历史数据的分布模式,并且应用了更加智能的分片逻辑,如图13-15所示。分片映射管理器维护了一个查找数据库,用来确定数据应该被存储在哪个分片上。举个例子,如果在历史查询中,以字母开头“s”开头的查询词数量和以字母“u”“v”“w”“x”“y”“z”开头的查询词数量不相上下,我们就可以维护两个分片:一个用于以字母“s”开头的查询词,一个用于以字母“u”到“z”开头的查询词。

其他

  • 如何支持多语言

  • ·为了支持非英文的查询词,我们在字典树节点中存储Unicode字符

  • 如果某个国家的高频查询词与其他国家的不一样怎么办?

    • 为不同国家构建不同的字典树。为了提升响应速度,我们可以把字典树存储在CDN中。
  • 如何支持趋势性(实时)查询词?

字符串HashCode算法

java hashCode() 方法用于返回字符串的哈希码。

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

使用 int 算法,这里 s[i] 是字符串的第 i 个字符的 ASCII 码,n 是字符串的长度,^ 表示求幂。空字符串的哈希值为 0。