抖音,让生活更加精彩。
一、Redis 适用的场景?扩展:结合每一个Redis拥有的数据结构来充分考虑使用的场景。
-
常用数据结构,以及每类数据结构对应的适用场景:
-
String适用于分布式锁,
setnx -
哈希:海量场景下合并Key(Redis的每个DB都是一个字典结构)
Redis在实现哈希时有这么一个配置约定:当哈希的filed-value数量不超过指定的配置
hash-max-ziplist-entries并且value字节数不超过配置:hash-max-ziplist-value时则会采用更加内存连续的压缩列表来实现哈希存储。控制内层的filed-value个数是为了提高查询时的效率,限制Value的大小是为了在有限的内存上存储更加紧凑的元素。Redis中的每个Key都是一个SDS结构,其中包含长度、容量、Data内容,如果Key很多,那么就会包含2*N个无效的字符串,占用大量内存空间,如果此时我们用哈希来存储的话,我们可以充分利用哈希实现的底层数据结构的特点来降低内存的使用情况。具体如下:
我们通过限制每一个Hash的大小为
hash-max-ziplist-entries限制数量以下的同时,我们针对内存Key使用int类型来存储,value限制字节大小在指定值hash-max-ziplist-value之下。这样就能够保证每一个哈希底层数据结构使用的是压缩链表使用更加紧凑类型的内存,从而避免内存块之间的指针存储占用。 -
List:模拟消息队列
-
Zset:构造优先队列、延时队列等。
-
Pub/Sub:缓存状态同步
Redis 并不是非常可靠的,其有可能会丢失数据,假设我们只启用了AOF,此时Redis单机宕机,AOF缓冲区的内容则会丢失,从而造成消息丢失。假设同步消息丢失的情况下,缓存则会和DB数据不一致,那么必须等待缓存到期在从DB进行拉数据。
-
二、数据同步如果Redis消息不可靠如何如何进行优化?
Redis中的Pub/Sub本身就是类似一个消息队列的能力,并且数据并不可靠(也有可能刚刚Pub,服务刚刚重启还没开始监听但是缓存已经Load到本地,此时进程内的缓存则是脏缓存),所以这里我们可以替换成一个可靠的消息队列来实现,我们可以通过使用Kafka来进行实现,我们使用一个Topic来作为团队服务内的缓存通知的一个通道,在该通道内我们自定义event_id来区分不同的缓存通知,所有需要缓存的地方都监听该Topic,每次有消息时只需要通过event_id来判断取重新Load那个本地缓存。
三、如果用Redis作为缓存,当缓存失效的那一刻,突然有海量请求打进来,这个时候如何解决呢?
当缓存失效时,每一个进程内只需要放过一个请求去缓存背后对应的DB进行Load数据,进程内其他用户阻塞等待缓存请求到本地内存中,这个过程只需要在本地用一个本地锁就可以实现,当获取到的Lock的用户去DB请求然后缓存到本地后 释放Lock,之后其他用户分别Lock,而Lock之后完全是在本地内存中进行的,效率会非常的高。
四、生产者的速率如果大于消费者的速率这个时候会出现什么场景呢?应该如何优化呢?
如果生产者的速率大于消费者的速率,那么如果有多个生产者的话,多个生产者则会处于Sleep状态,如何解决或者避免Sleep过多的goroutine或者线程避免用户态到内核态的不断切换的一个过程。这种其实可以针对生产者生产容器的大小进行设置一个阈值和最大值,当超过阈值后,允许生产者按照TCP传输过程中的慢启动原理,慢慢的扩大阈值向最大值靠齐,同时也能在一定程度上避免线程等待。
五、海量数据的前提下,消费者如何去提高吞吐量?
用Redis实现消息队列下存在一个弊端,那就是多个消费者只能在同一个时刻只能有一个消费者去获取消息进行消费,而获取消息一定是有时间延迟的,在这段延迟内其他消费者一定是出于阻塞等待状态,这个过程可以批量去Redis中获取指定数量的消息,每个消费者同理,然后在消费过程内判断当前要消费的消息是否有被其他消费者进行消费,这样可以在一定程度上避免阻塞。虽然能够进行优化,其实还是可以继续进行优化。
我们上面的方案是存储在一个消息容器,多个消费者同时消费一个容器内的数据,必须存在先后竞争关系,如果我们考虑使用每一个消费者去消费一个单独的容器,是不是就会降低这个的竞争关系。
具体思路是我们考虑使用Kafka来进行实现,我们通过指定和消费者数量相等的分区,然后生产者在进行生产时优先级高的消息存入到高优先分区,低优先级的消息存入到低优先分区,而我们在消费时,我们会有和分区数量相等的消费者,按照默认的分区分配策略,每一个消费者都会消费一个分区,那么数据量少的分区则会优先执行完毕,数据量大的分区会慢执行完毕,通过控制优先级消息的容器长度来实现不同消息之间的优先级的概念来进行实现。
六、消息幂等如何实现?
每次消费一个消息的时候,针对改消息加唯一的分布式锁,这样可以保证一个消息在同一个时刻只能被一个消费者进行消费。在Lock之后,我们创造一个消费记录,消费记录状态为0,也就是未处理状态。之后我们区执行我们的业务逻辑,我们的业务逻辑可能是RPC接口等,在我们的业务逻辑内我们生产一个唯一ID(类似支付订单)通过该ID透传给RPC接口,其每次消费消息调用的下游服务都必须是幂等实现的,当RPC接口返回时我们根据返回状态来进行修改消费记录的状态。如果RPC接口返回成功,那么我们增加处理成功流程,之后修改消费记录状态为成功状态。如果RPC接口返回失败,那么直接return不进行后续处理,等待后续消费。
七、Kafka优缺点?Kafka如何保证可靠的数据持久性?
Kafka在数据持久性上提高了非常高的可靠性,其背后逻辑是Leader-Flower机制,每一个Partition分区都会有一个Leader的同时,又会包含多个Flower,当我们生产者向Kafka发送消息时,只有当Leader的分区成功写入后,同时Flower也成功写入那么才会返回生产者一个ACK机制,这样一定能够保证消息的持久化是可靠的,无论是单个Node宕机还是网络分区等,都可以通过Flower同时对消费者提供消息消费。
Kafka的分区是有序的一个概念,当我们需要做一些有序的事情是,可以考虑使用分区+消费者消费模型来进行方案设计。分区有序的含义是Redis在每一个Topic下的每个分区内消费时顺序消费,只要我们按照生产时的顺序写入到一个指定的分区,那么在消费该分区时则会是顺序消费。
同时Kafka将日志分段,持久化到磁盘上,并通过对分段进行索引,日志写入磁盘通过顺序IO来进行写入以此来提高日志写入的性能,最大化提高Kafka的吞吐量。
八、当我们双方建立TCP连接时,如果某个端突然拔掉网线后,间隔3S后又重新插入网线,那么对端的Socket是什么状态?能否感知到拔掉网线的端掉线?
当我们建立TCP连接时,已经三次握手成功了,也已经开始通信了,此时Client端主动拔掉网线,在间隔了3秒(不一定是3S,就是间隔很短,或者一瞬间)之后重新插入网线,在这个过程中,假设没有触发到KeepAlive的同时,双方各端也没有进行消息同步,此时Server端是不知道Client端的一个网线拔掉的一个过程的,Server端还是认为当前TCP是可以通信的,此时在进行Send依然是可以将消息同步给Client的,Client也能正常收到数据包。这个是一种场景,另外的一种场景是,当在拔掉网线还未重新插入时,此时Server端主动进行Send发送数据包,此时Server端仍然认为Client端是可以通信的,此时Server端会通过物理链路层进行中转传输,假设数据包到了Client端附近的交换机,交换机内部通过MAC地址找到对应的端口,然后将数据包透传到对应的端口,到了目标数据端口后,端口发现网线已经断掉,此时会将数据包丢弃掉,丢弃后,可能Client端会发送一个ICMP协议告诉Server端连接不通等,在到达一个MSL后,Server端未能收到ACK,则会进行重试,如果重试几次后依然未果,那么Server端则会认为连接不同然后关闭连接。
九、如果一个服务正常的CPU使用率为40%左右,突然之间CPU使用率降低到0%或者100%,这个时候如何进行排查?
宏观排查:
- 首先查看日志,排查下是否存在
panic等效果。或者其他Error效果等。 - 查看服务入口流量是否有波动或者降低到0,如果有波动,则当前服务CPU使用变动则可能受上游服务影响。
- 确定CPU是否被其他系统进程大量抢占从而导致服务CPU使用率降低。通过
top等命令查看系统所有进程的负载情况,如果此时发现有大量的其他进程在占用CPU资源,那么则可以认为服务内CPU降低是有其他进程过度抢占CPU导致的。 - 硬件资源排查,检查硬件中断是否正常。
微观排查:
- 通过
pprof分析函数占用CPU,找出热点函数(CPU升高到100%)或者阻塞函数(CPU降低到0%)。 - 回归业务代码,重新分析热点函数,找到其内部存在的缺陷等。
- 修复测试上线等。
十、设计一个排行榜单,要求能够查出TopN的同时,也能够查出当前自己的排名,海量数据条件。同时数据要持久化,持久化要保证海量数据。
海量数据、TopN、Top1、持久化性能。
我们已经知道,我们必须要找到TopN和Top1,那么此时我们所能依赖的中间件就只有Redis的Zset了,Redis我们使用Redis集群版本,当内存不够时可以通过增加节点来平摊哈希槽来扩展可用内存,同时Redis的Zset既可以支持TopN,也可以支持Top1获取,在查询性能上效率很高。同时在存储Key时尽量使用Number类型而不是字符串类型,字符串类型在Redis底层结构上还会多占用16个字节的内容,而如果用Number类型,则只占用8个字节,会降低8个字节内存占用。在数据持久化上可以考虑使用MySQL或者可持久化的NoSQL等。如果用MySQL,我们可以进行水平分表,针对排行榜单数据表我们可以水平分20个表,每张表存储5000w数据的话,总共可以存储10亿条数据。而单张表5000w的数据量,如果查询通过主键查询的话,只需要进行3次IO,而每次IO在机械磁盘上平均10MS左右时间(随机IO),在加上MySQL InnoDB、MySQL Server、TCP传输等耗时,我们可以估算为60MS可以查询一次数据。
每次当用户充值送礼物时,将用户的积分信息先保存到DB表中,然后将积分刷新到Redis中。这样在下次查询绑定时,可以直接从Redis中获取而不需要走MySQL这种比较重的DB来进行查询。
**那还有没有优化的点呢?**如果全部存入Redis,会不会有问题呢?可以优化不可以?
- 分两个Redis实例,一个Redis存储直播间所有用户的积分信息,一个Redis用于存储积分区间数组对应的ZsetKey。
- 存储用户积分信息我们仍然用Redis来实现,同时我们进行分桶来实现,每一个直播间的排行榜按照128个元素来进行存储在Zset中,这样可以保证底层使用紧凑型的压缩链表来降低内存占用情况。
- 同时我们将每一个直播间的ziplist通过数组来保存,数组下标0表示最高128位用户,越靠前的元素分值越高。
- 如果现在一个直播间排名有100000,那这个时候有不断有用户去刷新榜单,这个时候你需要去不断地去更改用户在不同的zset中。当用户充值礼物刷新积分的时候,我们去保存用户在直播间的积分信息,此时如果用户命中第二个元素,假设第二个元素大于128,那么此时在第二个元素分为2个元素,此时刷新保存ziplist的数组。这种情况不需要再海量用户频繁去刷新积分的时候去移动zset中的内容。只需要在极少的时间复杂度内修改保存ziplist的数组即可。
十一、算法:找出两个二位数组的交集。
// findUni 求ab两个数组的交集。
// [[1,3],[4,6]]
// [[2,3],[3,5]]
// 交集:[[2,3],[4,5]]
// 双指针。
func findUni(a, b [][]int) [][]int {
var (
left, right int
result = make([][]int, 0)
)
// 没有交集的时候退出。
for left < len(a) && right < len(b) {
// 如果不存在交集,则++,小的数组进行++,让小的数组后移找到大的数组和当前大的进行交集。
// 如果a在b的左侧,则a++,
if a[left][1] < b[right][0] {
left++
continue
}
if b[right][1] < a[left][0] {
right++
continue
}
// 确定交集。交集有一个特性就是:左侧取最大,右侧取最小.
uniLeft, uniRight := a[left][0], a[left][1]
if uniLeft < b[right][0] {
uniLeft = b[right][0]
}
if uniRight > b[right][1] {
uniRight = b[right][1]
}
result = append(result, []int{uniLeft, uniRight})
// 确定移动指针
if a[left][0] < b[right][0] {
left++
} else {
right++
}
}
return result
}
func TestUni(t *testing.T) {
res := findUni([][]int{{1, 3}, {4, 6}}, [][]int{{2, 3}, {3, 5}})
fmt.Println(res)
}
输出如下,符合预期: