zijing2333...大约 45 分钟

如果设计一个可靠UDP?

通信领域存在制约三角:时延、成本和质量。TCP是通过增大时延和成本来保证通信质量,UDP牺牲了质量保证了时延和成本。一定场景使用RUDP可以找到这三者之间的平衡点。实现可靠UDP主要有三种方式:

尽力可靠:接收方要求发送方数据尽量完整到达,但是业务本身的数据可以丢失的,如:音视频

无序可靠:通信方要求发送方数据必须完整到达,但是顺序不重要,如:文件传输、日志追加

有序可靠:通信方要求发送方数据必须顺序完整到达

为什么要实现可靠UDP

主要解决以下问题:

端对端连通性问题:终端之间通信会跨NAT,TCP在NAT之间穿越非常困难,然后UDP就简单很多,如:端对端文件传输、音视频传输等;

弱网传输问题:TCP的延迟受网络影响比较大,如:实时操作网游、语音对话等;

宽带竞争问题:有时候客户端上传数据要突破TCP公平性限制来达到高速低时延和稳定,如音视频直播推流,可以压榨带宽;

传输路径优化:对于时延要求比较高就会用应用层relay的方式来进行选路优化延时,比如服务之间数据转发、数据备份;

资源优化问题:主要是避免三次握手和四次挥手,如QUIC

所以首要是实现可靠性,保证质量,核心就是重传。RUDP重传是通过接收端的ACK丢包信息反馈来进行数据重传,主要分为:定时重传、请求重传和FEC重传。

  • 定时重传:发送端在发出数据包之后一个RTO没有收到ACK,就重传这个包,缺点是容易误判:对方可能收到ACK但是ACK丢了,或者ACK在途中但是时间超过一个RTO。核心就是计算好RTO时间,如果应用场景对于延迟比较敏感但是流量成本要求不高,可以把RTO设置小一点,比如在线操作网游,适合小带宽低延迟传输。定时重传对于大带宽消耗比较大,一般采用请求重传。
  • 请求重传:请求重传是接收端发送ACK时候携带自己丢包反馈,发送端根据ACK重传。关键是回送ACK时候标明哪些信息丢失了,因为UDP在网络传输中会抖动,接收端要评估网络的RTT方差,当发现丢包时候记录时间t1,如果t1+RTT方差<当前时刻,就认为丢包了。请求重传还依赖RTO,所以要不停评估这两个参数,整体比定时重传时延大,但是节约了带宽,比较适合视频、文件传输。
  • FEC选择重传:FEC(Forward Error Correction)是前向纠错技术,用XOR算法实现。发送方在发送报文的时候会对数据进行FEC分组,通过XOR得到若干个冗余包,一起发送给接收端,如果丢包了可以通过分组进行还原,就不用重传了。如果不能回复就请求原始数据包。FEC可以解决延时敏感并且可能随机丢包的环境,可以与上面两种方式进行搭配。

具体实现方式

RTT和RTO计算

RTT是网络环路延时,是通过发送数据包和接收到ACK计算的,可以通过类似于加权平均收敛计算,然后再计算RTT方差。计算RTO会涉及到报文重传,就是一个报文的重传周期,如果一个RTT+方差的时间没收到ACK,就可以再次重传,一般RTO = SRTT + SRTT方差,网络严重抖动的时候RTO会设置高一点,比如乘以1.2到2。

窗口和拥塞控制

还需要解决延时和重传带宽的问题,所以发送端可以设置一个拥塞控制窗口来避免高并发带宽过度占用问题。引入一个滑动窗口,有的业务场景RUDP需要发送端和接收端严格控制窗口,比如有序UDP就需要做好窗口排序和缓冲,无序的话一般不用做缓冲,只做位置滑动。每次收到数据接收方的窗口要滑动,滑动的速度受拥塞控制机制控制,拥塞控制可以通过丢包率后者网络时延实现。

拥塞算法

TCP有慢启动、拥塞避免、拥塞处理和快速恢复,都是为了决定发送窗口和速度设计的,根据丢包状况判断网络状态,确定发送窗口大小。TCP的实现是以成本换质量,缺点是很难高效利用也就是压榨网络带宽,很难保证大吞吐量和小时延。

  • BBR算法:基于发送端延迟和带宽评价拥塞的,可以保证在有一定丢包率的网络上充分利用带宽,然后还可以降低buffer时延。主要策略是周期性的根据ACK和NACK评估最小RTT和最大带宽,最大吞吐量等于 = 最大带宽/最小RTT。首先会进行初始化cwnd为8,开始慢启动,根据周期性ACK采样判断是否增大带宽,如果可以cwnd = cwnd * max_gain。如果超过周期丢包就会进行消耗状态,发出去但是还没有确认的数据大小>cwnd的话,继续保持消耗状态,发出去但是还没有确认数据<cwnd,进入带宽探测状态,这个状态下如果没发生丢包并非发出去但是还没确认的数据<1.25倍cwnd,将维持原来状态的cwnd进入慢启动,发生丢包cwnd = cwnd * 0.75,否则cwnd=cwnd*1.25。当10秒内的RTT<=min_rtt,就会进入RTT探测状态,这个状态收到ACK并且没有丢包,将用本次统计最小RTT来更换原本min_rtt,然后再次慢启动。

  • 通过周期性的计算cwnd,来完成拥塞控制,比较适合随机丢包且网络稳定的场景,如果网络极不稳定就容易预测不准,而且在公平问题上小RTT比大RTT更吃带宽。是使用成本换取质量和可用的方式。

  • webRTC算法:webRTC算法是基于丢包率和接收端延迟带宽进进行拥塞控制,并且保证尽力可靠交付,重传太多次会直接丢弃。当丢包率小于2%会加大带宽、在2到10之间会保持当前码率,丢包率大于10%会认为传输过载而减小带宽。缺点就是网络间歇性丢包的时候收敛比较慢,容易造成发送端流量失效。是以质量和成本换取时延。

  • 弱窗口拥塞控制:传输数据量就不大,保证时延比较小和可靠,采用固定大小如cwnd = 32来做控制,简单直接但是难以适应弱网环境。

传输路径

RUDP可以利用UDP特性在链路上做传输优化,主要分为多点串联和多点并联。

  • RTN:双方通过传递节点动态选路,中间的链路只是无状态的缓存,传递节点之间进行路由探测和选路,来保证高可用。

  • 多点并联:中间通过多节点并联来进行跳转。

如何设计一个抢红包系统?

拆包算法

  • 随机发红包:每个人可以获取的红包金额等于[0.01,99.91)的左闭右开区间;最后一个人不用随机了。缺点是生成的过程不均匀。

  • 线性切割法:把总金额类比成一根绳子,对绳子切N-1刀,每个人能抢道的红包金额等于切割绳子的占比。

  • 二倍均值法:每次抢到的红包 = 随机区间(0,M/N * 2),M是总金额,N是红包个数,任何一个人抢到的红包都不会大于人均的二倍。比如100个人抢五个红包,第一个人抢到的红包金额为(0.01,100/5*2),第二个人抢到红包的金额为(0.01,80/4*2),最后符合金额正态分布,在20左右。

业务架构图

首先使用户和前端页面进行交互,网关微服务做主机鉴权,看看用户有没有登录认证。如果没有用户认证的话需要到用户认证微服务到用户微服务中查询用户,注册或者登陆成功之后跳转到聊天室,对应聊天室微服务。

用户微服务、用户认证微服务和聊天室微服务是面向用户。用户微服务对应着图片微服务和账户微服务,分别存储用户信息和提供账户管理功能。聊天室微服务可以发红包和存储历史消息。

外部依赖redis缓存、Nacos注册中心和Seata-Server。

发、抢红包流程

调用发红包的API,先检查用户余额是否大于红包金额,如果用户的余额大于红包的金额的话,将一条红包记录保存到数据库里,更新账户余额并且用红包生成算法将红包放入redis。

抢红包的时候要判断红包个数是否大于0,大于0就更新redis的缓存并且插入一条抢红包记录,再把相应的红包入账。抢红包入账是异步实现的,采用消息队列,红包入库系统可以监听MQ,如果信道上有消息就更新账户并且将记录保存到数据库。引入MQ可以实现实现高并发、高可用、高可扩展。

首先将个人红包记录入库,红包个数和红包金额扣减,用户金额增加,成功就返回ACK,失败就返回失败ACK。

高并发问题

  • 超卖:不同用户在读请求时候发现商品库存足够然后同时发起请求,进行秒杀操作导致库存为负数;同一个用户在有库存的时候连续发出多个请求,两个请求同时存在,于是生成多个订单。不同用户抢红包导致红包为负数或者同一个抢到多个红包。解决办法是分布式锁,可以基于Redis或者zookeeper实现,Redis是NoSQL数据,Zk是分布式协调工具,redis通过设置key有效期防止死锁,zk通过使用会话有效期解决死锁。Redis是NoSQL并且Zk需要创建删除节点,所以Redis效率更好。但是Redis有效期不是很好控制,可能会导致有效期延迟,而ZK临时节点有先天可控的有效期,因此Zk更可靠。小并发选择zk,性能优先选择redis。
  • 数据一致性:发红包之后红包服务要将红包放入数据库,红包服务要调用账户服务更新账户数据库,微服务之间远程调用要保证数据一致性,然后还要保证事务不失败。为了保证数据一致性,要用到分布式事务。分布式事务方案可以使用seata,seata使用2pc实现。
  • 消息可靠性:调用红包服务,更新redis,然后利用MQ解耦,更新数据库。使用消息队列保证消息可靠性,有ACK确认机制。生产者ACK可以知道消息是否到达消息队列,消费者ACK之后MQ队列能知道消费者有没有正常消费消息。可以使用重试机制或者消息补偿来保证幂等性。

设计一个秒杀系统?

秒杀界面CDN

内容分发网络(CDN)。可以在秒杀开始前,预先把网页的静态资源存放在 CDN 节点,用户在刷新界面时直接从 CDN 获取静态资源,从而降低刷新秒杀界面对服务器造成的压力。添加了 CDN 服务之后,秒杀界面有大量用户同时访问和刷新并不会给服务端带来多大压力。

秒杀按钮优化

秒杀系统往往会有一个秒杀按钮,如果不对按钮进行限制,可能存在以下问题:

  • 用户在秒杀开始前点击按钮,造成很多无用的请求
  • 用户在秒杀开始后多次点击按钮,造成很多重复请求

可以对按钮做一些限制:秒杀开始前按钮不可用,用户点击一次秒杀按钮后,按钮也进入不可用状态。这种方式无法限制通过脚本请求后端的情况,但是可以限制正常用户的多次无效点击,大大降低请求量。

秒杀链接优化

用户在点击秒杀按钮的时候,前端会请求一个固定的 URL,这个 URL 可以在前端界面查到。对于普通不懂技术的用户来说,这没有什么问题,如果用户稍微懂点 Http 协议,就可以在秒杀开始前拿到 URL,在秒杀开始前或开始的毫秒级时间内请求秒杀链接,不仅会给服务端带来很大的压力,还会造成不公平现象:商品都被开脚本的人抢走了。为了避免这种现象,可以将 URL 动态化,即使秒杀系统的开发人员也无法在知晓在秒杀开始时的 URL。具体实现方法是在获取秒杀 URL 的接口中,返回一个服务器端生成的随机数,并在下单 URL 中传递该参数完成下单。

秒杀验证码

动态 URL 避免了用户在秒杀开始前请求秒杀链接,但是用户还是可以通过脚本在秒杀开始的那一刻去请求秒杀连接,普通用户基本没有办法和脚本秒杀进行竞争。可以引入机器难以识别的验证码,用户在请求秒杀链接之前,需要填写验证码识别的结果,验证码错误的请求直接拒绝。使用验证码不仅可以增加脚本秒杀的难度,还可以降低请求的 QPS,因为请求不再是在秒杀那一刻进来,而会被分散到填写验证码的时间段内。

过滤请求

可以在用户端和服务端添加一层过滤层,只要保证有100个以上的请求能打到秒杀服务器端。使用 Nginx 服务器来构建过滤层,一个 Nginx 服务器也没法抗 100W 的请求,假设每个 Nginx 服务器可以处理 10W 的请求,那么就需要 10 台 Nginx。可以简单的让每个 Nginx 服务器只通过前 100 个请求,后续请求直接返回降级界面。通过 Nginx 过滤,可以把 100W 的请求过滤为 1000 个请求,大大减少了服务器端的压力。

Redis缓存

如果通过前面的过滤,请求量依旧非常大,如果数据库无法处理这些请求量,需要在数据库之上添加一层 Redis 缓存。单个 Redis 可以处理几万的 QPS,如果预估请求的 QPS 大于几万,可以使用 Redis 集群模式来增加 Redis 的处理能力。在 Redis 存放和售卖商品数目大小相同的数字,秒杀服务每次访问数据库之前,都需要先去 Redis 中扣减库存,扣减成功才能继续更新数据库。这样,最终到的数据库的请求数目和需要售卖商品的数目基本一致,数据库的压力可以大大减少。

Redis原子性

Redis是不支持事务的,所以可能出现扣减为负数的情况,这种情况下可以使用 Lua 脚本来保证一次扣减操作的原子性,从而保证扣减结果的正确性。

异步更新数据库

通过 Redis 判断之后,去更新数据库的请求都是必要的请求,这些请求数据库必须要处理,但是如果数据库还是处理不过来这些请求怎么办呢?

这个时候就可以考虑削峰填谷操作了,削峰填谷最好的实践就是 MQ 了。经过 Redis 库存扣减判断之后,我们已经确保这次请求需要生成订单,我们就可以通过异步的形式通知订单服务生成订单并扣减库存。

如何设计一个分布式ID?

为了保证全局唯一性可以用时间作为区分点一部分,时间尽可能细化,可以精确到毫秒,甚至是微秒和纳秒。如果是分布式系统有多台机器,可以根据机器ID再进行以下区分。如果机器运行的特别快,1毫秒有大量ID生成,可以结合实际限制下实际生成的ID数目。

如果N台机器去ID生成服务器的服务端得到全局ID,很容易保证全局唯一且自增的,但是存在单点失效的问题,不满足高可用。

雪花算法

生成的结果是一个int64的数据。核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号,意味着每个节点在每毫秒可以产生 4096个ID,最后还有一个符号位,永远是0。

优点:优点是毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的。可以根据自身业务特性分配bit位,非常灵活。

缺点:强依赖机器时钟,如果机器上时钟回拨,会导致号重复或者服务会处于不可用状态。

Redis生成ID

因为Redis是单线程的,也可以用来生成全局唯一ID。可以用Redis的原子操作INCR和INCRBY来实现。使用Redis集群来获取更高的吞吐量。假如一个集群中有5台Redis,可以初始化每台Redis的值分别是1,2,3,4,5,步长都是5,各Redis生成的ID如下:A:1,6,11,16;B:2,7,12,17;C:3,8,13,18;D:4,9,14,19;E:5,10,15,20。负载到哪台机器提前定好,未来很难做修改。3~5台服务器基本能够满足需求,但步长和初始值一定需要事先确定,使用Redis集群也可以解决单点故障问题

优点:不依赖于数据库,灵活方便,且性能优于数据库;数字ID天然排序,对分页或需要排序的结果很有帮助。

缺点:如果系统中没有Redis,需要引入新的组件,增加系统复杂度;需要编码和配置的工作量较大。

UUID

可以利用数据库也可以利用程序生成,一般全球唯一。UUID是由32个的16进制数字组成,所以每个UUID的长度是128位(16^32 = 2^128)。UUID有多个实现版本,影响它的因素包括时间、网卡MAC地址、自定义Namesapce等等。

优点:简单,代码方便;生成ID性能非常好,基本不会有性能问题;全球唯一,在遇见数据迁移,系统数据合并,或者数据库变更等情况下,可以从容应对。

缺点:没有排序,无法保证趋势递增;UUID往往是使用字符串存储,查询的效率比较低;存储空间比较大,如果是海量数据库,就需要考虑存储量的问题;传输数据量大;不可读。

美团Leaf

Leaf-segment

直接用数据库自增ID充当分布式ID,减少对数据库的频率操作。过程是从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如 (1,10000] 代表10000个ID,业务服务将号段在本地生成1~10000的自增ID并加载到内存。在当前号段消费到某个点时,就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做很大程度上的降低了系统的风险。Leaf-segment采用双buffer的方式,它的服务内部有两个号段缓存区segment。当前号段已消耗10%时,还没能拿到下一个号段,则会另启一个更新线程去更新下一个号段。Leaf保证了总是会多缓存两个号段,即便哪一时刻数据库挂了,也会保证发号服务可以正常工作一段时间。通常推荐号段(segment)长度设置为服务高峰期发号QPS的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。

biz_tag

针对不同业务需求,用biz_tag字段来隔离,如果以后需要扩容时,只需对biz_tag分库分表即可

优点:Leaf服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景;容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务。

缺点:ID号码不够随机,能够泄露发号数量的信息,不太安全;DB宕机会造成整个系统不可用(用到数据库的都有可能)。

Leaf-snowflake

Leaf-snowflake基本上就是沿用了snowflake的设计,ID组成结构:正数位(占1比特)+ 时间戳(占41比特)+ 机器ID(占5比特)+ 机房ID(占5比特)+ 自增值(占12比特),总共64比特组成的一个int64类型。不同点主要是在workId的生成上,Leaf-snowflake依靠Zookeeper生成workId。Leaf中workId是基于ZooKeeper的顺序Id来生成的,每个应用在使用Leaf-snowflake时,启动时都会都在Zookeeper中生成一个顺序Id,相当于一台机器对应一个顺序节点。

启动服务的过程大致如下:启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过;如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务;如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。Leaf-snowflake对Zookeeper是一种弱依赖关系,除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。一旦ZooKeeper出现问题,恰好机器出现故障需重启时,依然能够保证服务正常启动。

优点:ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。

缺点:依赖ZooKeeper,存在服务不可用风险。

谁关注了我,我关注了谁,谁与我互相关注。数据库表该如何设计,索引怎么建。查询语句怎么写?

第一种设计方案

第一种方案包含3个字段:

  • id:一个自增的主键,唯一标识每一条关注关系记录。
  • user_id:关注者的ID。
  • fans_id:被关注者的ID。
CREATE TABLE follows (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    fans_id INT NOT NULL,
    UNIQUE KEY (user_id, fans_id)
);

索引

user_idfans_id 创建索引:

CREATE INDEX idx_user ON follows (user_id);
CREATE INDEX idx_fans ON follows (fans_id);

查询语句

  • 查询谁关注了我(假设我的用户ID是 my_user_id):
SELECT user_id FROM follows WHERE fans_id = my_user_id;
  • 查询我关注了谁:
SELECT fans_id FROM follows WHERE user_id = my_user_id;
  • 查询我与谁互相关注(通过自连接 follows 表,并使用连接条件筛选出互相关注的用户):
SELECT f1.user_id
FROM follows f1
JOIN follows f2
ON f1.user_id = f2.fans_id AND f1.fans_id = f2.user_id
WHERE f1.user_id = my_user_id;

第二种设计方案

在第一种基础之上多了一个mutual_follow表示是否相关关注,这样设计可以轻松地查询关注关系以及互相关注的状态。例如想找出与某个用户互相关注的所有用户,只需查询mutual_followtrueuser_idfans_id为该用户ID的记录即可。

但是这种设计在更新互相关注状态时会有额外开销,因为当两个用户之间的关注状态发生变化时需要更新两条关注关系记录(user_idfans_id交换的两条记录)。所以相对于第一种方案是一种空间换时间的策略

数据库表设计

第二种设计包含三个字段:

  • id:一个自增的主键,唯一标识每一条关注关系记录。
  • user_id:关注者的ID。
  • fans_id:被关注者的ID。
  • mutual_follow:一个布尔类型字段,表示这两个用户之间是否互相关注。如果user_id关注fans_id,并且fans_id也关注了user_id,则这个字段的值为true,否则为false

CREATE TABLE语句中创建唯一复合索引UNIQUE (user_id, fans_id),不再创建额外索引。

CREATE TABLE follows (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    fans_id INT NOT NULL,
    mutual_follow BOOLEAN DEFAULT FALSE,
    UNIQUE (user_id, fans_id)
);

查询语句

查询谁关注了我(以user_id=100为例):

SELECT user_id
FROM follows
WHERE fans_id = 100;

查询我关注了谁:

SELECT fans_id
FROM follows
WHERE user_id = 100;

查询我与谁互相关注:

SELECT fans_id
FROM follows
WHERE user_id = 100 AND mutual_follow = TRUE;

UNION

SELECT user_id
FROM follows
WHERE fans_id = 100 AND mutual_follow = TRUE;

对比

方案一:只包含 user_id 和 fans_id 两个字段,不包含 mutual_follow 字段的表结构。

优点:

  1. 表结构简单,易于理解和维护。
  2. 更新关注关系时,不需要额外更新 mutual_follow 字段,减少了更新开销。

缺点:

  1. 查询互相关注的用户时需要联接两次同一张表,可能导致查询性能较低。

方案二:包含 user_id、fans_id 和 mutual_follow 三个字段的表结构。

优点:

  1. 查询互相关注的用户时更加高效,因为互相关注信息已经在 mutual_follow 字段中,无需再进行联接操作。
  2. 更容易直接从表中获取互相关注的状态。

缺点:

  1. 表结构相对复杂,增加了维护成本。
  2. 更新关注关系时,需要额外更新 mutual_follow 字段,增加了更新开销。

如果查询互相关注用户的操作很频繁,且对查询性能有较高要求,可以选择方案二。如果更新关注关系的操作更加频繁,或者对查询互相关注用户的性能要求不高,可以选择方案一。

数据库连接池该怎么设计?

为什么要设计数据库连接池

设计数据库连接池的目的就是为了让系统更快、更省资源。通过复用已有的连接,可以避免重复创建和关闭连接的时间浪费,让系统响应更快、处理能力更强。

数据库连接是稀缺资源,如果没有连接池,每次访问数据库都会新建连接,就容易造成资源浪费。连接池通过维护一定数量的连接,并按需复用,有效地节省资源;连接池还能集中管理连接,比如设置最大连接数、最小连接数、超时时间等,这些参数可以根据实际需求和资源情况调整,用于获得最好的性能和资源利用;此外连接池还能提高系统的稳定性,如果没有连接池,系统可能会因为创建太多连接而耗尽资源,而连接池通过限制最大连接数,可以避免这个问题,让系统更稳定。

怎样设计数据库连接池

确定连接池类型

有三种主要的连接池类型:静态、动态和预先填充。静态连接池在程序启动时创建固定数量的连接,程序关闭时销毁。动态连接池可以根据需要创建和销毁连接。预先填充连接池在启动时创建一定数量的连接,但运行时仍可以动态调整。

连接创建与释放策略

连接创建策略包括以下几点:

  1. 当连接池启动时,我们会初始化一定数量的连接,以满足应用程序的基本需求。
  2. 我们还需要设置连接池允许的最大连接数,当连接数达到此限制时,新的请求需要等待直到有可用连接。
  3. 为了保持一定数量的空闲连接,我们需要设置最小空闲连接数,如果空闲连接不足,连接池将创建新连接。
  4. 另外,我们会设置尝试创建新连接的超时时间,如果超过这个时间仍无法建立连接,就会抛出异常。

连接释放策略主要包括以下几点:

  1. 我们需要设置连接空闲超时时间,当连接空闲时间超过此值时,连接将被关闭并从连接池中移除。
  2. 我们还要设置连接的最大存活时间,以防止潜在的资源泄漏。超过这个时间的连接会被关闭并移除。
  3. 为了确保已关闭的连接被正确回收,我们需要定期检查连接池中的连接,如果发现连接泄漏,可以采取相应措施,如记录日志或发出警报。
  4. 最后,在提供连接给应用程序之前,我们会对连接进行有效性检查,例如执行一个简单的SQL查询,以确保应用程序获得的连接是可用的。

超时设置

我们需要根据应用程序的性能要求来评估合适的连接获取超时时间。比如说,如果应用程序需要在200毫秒内响应用户请求,那么我们就要把连接超时时间设置得比200毫秒短,这样应用程序就能够快速响应;其次,我们要考虑数据库在高峰时段的负载和响应时间,以便确定合适的连接超时时间。如果数据库响应时间比较长,我们可以适当增加连接超时时间,这样就能降低因等待数据库响应而导致的超时异常。

关于空闲连接超时时间,需要考虑数据库的资源限制,比如最大连接数和内存占用等。较短的空闲超时时间有助于释放闲置连接,节省数据库资源,但可能导致频繁地创建和销毁连接。相反,较长的空闲超时时间可以减少这些开销,但可能导致数据库资源被闲置连接占用。

同时,我们还要在设置空闲连接超时时间时,平衡应用程序负载的变化。在业务高峰期,连接需求可能会迅速增加。较短的空闲连接超时时间可以让连接池更快地适应负载变化,但可能增加创建和销毁连接的开销。而较长的空闲连接超时时间可以降低这些开销,但可能导致连接池在负载变化时反应较慢。

线程安全

使用锁和信号量保护关键数据结构,如连接队列,并控制访问连接池的线程数量。

性能监控与调优

加入定期监控连接池的性能指标,如连接数、空闲连接数和等待连接的请求数等。根据这些指标和实际应用场景,我们可以调整超时设置,以达到最佳性能。

异常处理

设计异常处理机制,处理连接异常、超时异常等。处理方式包括记录日志、重试和触发告警。

可扩展性

接口化、插件化和配置化设计,便于切换实现、支持不同数据库和调整参数。

如何设计一个本地缓存?需要考虑哪些方面?

数据结构:设计用什么数据结构存储。最简单的就直接用Map来存储数据,或者复杂像redis一样提供了多种数据类型哈希,列表,集合,有序集合等,底层使用了双端链表,压缩列表,集合,跳跃表等数据结构

对象上限:本地缓存,内存有上限,所以一般都会指定缓存对象的数量比如1024,当达到某个上限后需要有某种策略去删除多余的数据。

清除策略:常见的比如有LRU(最近最少使用)、FIFO(先进先出)、LFU(最近最不常用)。

过期时间:除了使用清除策略,可以给缓存设置一个过期时间,这样当达到过期时间之后直接删除,采用清除策略+过期时间双重保证。

线程安全:像redis是直接使用单线程处理,所以就不存在线程安全问题。而现在提供的本地缓存往往是可以多个线程同时访问的,所以线程安全是不容忽视的问题,并且线程安全问题是不应该抛给使用者去保证。

简明的接口:提供常用的get,put,remove,clear,getSize等方法即可。

是否持久化:分布式缓存如redis是有持久化功能的,memcached是没有持久化功能的。

阻塞机制:二级缓存提供了一个blocking标识,表示当在缓存中找不到元素时,它设置对缓存键的锁定;这样其他线程将等待此元素被填充,而不是命中数据库。

分布式集群中如何保证线程安全?

串行化

通过串行化可能产生并发问题操作,牺牲性能和扩展性,来满足对数据一致性的要求。比如分布式消息系统就没法保证消息的有序性,但可以通过变分布式消息系统为单一系统就可以保证消息的有序性了。另外,当接收方没法处理调用有序性,可以通过一个队列先把调用信息缓存起来,然后再串行地处理这些调用。

分布式锁

需要满足互斥性,在任意时刻,只有一个客户端能持有锁;不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁;加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了;加锁和解锁必须具有原子性。可以用Redis实现分布式锁。

可能会释放其他服务器的锁:在删除锁之前先进行判断看是不是自己的锁,通过uuid进行锁的标识。

删除操作缺乏原子性:需要使得判断和删除时原子性的,可以使用Lua脚本实现判断和删除操作的原子性。

扫码登录是如何实现的?

二维码信息里主要包括唯一的二维码ID,过期的时间,还有扫描状态:未扫描、已扫描、已失效。

扫码登录流程

用户打开网站的登录页面的时候,浏览器会向二维码服务器发送一个获取登录二维码的请求。二维码服务器收到请求之后,会随机的生成一个uuid,通常是唯一的。将这个uuid作为key存储到redis服务器中,同时会设置一个过期时间,过期之后用户就要重新网页刷新来获取。之后会将这个uuid和本公司的验证字符串和在一起通过二维码生成接口生成图片,将二维码图片信息和uuid返回给浏览器,浏览器拿到uuid和图片之后,每隔一定时间就向服务器发送一个判断登陆是否成功的请求,请求中会携带uuid作为当前页面的标识符。

用户拿起手机扫描二维码之后,就会得到二维码中包含的验证信息和uuid,由于手机端已经进行过登陆验证,在访问手机端服务器的时候参数中都会携带一个用户信息token,这个token是在第一次手机登陆过程中产生并且长期有效的。手机端服务器通过这个token就可以解析出用户的类似于userId等信息。然后手机端服务器会将解析出来的数据作为参数向二维码服务器发送登陆请求,二维码服务器收到请求之后会对参数进行校验,确定是否为用户登录请求接口。如果是就返回给手机一个确认信息。手机端收到信息之后,登陆确认框会显示给用户,用户进行登陆确认之后手机再次发送请求,redis服务器拿到信息之后,会将刚才uuid的key的value设置为userId。

这样浏览器再次发送请求的时候就可以在redis服务器中拿到用户的id,并调用登陆方法生成一个浏览器端token。浏览器再发送请求,就会将用户信息的token返回给浏览器。

提示

这里存储用户id而不是直接存储用户信息是因为,手机端的用户信息,不一定是和浏览器端的用户信息完全一致。

传token是为了安全,token是被加密的,直接传userId可能有被窃取的风险。

①认证成功后,会对当前用户数据进行加密,生成一个加密字符串token,返还给客户端(服务器端并不进行保存)

②浏览器会将接收到的token值存储在Local Storage中(通过js代码写入Local Storage,通过js获取,并不会像cookie一样自动携带)

③再次访问时服务器端对token值的处理:服务器对浏览器传来的token值进行解密,解密完成后进行用户数据的查询,如果查询成功,则通过认证,实现状态保持,所以,即时有了多台服务器,服务器也只是做了token的解密和用户数据的查询,它不需要在服务端去保留用户的认证信息或者会话信息,这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利,解决了session扩展性的弊端。

在弱网环境下,如何确保一个http请求发送成功/如何提高http连接成功率?

问题

丢包、错包、乱包。

高延迟:响应数据回来时间长,甚至大于客户端等待时间

带宽小:每次能够通信的内容较少,数据包越大受影响可能越大

网络断续:网络经常断开又连接

优化处理

  • 采用TCP协议、实现长连接,采用长连接池,节省握手时间。

  • 采用ProtocolBuffer,减少冗余数据。

  • 弃用DNS,直接使用IP,减少了请求DNS服务查询IP的时间,避免被DNS劫持。

  • 加入重试机制,提供成功率。

  • 使用Http 2.0,压缩头部、长连接更加彻底、支持推送、支持(Multiplexing:支持一个TCP连接上同时实现多个请求和响应)。

  • 超时时间设置可以适当延长(限制放宽一点)。

  • 使用https:https对于http 主要在前面的握手阶段次数多,因为增大了丢包的概率。https 因为过程中,server端会把ssl证书公钥给客户端,证书很大,可能会引起分包,也有概率把次数变得更多。可以把reve buffer都尽量根据证书调整到正确的大小。

  • 交互次数上:提供一个预热的接口(pre https,pre httpdns),后面保持长连接和心跳,后面真正的业务上来的时候就会体验好一些。交互内容大小:可以预先把证书下载到客户端(定期刷新),安全和效率的一个平衡。

  • 断线重连。这可能是最重的一个特性,因为在无线网络中有太多的原因导致数据连接中断了。这里可以使用CDN。(CDN 是构建在数据网络上的一种分布式的内容分发网。 CDN 的作用是采用流媒体服务器集群技术,克服单机系统输出带宽及并发能力不足的缺点,可极大提升系统支持的并发流数目,减少或避免单点失效带来的不良影响。)

  • 由于创建连接是一个非常昂贵的操作,所以应尽量减少数据连接的创建次数,且在一次请求中应尽量以批量的方式执行任务。如果多次发送小数据包,应该尽量保证在2秒以内发送出去。在短时间内访问不同服务器时,尽可能地复用无线连接

  • 控制数据包大小不超过1500,避免分片。包括逻辑链路控制(Logic Link Control)分片、GGSN分片,以及IP分片。其中,当数据包大小超出GGSN所允许的最大大小时,GGSN的处理方式有以下三种:分片、丢弃和拒绝。

  • 优化TCP socket参数,包括:是否关闭快速回收、初始RTO、初始拥塞窗口、socket缓存大小、Delay-ACK、Selective-ACK、TCP_CORK、拥塞算法(westwood/TLP/cubic)等。做这件事情的意义在于:由于2G/3G/4G/WIFI/公司内网等接入网络的QoS差异很大,所以不同网络下为了取得较好的服务质量,上述参数的取值差异可能会很大。

  • 前后端采用gzip方式请求和响应,前端在请求header添加:“content-encoding” 为 “gzip”,后端也要开启gzip,才能生效,相比不采用gzip的请求方式,能节省流量,可以快速响应。

app如何精准校时(客户端的时间如何与服务端的时间进行较准?)

服务器端永远使用UTC时间,包括参数和返回值,不要使用Date格式,而是使用UTC时间1970年1月1日的差值,即long类型的长整数。

APP端将服务器返回的long型时间转换为GMT8时区的时间,额外加上8小时,这样就保证了无论使用者在哪个时区,他们看到的时间都是同一个时间,也就是GMT8的时间。

APP本地时间会不准,可以使用HTTP Response头的Date属性,每次调用服务器接口时就取出HTTP Response头的Date值,转换为GMT时间,再减去本地取出的时间,得到一个差值d,将这个差值d保存下来。每次获取本地时间的时候,额外加上这个差值d,就得到了服务器的GMT8时间,就保证了任何人看见的时间都是一样的。

如何实现单点登录?

指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分。

相比于单系统登录,sso需要一个独立的认证中心,只有认证中心能接受用户的用户名密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权。间接授权通过令牌实现,sso认证中心验证用户的用户名密码没问题,创建授权令牌,在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以借此创建局部会话,局部会话登录方式与单系统的登录方式相同。这个过程,也就是单点登录的原理,用下图说明:

下面对上图简要描述:

  1. 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数;
  2. sso认证中心发现用户未登录,将用户引导至登录页面;
  3. 用户输入用户名密码提交登录申请;
  4. sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌;
  5. sso认证中心带着令牌跳转会最初的请求地址(系统1);
  6. 系统1拿到令牌,去sso认证中心校验令牌是否有效;
  7. sso认证中心校验令牌,返回有效,注册系统1;
  8. 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源;
  9. 用户访问系统2的受保护资源;
  10. 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数;
  11. sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌;
  12. 系统2拿到令牌,去sso认证中心校验令牌是否有效;
  13. sso认证中心校验令牌,返回有效,注册系统2;
  14. 系统2使用该令牌创建与用户的局部会话,返回受保护资源。

用户登录成功之后,会与sso认证中心及各个子系统建立会话,用户与sso认证中心建立的会话称为全局会话,用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso认证中心,全局会话与局部会话有如下约束关系:

  • 局部会话存在,全局会话一定存在;
  • 全局会话存在,局部会话不一定存在;
  • 全局会话销毁,局部会话必须销毁。

注销

单点登录自然也要单点注销,在一个子系统中注销,所有子系统的会话都将被销毁,用下面的图来说明:

sso认证中心一直监听全局会话的状态,一旦全局会话销毁,监听器将通知所有注册系统执行注销操作。下面对上图简要说明:

  1. 用户向系统1发起注销请求;
  2. 系统1根据用户与系统1建立的会话id拿到令牌,向sso认证中心发起注销请求;
  3. sso认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址;
  4. sso认证中心向所有注册系统发起注销请求;
  5. 各注册系统接收sso认证中心的注销请求,销毁局部会话;
  6. sso认证中心引导用户至登录页面。

部署

单点登录涉及sso认证中心与众子系统,子系统与sso认证中心需要通信以交换令牌、校验令牌及发起注销请求,因而子系统必须集成sso的客户端,sso认证中心则是sso服务端,整个单点登录过程实质是sso客户端与服务端通信的过程,用下图描述:

sso认证中心与sso客户端通信方式有多种,这里以简单好用的httpClient为例,web service、rpc、restful api都可以。

如何计算游戏客户端与服务器之间的时间延迟?

要计算客户端与服务器之间的时间延迟,我们可以采用以下方法:

  1. 首先,客户端记录当前本地时间TA1,并向服务器发送一条报文。
  2. 服务器收到报文后,记录自己的本地时间TB,并将TB放入报文中回发给客户端。
  3. 客户端收到回复后,记录收到报文的时间TA2。

假设报文往返的时间基本相等,那么客户端到服务器的时间延迟P = (TA2 - TA1) / 2。通常,单次计算的延迟不够精确,可以让客户端定时发送测量信息,然后计算P的平均值。

为了获取客户端和服务器的本地时间差,我们可以通过以下公式计算:

  1. 假设客户端和服务器的本地时钟相同(实际情况并非如此),则TB = TA1 + P。
  2. 将上述延迟P代入公式,整理后得到TB = (TA1 + TA2) / 2。
  3. 由于客户端和服务器之间存在实际时间差X,我们可以得到TB + X = (TA1 + TA2) / 2。
  4. 整理后得到X = (TA1 + TA2) / 2 - TB,即客户端和服务器的本地时间差。

10w定时任务,如何高效触发超时?

环形队列法

m秒超时,就创建一个index从0到m的环形队列(本质是个数组),环上每一个slot是一个set,存储uid,任务集合

同时还有一个Map,key是uid,value是index,记录uid落在环上的哪个slot里。

  • 启动一个timer,每隔1s,在上述环形队列中移动一格,0->1->2->3…->29->30->0…

  • 有一个Current Index指针来标识刚检测过的slot

当有某用户uid有请求包到达时

  • 从Map结构中,查找出这个uid存储在哪一个slot里

  • 从这个slot的Set结构中,删除这个uid

  • 将uid重新加入到新的slot中,具体是哪一个slot呢 => Current Index指针所指向的上一个slot,因为这个slot,会被timer在30s之后扫描到

  • 更新Map,这个uid对应slot的index值

哪些元素会被超时掉

Current Index每秒种移动一个slot,这个slot对应的Set中所有uid都应该被集体超时。如果最近30s有请求包来到,一定被放到Current Index的前一个slot了,Current Index所在的slot对应Set中所有元素,都是最近30s没有请求包来到的。

所以,当没有超时时,Current Index扫到的每一个slot的Set中应该都没有元素。

优点是:

  • 只需要1个timer

  • timer每1s只需要一次触发,消耗CPU很低

  • 批量超时,Current Index扫到的slot,Set中所有元素都应该被超时掉

这个环形队列法是一个通用的方法,Set和Map中可以是任何task,本文的uid是一个最简单的举例。

轮询扫描法

  • 用一个Map<uid, last_packet_time>来记录每一个uid最近一次请求时间last_packet_time

  • 当某个用户uid有请求包来到,实时更新这个Map

  • 启动一个timer,当Map中不为空时,轮询扫描这个Map,看每个uid的last_packet_time是否超过30s,如果超过则进行超时处理

多timer触发法

  • 用一个Map<uid, last_packet_time>来记录每一个uid最近一次请求时间last_packet_time

  • 当某个用户uid有请求包来到,实时更新这个Map,并同时对这个uid请求包启动一个timer,30s之后触发

  • 每个uid请求包对应的timer触发后,看Map中,查看这个uid的last_packet_time是否超过30s,如果超过则进行超时处理

一个app在启动的时候有很多模块要加载,一个模型就要使用一个线程,怎样限制线程的最大使用数量?

在一个应用程序启动时,可能需要加载许多模块,每个模块可能需要一个线程来执行。为了控制线程数量并提高系统性能,我们可以使用线程池。线程池是一种维护一定数量线程的技术,它允许我们限制创建的线程数量并在多个任务之间重用线程

首先我们要根据应用程序的需求和系统资源情况,设置合适的线程池大小。这可以确保我们不会创建过多线程,导致资源消耗过大。同时,我们需要使用一个队列来管理等待执行的任务,当线程池中有空闲线程时,它会从队列中取出任务并执行。

针对不同的应用需求,我们需要选择合适的线程池类型。例如,在Java中,我们可以选择固定大小的线程池、可缓存的线程池或定时任务线程池等。这可以帮助我们更好地满足应用程序的特定需求。

此外,我们应该尽量采用异步执行任务的方式,以避免线程阻塞,从而提高线程利用率。同时,我们需要定期监控线程池的状态,如线程数量、任务队列大小等,以确保应用程序性能和资源利用率处于最佳状态。

你认为这篇文章怎么样?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.14.8