阿里妹导读
本文是技术人面试系列个人项目篇,作者总结了一些自己的实战项目经验,一文带你详细了解,欢迎收藏!
一、一站到底
采用SpringBoot构建项目,主要通过分布式缓存、队列、限流保证系统高可用,Netty、缓存、反向代理保证高并发。
双人对战答题、公司对战抢答。
1、如何设计排行榜
  • 个人总得分和总排名实时更新;
  • 个人排行榜按分数、时间、次数、正确率展示;
  • 日榜、过去N日榜滚动更新;

性能优化过程

第一条需求很简单,使用了Redis的Zset实现不过这里总得分采用了基于分数、时间、次数和正确率的混合加权。考虑到数据的持久化,以及关系数据库和缓存的一致性导致的设计的复杂性,使用了谷歌开源的JamsRanking。
优点是可以直接使用现成的setScores和getRanking接口封装了Redis和Mysql和消息队列的完成事务和一致性的使用细节。缺点是并发比较低使用Jmeter进行压测,单机只有20左右的TPS。
后来看了下源码,主要是它针对每一次设置都进行了分布式事务处理,并且会返回事务提交或回滚的结果。了解了底层实现以后就去谷歌的开源社区去查阅了相关的解决方案,当时官方对这个问题并没有通过配置能直接解决问题的快捷方式,不过推荐了使用者自身如果对响应时间不高的场景下可以采用批量合并事务的方式进行优化。基于这个思路,我们把写操作进行了封装并放入了队列,然后在消费者端批量取得数据后进行事务的批量处理,压测环境下整体性能达到了500TPS。已经基本满足了线上更新的需求,但是当时压测的过程中,队列偶尔的吞吐量会大范围波动,经常会持续数十秒,然后业务一次性处理完再响应,导致局部响应时间大幅度增长。
后来也是在官网上查询,了解到谷歌开源组件使用的队列服务底层是使用BigTable作为持久层,但是当BigTable分片过大时,会触发再分片的过程,再分片的过程中,是不会进行任务分发的,所以就会导致先前的问题。针对这个问题,谷歌官方的建议是提前配置队列的数量、负载策略和最大容量等信息,保证所有队列不同时触发再分片。
进行两次优化后,压测环境已经基本可以满足预期了,在实际生产环境的部署中,发现对于事务更新失败时,JamsRanking会对失败的事务进行切分和重试,整个过程对于研发人员是透明的,不利于线上问题排查,所以我们当时特地写了一个watchdog的工具,监控事务回滚达到十次以上的事务,查明原因后通过后台管理系统进行相应补偿,保证最终一致性。
最终结果:
  • 高效快速:能在数百毫秒内找到玩家排名以及进行更新
  • 强一致性以及持久化、排名准确
  • 可以扩展到任意数量的玩家
  • 吞吐量有限制,只能支持约每秒 500次更新。
针对这个缺点谷歌官方也是给出了使用分片树和近似排名的解决方案,当然复杂的方案有更高的运维成本,所以我们优化工作也就到此为止。

方案优化过程

方案1:每日一个滚动榜,当日汇聚(费时间)

首先记录每天的排行榜和一个滚动榜,加分时同时写入这两个榜单,每日零点后跑工具将前几天数据累加写入当日滚动榜,该方案缺点是时间复杂度高,7天榜还好,只需要读过去6天数据,如果是100天榜,该方案需要读过去99天榜,显然不可接受。

方案2:全局N个滚动榜同时写(费空间)

要做到每日零点后榜单实时生效,而不需要等待离线作业的完成,一种方案是预写未来的榜单。可以写当天的滚动榜的同时,写往后N-1天的滚动榜一起写入该方案不仅能脱离离线作业做到实时更新,且可以省略每天的日榜。但缺点也不难看出,对于7天滚动榜,每次写操作需要更新7个榜单,但是对于百日榜,空间消耗无法接受,1000万榜单大约消耗1G内存。

方案3:实时更新,常数次写操作

有不有办法做到既能实时更新,写榜数量也不随N的增加而增加呢?
仍然是记录每天的排行榜和一个滚动榜,加分操作也还是同时操作当日榜和全局榜,但每日零点的离线作业改为从全局榜中减去之前过期的数据,从而实现先滚动更新。 此方案每次只需读取一个日榜做减法,时间复杂度为O(1);但是无法做到实时更新。 这个方案的优点是在十二点前提前准备好差分榜,到了十二点直接加上当天数据就是滚动榜内容 ,这样就在常数次写操作的前提下,实现了滚动榜的实时更新。
2、如何解决重复答题
利用setnx防止重复答题
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。 利用Redis的单线程特性对共享资源进行串行化处理。
// 获取锁推荐使用set的方式Stringresult = jedis.set(lockKey, requestId, "NX", "EX", expireTime)
// 推荐使用redis+lua脚本String lua = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";Object result = jedis.eval(lua, Collections.singletonList(lockKey)
3、一个题目被多个人抢答
利用redis来实现乐观锁(抢答),好处是答错的人不影响状态,第一个秒杀答对的人才能得分。
1、利用redis的watch功能,监控这个 Corp:Activ:Qust: 的状态值;

2、获取Corp:Activ:Qust: 的值,创建redis事务,给这个key的值-1;

3、执行这个事务,如果key的值被修改过则回滚,key不变;
4、如何管理昵称重复
使用布隆过滤器:
它实际上是一个很长的二进制矢量数组和 K 个哈希函数。当一个昵称加入布隆过滤器中的时候,会进行如下操作:
  • 使用 K 个哈希函数对元素值进行 K 次计算,得到 K 个哈希值。
  • 根据得到的哈希值,在位数组中把对应下标的值置为 1。 Na
用户新增昵称时需要首先计算K个哈希值,如果K个哈希值有一个不为0则通过,否则不通过,不通过时通过加随机字符串再次检验,检测通过后返回给前端,帮助用户自动填写。
布隆过滤器的好处是它可以用来判断一个元素是否在一个集合中。它的优势是只需要占用很小的内存空间以及有着高效的查询效率。对于布隆过滤器而言,它的本质是一个位数组:位数组就是数组的每个元素都只占用 1 bit ,并且每个元素只能是 0 或者 1。
BloomFilter 的优势是,全内存操作,性能很高。另外空间效率非常高,要达到 1% 的误判率,平均单条记录占用 1.2 字节即可。而且,平均单条记录每增加 0.6 字节,还可让误判率继续变为之前的 1/10,即平均单条记录占用 1.8 字节,误判率可以达到 1/1000;平均单条记录占用 2.4 字节,误判率可以到 1/10000,以此类推。这里的误判率是指,BloomFilter 判断某个 key 存在,但它实际不存在的概率,因为它存的是 key 的 Hash 值,而非 key 的值,所以有概率存在这样的 key,它们内容不同,但多次 Hash 后的 Hash 值都相同。对于 BloomFilter 判断不存在的 key ,则是 100% 不存在的,反证法,如果这个 key 存在,那它每次 Hash 后对应的 Hash 值位置肯定是 1,而不会是 0。
5、如何管理出题定时任务
压测环境中服务器通过Netty的主从Reactor多路复用NIO事件模型,单机可以轻松应对十万长连接,但是每个业务中,由于每个用户登录系统后需要按照指定顺序答题,例如一共要答十道,那么服务器针对这一个用户就会产生十个定时任务,所以对于系统来说,定时器的数量就是百万级别的。
通过压测结果发现:JDK自带的Timer,在大概三万并发时性能就急剧下降了。也是此时根据业务场景的需要,将定时任务改成了Netty自带的HashedWheelTimer时间轮方案,通过压测单机在50万级别下依然能够平滑的执行。
也是这个强烈的反差,使我在强烈的好奇心促使下,阅读源码了解到常规的JDK 的Timer 和 DelayedQueue 等工具类,可实现简单的定时任务,单底层用的是堆数据结构,存取复杂度都是 O(NlogN),无法支撑海量定时任务。Netty经典的时间轮方案,正是通过将任务存取及取消操作时间复杂度降为 O(1),而广泛应用在定时任务量大、性能要求高的场景中。
基于Netty的Websocket底层,服务器端维护一个高效批量管理定时任务的调度模型。时间轮一般会实现成一个环形数组结构,类似一个时钟,分为很多槽,一个槽代表一个时间间隔,每个槽使用双向链表存储定时任务。指针周期性地跳动,跳动到一个槽位,就执行该槽位的定时任务。
单层时间轮的容量和精度都是有限的,对于精度要求特别高、时间跨度特别大或是海量定时任务需要调度的场景,可以考虑使用多级时间轮以及持久化存储与时间轮结合的方案。时间轮的定时任务处理逻辑如下:
  1. 将缓存在 timeouts 队列中的定时任务转移到时间轮中对应的槽中
  2. 根据当前指针定位对应槽,处理该槽位的双向链表中的定时任务,从链表头部开始迭代:
    • 属于当前时钟周期则取出运行
    • 不属于则将其剩余的时钟周期数减一
  3. 检测时间轮的状态。如果时间轮处于运行状态,则循环执行上述步骤,不断执行定时任务。
6、如何解决客户端断连
使用Netty的重连检测狗ConnectionWatchdog
服务端定义refreshTime,当我们从channel中read到了服务端发来的心跳响应消息的话,就刷新refreshTime为当前时间。
客户端在state是WRITER_IDLE的时候每隔一秒就发送一个心跳包到sever端,告诉server端我还活着。
当重连成功时,会触发channelActive方法,在这里我们开启了一个定时任务去判断refreshTime和当前时间的时间差,超过5秒说明断线了,要进行重连,最后计算重连次数,尝试连接2次以上连不上就会修改header信息强制重连去连另一个服务器。
二、秒杀项目
技术选型
秒杀用到的基础组件,主要有框架、KV 存储、关系型数据库、MQ。
框架主要有 Web 框架和 RPC 框架。
其中,Web 框架主要用于提供 HTTP 接口给浏览器访问,所以 Web 框架的选型在秒杀服务中非常重要。在这里,我推荐Gin,它的性能和易用性都不错,在 GitHub 上的 Star 达到了 44k。对比性能最好的 fasthttp,虽然 fasthttp 在请求延迟低于 10ms 时性能优势明显,但其底层使用的对象池容易让人踩坑,导致其易用性较差,所以没必要过于追求性能而忽略了稳定性。
至于 RPC 框架,我推荐选用 gRPC,因为它的扩展性和性能都非常不错。在秒杀系统中,Redis 中的数据主要是给秒杀接口服务使用,以便将配置从管理后台同步到 Redis 缓存中。
KV 存储方面,秒杀系统中主要是用 Redis 缓存活动配置,用 etcd 存储集群信息。
关系型数据库中,MySQL 技术成熟且稳定可靠,秒杀系统用它存储活动配置数据很合适。主要 原因还是秒杀活动信息和库存数据都缓存在 Redis 中,活动过程中秒杀服务不操作数据库, 使用 MySQL 完全能够满足需求。
MQ 有很多种,其中 Kafka 在业界认可度最高,技术也非常成熟,性能很不错,非常适合用在秒杀系统中。Kafka 支持自动创建队列,秒杀服务各个节点可以用它自动创建属于自己的队列。
方案设计
背景
  • 秒杀业务简单,每个秒杀活动的商品是事先定义好的,商品有明确的类型和数量,卖完即止;
  • 秒杀活动定时上架,消费者可以在活动开始后,通过秒杀入口进行抢购秒杀活动;
  • 秒杀活动由于商品物美价廉,开始售卖后,会被快速抢购一空;
现象
  • 秒杀活动持续时间短,访问冲击量大,秒杀系统需要应对这种爆发性的访问模型;
  • 业务的请求量远远大于售卖量,大部分是陪跑的请求,秒杀系统需要提前规划好处理策略;
  • 前端访问量巨大,系统对后端数据的访问量也会短时间爆增,对数据存储资源进行良好设计;
  • 活动期间会给整个业务系统带来超大负荷,需要制定各种策略,避免系统过载而宕机;
  • 售卖活动商品价格低廉,存在套利空间,各种非法作弊手段层出,需要提前规划预防策略;
秒杀系统设计
首先,要尽力将请求拦截在系统上游,层层设阻拦截,过滤掉无效或超量的请求。因为访问量远远大于商品数量,所有的请求打到后端服务的最后一步,其实并没有必要,反而会严重拖慢真正能成交的请求,降低用户体验。
秒杀系统专为秒杀活动服务,售卖商品确定,因此可以在设计秒杀商品页面时,将商品信息提前设计为静态信息,将静态的商品信息以及常规的 CSS、JS、宣传图片等静态资源,一起独立存放到 CDN 节点,加速访问,且降低系统访问压力,在访问前端也可以制定种种限制策略,比如活动没开始时,抢购按钮置灰,避免抢先访问,用户抢购一次后,也将按钮置灰,让用户排队等待,避免反复刷新。
其次,要充分利用缓存,提升系统的性能和可用性。
用户所有的请求进入秒杀系统前,通过负载均衡策略均匀分发到不同 Web 服务器,避免节点过载。在 Web 服务器中,首先检查用户的访问权限,识别并发刷订单的行为。如果发现售出数量已经达到秒杀数量,则直接返回结束,要将秒杀业务系统和其他业务系统进行功能分拆,尽量将秒杀系统及依赖服务独立分拆部署,避免影响其他核心业务系统。
秒杀系统需要构建访问记录缓存,记录访问 IP、用户的访问行为,发现异常访问,提前进行阻断及返回。同时还需要构建用户缓存,并针对历史数据分析,提前缓存僵尸强刷专业户,方便在秒杀期间对其进行策略限制。这些访问记录、用户数据,通过缓存进行存储,可以加速访问,另外,对用户数据还进行缓存预热,避免活动期间大量穿透。

1、如何解决超卖?

mysql乐观锁+redis预减库存+redis缓存卖完标记 
第一是基于数据库乐观锁的方式保证数据并发扣减的强一致性;
第二是基于数据库的事务实现批量扣减部分失败时的数据回滚。
在扣减指定数量前应先做一次前置数量校验的读请求(参考读写分离 + 全缓存方案)。
纯数据库乐观锁+事务的方式性能比较差,但是如果不计成本和考虑场景的话也完全够用,因为任何没有机器配置的指标,都是耍流氓。如果我采用 Oracle 的数据库、100 多核的刀锋服务器、SSD 的硬盘,即使是纯数据库的扣减方案,也是可以达到单机上万的 TPS 的。
单线程Redis 的 lua 脚本实现批量扣减。
当用户调用扣减接口时,将扣减的 对应数量 + 脚本标示传递至 Redis 即可,所有的扣减判断逻辑均在 Redis 中的 lua 脚本中执行,lua 脚本执行完成之后返还是否成功给客户端。
图片来源于网络
Redis 中的 lua 脚本执行时,首先会使用 get 命令查询 uuid 进行查重。当防重通过后,会批量获取对应的剩余库存状态并进行判断,如果一个扣减的数量大于剩余数量,则返回错误并提示数量不足。
Redis 的单线程模型,确保不会出现当所有扣减数量在判断均满足后,在实际扣减时却数量不够。同时,单线程保证判断数量的步骤和后续扣减步骤之间,没有其他任何线程出现并发的执行。
当 Redis 扣减成功后,扣减接口会异步的将此次扣减内容保存至数据库。异步保存数据库的目的是防止出现极端情况—— Redis 宕机后数据未持久化到磁盘,此时我们可以使用数据库恢复或者校准数据。
最后,运营后台直连数据库,是运营和商家修改库存的入口。商家在运营后台进货物进行补充。同时,运营后台的实现需要将此数量同步的增加至 Redis,因为当前方案的所有实际扣减都在 Redis 中。
纯缓存方案虽不会导致超卖,但因缓存不具备事务特性,极端情况下会存在缓存里的数据无法回滚,导致出现少卖的情况。且架构中的异步写库,也可能发生失败,导致多扣的数据丢失。
可以借助顺序写的特性,将扣减任务同步插入任务表,发现异常时,将任务表作为undolog进行回滚。
可以解决由于网络不通、调用缓存扣减超时、在扣减到一半时缓存突然宕机(故障 failover)了。针对上述请求,都有相应的异常抛出,根据异常进行数据库回滚即可,最终任务库里的数据都是准的。
更进一步:由于任务库是无状态的,可以进行水平分库,提升整体性能。

2、如何解决重复下单?

mysql唯一索引+分布式锁 

3、如何防刷?

IP限流 | 验证码 | 单用户 | 单设备 | IMEI | 源IP |均设置规则

4、热key问题如何解决?

redis集群+本地缓存+限流+key加随机值分布在多个实例中 :
1、缓存集群可以单节点进行主从复制和垂直扩容;
2、利用应用内的前置缓存,但是需注意需要设置上限;
3、延迟不敏感,定时刷新,实时感知用主动刷新;
4、和缓存穿透一样,限制逃逸流量,单请求进行数据回源并刷新前置;
5、无论如何设计,最后都要写一个兜底逻辑,千万级流量说来就来;

5、应对高并发的读请求

使用缓存策略将请求挡在上层中的缓存中
使用CDN,能静态化的数据尽量做到静态化
加入限流(比如对短时间之内来自某一个用户,某一个IP、某个设备的重复请求做丢弃处理)。
资源隔离限流会将对应的资源按照指定的类型进行隔离,比如线程池和信号量。
  • 计数器限流,例如5秒内技术1000请求,超数后限流,未超数重新计数;
  • 滑动窗口限流,解决计数器不够精确的问题,把一个窗口拆分多滚动窗口;
  • 令牌桶限流,类似景区售票,售票的速度是固定的,拿到令牌才能去处理请求;
  • 漏桶限流,生产者消费者模型,实现了恒定速度处理请求,能够绝对防止突发流量;
    流量控制效果从好到差依次是:漏桶限流 > 令牌桶限流 > 滑动窗口限流 > 计数器限流;
    其中,只有漏桶算法真正实现了恒定速度处理请求,能够绝对防止突发流量超过下游系统承载能力。
    不过,漏桶限流也有个不足,就是需要分配内存资源缓存请求,这会增加内存的使用率。而令牌桶限流算法中的“桶”可以用一个整数表示,资源占用相对较小,这也让它成为最常用的限流算法。正是因为这些特点,漏桶限流和令牌桶限流经常在一些大流量系统中结合使用。

6、应对高并发的写请求

  • 削峰:恶意用户拦截
    对于单用户多次点击、单设备、IMEI、源IP均设置规则;
  • 采用比较成熟的漏桶算法、令牌桶算法,也可以使用guava开箱即用的限流算法;
    可以集群限流,但单机限流更加简洁和稳定;
  • 当前层直接过滤一定比例的请求,最大承载值前需要加上兜底逻辑;
  • 对于已经无货的产品,本地缓存直接返回;
  • 单独部署,减少对系统正常服务的影响,方便扩缩容;
对于一段时间内的秒杀活动,需要保证写成功,我们可以使用 消息队列。
  • 削去秒杀场景下的峰值写流量——流量削峰
  • 通过异步处理简化秒杀请求中的业务流程——异步处理
  • 解耦,实现秒杀系统模块之间松耦合——解耦
削去秒杀场景下的峰值写流量
  • 将秒杀请求暂存于消息队列,业务服务器响应用户“秒杀结果正在处理中。。。”,释放系统资源去处理其它用户的请求。
  • 削峰填谷,削平短暂的流量高峰,消息堆积会造成请求延迟处理,但秒杀用户对于短暂延迟有一定容忍度。秒杀商品有 1000 件,处理一次购买请求的时间是 500ms,那么总共就需要 500s 的时间。这时你部署 10 个队列处理程序,那么秒杀请求的处理时间就是 50s,也就是说用户需要等待 50s 才可以看到秒杀的结果,这是可以接受的。这时会并发 10 个请求到达数据库,并不会对数据库造成很大的压力。
通过异步处理简化秒杀请求中的业务流程
先处理主要的业务,异步处理次要的业务。
  • 如主要流程是生成订单、扣减库存;
  • 次要流程比如购买成功之后会给用户发优惠券,增加用户的积****分。
  • 此时秒杀只要处理生成订单,扣减库存的耗时,发放优惠券、增加用户积分异步去处理了。
解耦
实现秒杀系统模块之间松耦合将秒杀数据同步给数据团队,有两种思路:
  • 使用 HTTP 或者 RPC 同步调用,即提供一个接口,实时将数据推送给数据服务。系统的耦合度高,如果其中一个服务有问题,可能会导致另一个服务不可用。
  • 使用消息队列将数据全部发送给消息队列,然后数据服务订阅这个消息队列,接收数据进行处理。

7、如何保证数据一致性

CacheAside旁路缓存读请求不命中查询数据库,查询完成写入缓存,写请求更新数据库后删除缓存数据。
// 延迟双删,用以保证最终一致性,防止小概率旧数据读请求在第一次删除后更新数据库public void write(String key,Object data){ redis.delKey(key); db.updateData(data); Thread.sleep(1000); redis.delKey(key);}
为防缓存失效这一信息丢失,可用消息队列确保。
  • 更新数据库数据;
  • 数据库会将操作信息写入binlog日志当中;
  • 另起一段非业务代码,程序订阅提取出所需要的数据以及key;
  • 尝试删除缓存操作,若删除失败,将这些信息发送至消息队列;
  • 重新从消息队列中获得该数据,重试操作;
订阅binlog程序在mysql中有现成的中间件叫canal,重试机制,主要采用的是消息队列的方式。
终极方案:请求串行化
真正靠谱非秒杀的方案:将访问操作串行化
  1. 先删缓存,将更新数据库的写操作放进有序队列中
  2. 从缓存查不到的读操作也进入有序队列
需要解决的问题:
  1. 读请求积压,大量超时,导致数据库的压力:限流、熔断
  2. 如何避免大量请求积压:将队列水平拆分,提高并行度。

8、可靠性如何保障

由一个或多个sentinel实例组成sentinel集群可以监视一个或多个主服务器和多个从服务器。哨兵模式适合读请求远多于写请求的业务场景,比如在秒杀系统中用来缓存活动信息。 如果写请求较多,当集群 Slave 节点数量多了后,Master 节点同步数据的压力会非常大。
当主服务器进入下线状态时,sentinel可以将该主服务器下的某一从服务器升级为主服务器继续提供服务,从而保证redis的高可用性。 

9、秒杀系统瓶颈-日志

秒杀服务单节点需要处理的请求 QPS 可能达到 10 万以上。一个请求从进入秒杀服务到处理失败或者成功,至少会产生两条日志。也就是说,高峰期间,一个秒杀节点每秒产生的日志可能达到 30 万条以上。
一块性能比较好的固态硬盘,每秒写的IOPS 大概在 3 万左右。也就是说,一个秒杀节点的每秒日志条数是固态硬盘 IOPS 的 10 倍,磁盘都扛不住,更别说通过网络写入到监控系统中。
  • 每秒日志量远高于磁盘 IOPS,直接写磁盘会影响服务性能和稳定性
  • 大量日志导致服务频繁分配,频繁释放内存,影响服务性能。
  • 服务异常退出丢失大量日志的问题
解决方案
  • Tmpfs,即临时文件系统,它是一种基于内存的文件系统。我们可以将秒杀服务写日志的文件放在临时文件系统中。相比直接写磁盘,在临时文件系统中写日志的性能至少能提升 100 倍,每当日志文件达到 20MB 的时候,就将日志文件转移到磁盘上,并将临时文件系统中的日志文件清空;
  • 可以参考内存池设计,将给logger分配缓冲区,每一次的新写可以复用Logger对象;
  • 参考kafka的缓冲池设计,当缓冲区达到大小和间隔时长临界值时,调用Flush函数,减少丢失的风险;
10、池化技术
通常可以采用循环队列来保存空闲连接。使用的时候,可以从队列头部取出连接,用完后将空闲连接放到队列尾部。Netty中利用带缓冲区的 channel 来充当队列。
三、即时通信
1、单聊消息可靠传输
TCP保证消息可靠传输三板斧:超时、重传、确认。服务端和客户端通信MSG和ACK的共计6个报文。
  • 请求报文(request,后简称为为R),客户端主动发送给服务端。
  • 应答报文(acknowledge,后简称为A),服务器被动应答客户端的报文。
  • 通知报文(notify,后简称为N),服务器主动发送给客户端的报文
在线消息流程:
A 消息请求  MSG:R => S 消息应答  MSG:A => S 消息通知B  MSG:N
S 确认通知  ACK:N <= S 确认应答  ACK:A <= B确认请求S  ACK:R
超时与重传、确认和去重:
A发出了 MSG:R ,收到了MSG:A之后,在一个期待的时间内,如果没有收到ACK:N,A会尝试将 MSG:R 重发。可能A同时发出了很多消息,所以A需要在本地维护一个等待ack队列,并配合timer超时机制,来记录哪些消息没有收到ACK:N,定时重发。确认ACK保证必达,去重保证唯一
离线消息流程
原方案:根据离线好友的标识,交互拉取指定的消息。
图片来源于网络
优化的方案:
  • 如用户勾选全量则返回计数,在用户点击时拉取。
  • 如用户未勾选全量则返回最近全部离线消息,客户端针对用户id进行计算。
  • 全量离线信息可以通过客户端异步线程分页拉取,减少卡顿
  • 将ACK和分页第二次拉取的报文重合,可以较少离线消息拉取交互的次数
2、群聊消息如何保证不丢不重
在线的群友能第一时间收到消息;

离线的群友能在登陆后收到消息。
图片来源于网络
  • 群消息发送者x向server发出群消息;
  • server去db中查询群中有多少用户(x,A,B,C,D);
  • server去cache中查询这些用户的在线状态;
  • 对于群中在线的用户A与B,群消息server进行实时推送;
  • 对于群中离线的用户C与D,群消息server进行离线存储。
对于同一份群消息的内容,多个离线用户存储了很多份。假设群中有200个用户离线,离线消息则冗余了200份,这极大的增加了数据库的存储压力
  • 离线消息表只存储用户的群离线消息msg_id,降低数据库的冗余存储量;
  • 加入应用层的ACK,才能保证群消息一定到达,服务端幂等性校验及客户端去重,保证不重复;
  • 每条群消息都ACK,会给服务器造成巨大的冲击,通过批量ACK减少消息风暴扩散系数的影响;
  • 群离线消息过多:拉取过慢,可以通过分页懒拉取改善。
3、如何保证消息的时序性
方案:
  • Id通过借鉴微信号段+跳跃的方式保证趋势递增;
  • 单聊借鉴数据库设计,单点序列化同步到其他节点保证多机时序;
  • 群聊消息使用单点序列化保证各个发送者的消息相对时序;
图片来源于网络
优化:
  • 利用服务器单点序列化时序,可能出现服务端收到消息的时序,与发出序列不一致;
  • 在A往B发出的消息中,加上发送方A本地的一个绝对时序,来表示接收方B的展现时序;
  • 群聊消息保证一个群聊落在一个service上然后通过本地递增解决全局递增的瓶颈问题;
4、推拉结合
历史方案:
  • 服务器在缓存集群里存储所有用户的在线状态 -> 保证状态可查;
  • 用户状态实时变更,任何用户登录/登出时,需要推送所有好友更新状态;
  • A登录时,先去数据库拉取自己的好友列表,再去缓存获取所有好友的在线状态;
“消息风暴扩散系数”是指一个消息发出时,变成N个消息的扩散系数,这个系数与业务及数据相关,一定程度上它的大小决定了技术采用推送还是拉取。
优化方案:
  • 好友状态推拉结合,首页置顶亲密、当前群聊,采用推送,否则可以采用轮询拉取的方式同步;
  • 群友的状态,由于消息风暴扩散系数过大,可以采用按需拉取,延时拉取的方式同步;
  • 系统消息/开屏广告等这种实时产生的消息,可以采用推送的方式获取消息;
5、好友推荐
Neo4j 图谱数据库
四、智慧社区
18年初,针对我们Dubbo框架的智慧楼宇项目的单体服务显得十分笨重,需要采用微服务的形式进行架构的重新设计,当时,我阅读了Eric Evans 写的《领域驱动设计:软件核心复杂性应对之道》和Martin fowler的《微服务架构:Microservice》两本重量级书籍,书中了解到转型微服务的重要原因之一就是利用分治的思想减少系统的复杂性,是一种针对复杂问题的宏观设计,来应对系统后来规模越来越大,维护越来越困难的问题。然而,拆分成微服务以后,并不意味着每个微服务都是各自独立地运行,而是彼此协作地组织在一起。这就好像一个团队,规模越大越需要一些方法来组织,这正是我们需要DDD模型为我们的架构设计提供理论并实践的方法。
当时每次版本更新迭代动辄十几个微服务同时修改,有时一个简单的数据库字段变更,也需要同时变更多个微服务,引起了团队的反思:微服务化看上去并没有减少我们的工作量。《企业架构设计》中对于微服务的定义是小而专,但在起初的设计时,我们只片面的理解了小却忽视了专,此时我们才意识到拆分的关键是要保证微服务内高内聚,微服务间低耦合。
物联网架构
物联网是互联网的外延。将用户端延伸和扩展到物与人的连接。物联网模式中,所有物品与网络连接,并进行通信和场景联动。互联网通过电脑、移动终端等设备将参与者联系起来,形成的一种全新的信息互换方式。

DCM系统架构

  • 设备感知层(Device):利用射频识别、二维码、传感器等技术进行数据采集;
  • 网络传输层(Connect):依托通信网络和协议,实现可信的信息交互和共享;
  • 应用控制层(Manage):分析和处理海量数据和信息,实现智能化的决策和控制;

三要素

  • 设备联网:通过不同的网络协议和通信标准,实现设备与控制端的连接;
  • 云端分析:提供监控、存储、分析等数据服务,以及保障客户的业务数据安全;
  • 云边协同:云端接受设备上报数据,下发设备管控指令;

云 / 边 / 端协同

云端计算、终端计算和边缘计算是一个协同的系统,根据用户场景、资源约束程度、业务实时性等进行动态调 配,形成可靠、低成本的应用方案。从过去几年的发展积累来看,AI 已在物联网多个层面进行融合,比我们合作的海康威视、旷视宇视、商汤科技等纷纷发布了物联网AI相关平台和产品,和移动和小区进行了紧密的融合。

物联网平台接入

向下连接海量设备,支撑设备数据采集上云;
向上通过调用云端API将指令下发至设备端,实现远程控制。
上行数据链路
  • 设备建立MQTT长连接,上报数据(发布Topic和Payload)到物联网平台;
  • 物联网平台通过配置规则,通过RocketMQ、AMQP等队列转发到业务平台;
下行指令链路
  • 业务服务器基于HTTPS协议调用的API接口,发布Topic指令到物联网平台;
  • 物联网平台通过MQTT协议,使用发布(指定Topic和Payload)到设备端;

门锁接入

WIFI门锁:非保活 平常处于断电休眠状态,需要MCU 唤醒才能传输和发送数据;
蓝牙门锁:MCU串口对接和SDK对接,近距离单点登录和远距离网关登录;
Zigbee门锁:非保活 但是保持心跳,MCU对接,Zigbee协议控制;
NB-Iot门锁:可以通过公网连接,把门禁变成SAAS服务,MCU;

各种协议

HTTP协议(CS用户上网)
HTTP协议是典型的CS通讯模式,由客户端主动发起连接,向服务器请求XML或JSON数据。该协议最早是为了适用web浏览器的上网浏览场景和设计的,目前在PC、手机、pad等终端上都应用广泛,但并不适用于物联网场景。
  • 由于必须由设备主动向服务器发送数据,难以主动向设备推送数据;
  • 物联网场景中的设备多样,运算受限的设备,难以实现JSON数据格式的解析;
RESTAPI(松耦合调用)
REST/HTTP主要为了简化互联网中的系统架构,快速实现客户端和服务器之间交互的松耦合,降低了客户端和服务器之间的交互延迟。因此适合在物联网的应用层面,通过REST开放物联网中资源,实现服务被其他应用所调用。
CoAP协议(无线传感)
简化了HTTP协议的RESTful API,它适用于在资源受限的通信的IP网络。
MQTT协议(低带宽)
MQTT协议采用发布/订阅模式,物联网终端都通过TCP连接到云端,云端通过主题的方式管理各个设备关注的通讯内容,负责将设备与设备之间消息的转发。
适用范围:在低带宽、不可靠的集中星型网络架构(hub-and-spoke),不适用设备与设备之间通信,设备控制能力弱,另外实时性较差,一般都在秒级。协议要足够轻量,方便嵌入式设备去快速地解析和响应。具备足够的灵活性,使其足以为 IoT 设备和服务的多样化提供支持。应该设计为异步消息协议,这么做是因为大多数 IoT 设备的网络延迟很可能非常不稳定,若使用同步消息协议,IoT 设备需要等待服务器的响应,必须是双向通信,服务器和客户端应该可以互相发送消息。
AMQP协议(互操作性)
用于业务系统例如PLM,ERP,MES等进行数据交换。
适用范围:最早应用于金融系统之间的交易消息传递,在物联网应用中,主要适用于移动手持设备与后台数据中心的通信和分析。
XMPP协议(即时通信)
开源形式组织产生的网络即时通信协议。被IETF国际标准组织完成了标准化工作。
适用范围:即时通信的应用程序,还能用在协同工具、游戏等。
XMPP在通讯的业务流程上是更适合物联网系统的,开发者不用花太多心思去解决设备通讯时的业务通讯流程,相对开发成本会更低。但是HTTP协议中的安全性以及计算资源消耗的硬伤并没有得到本质的解决。
JMS (Java消息服务)
Java消息服务(Java Message Service)应用程序接口,是一个Java平台中关于面向消息中间件(MOM)的API,用于在两个应用程序之间,或分布式系统中发送消息,进行异步通信。
Zigbee协议
低功耗,它保持IEEE 802.15.4(2003)标准
IOT流量洪峰
智慧社区IOT领域,不管是嵌入式芯片还是应用服务器都需要传递消息,常见上行的消息有:人脸识别开门、烟感雾感告警、共享充电桩充电,下行的广告下发、NB门禁开门指令、超级门板显示等,由于物联网设备时不时会故障和断网导致大量的流量洪峰,传统消息队列需要针对性优化。
  • 上下行拆分
    上行消息特征:并发量高、可靠性和时延性要求低
    下行消息特征:并发量低、控制指令的成功率要求高
  • 海量Topic下性能
    Kafka海量Topic性能会急剧下降,Zookeeper协调也有瓶颈
    多泳道消息队列可以实现IoT消息队列的故障隔离
  • 实时消息优先处理
    NB门禁实时产生的开门指令必须第一优先级处理,堆积的消息降级
    设计成无序、不持久化的,并与传统的FIFO队列隔离
  • 连接、计算、存储分离
    Broker只做流转分发,实现无状态和水平扩展
    计算交给Flink,存储交给nosqlDB,实现高吞吐写
  • 消息策略-推拉结合
    MQTT针对电池类物联网设备,AMQP针对安全性较高的门禁设备
    消费端离线时存到queue,在线时将实时消息和从queue中拉取的消息一起推送
图片来源于网络
如果解决海量Topic
首先要做的就是分区、分组等水平拆分的方式,接下来考虑单实例如何处理更多Topic,传统消息队列在海量Topic下顺序写会退化成随机写,性能大幅下降
  • 人工Sharding:部署多个Kafka集群,通过不同mq连接来隔离;
  • 合并Topic,客户端封装subTopic。比如一个服务的N个统计项,会消费到无关消息;
    基于这个思路,使用Kafka Streams或者Hbase列存储来聚合;
针对单个Topic海量订阅的问题,可以在上层封装广播组件来协调批量发送。
图片来源于网络
社区直播带货
使用端 / 边 / 云三级架构,客户端加密传输,边缘节点转发、云侧转码并持久化。

产品的背景

上线时间,从调研到正式上线用了 3个月时间,上线后一个月内就要经历双十二挑战。在这么紧的上线时间要求下,需要用到公司提供的所有优势,包括cdn网络,直播牌照等。

面临的挑战

  • 直播数据是实时生成的,所有不能够进行预缓存;
  • 直播随时会发生,举办热点活动,相关服务器资源需要动态分配;
  • 直播的延迟对于用户体验影响很大,需要控制在秒级;
  • 直播sdk是内嵌在社区应用里的,整体要求不能超过5M;

协议的比较

整体流程

RTMPS:基于TCP实时传输消息协议,更安全更可靠
MPEG-DASH:是一种基于HTTP协议自适应比特率流媒体技术,应对复杂的环境
  • 直播端使用 RTMPS 协议发送直播数据到边缘节点(POP)
  • POP 使用RTMP发送数据到数据中心(DC)
  • DC 将数据编码成不同的清晰度并进行持久化存储
    云端转码主要有两种分辨率400x400 和 720x720.
  • 播放端通过 MPEG-DASH / RTMPS 协议接收直播数据
    如果用户网络不好MPEG-DASH会自动转换成低分辨率

直播流程

  1. 直播端使用 RTMPS 协议发送直播流数据到 POP 内的就近的代理服务器;
  2. 代理服务器转发直播流数据到数据中心的网关服务器(443转80);
  3. 网关服务器使用直播 id 的一致性哈希算法发送直播数据到指定的编码服务器;
  4. 编码服务器有几项职责:
    • 4.1 验证直播数据的格式是否正确;
    • 4.2 关联直播 id 以及编码服务器第一映射,保证客户端即使连接中断或者服务器扩容时,在重新连接的时候依然能够连接到相同的编码服务器;
    • 4.3 使用直播数据编码成不同解析度的输出数据;
    • 4.4 使用 DASH 协议输出数据并持久化存储;

播放流程

  1. 播放端使用 HTTP DASH 协议向 POP 拉取直播数据;
  2. POP 里面的代理服务器会检查数据是否已经在 POP 的缓存内。如果是的话,缓存会返回数据给播放端,否则,代理服务器会向 DC 拉取直播数据;
  3. DC 内的代理服务器会检查数据是否在 DC 的缓存内,如果是的话,缓存会返回数据给 POP,并更新 POP 的缓存,再返回给播放端。不是的话,代理服务器会使用一致性哈希算法向对应的编码服务器请求数据,并更新 DC 的缓存,返回到 POP,再返回到播放端;
收获
  1. 项目的成功不,代码只是内功,考虑适配不同的网络、利用可利用的资源;
  2. 惊群效应在热点服务器以及许多组件中都可能发生;
  3. 开发大型项目需要对吞吐量和时延、安全和性能做出妥协;
  4. 保证架构的灵活度和可扩展性,为内存、服务器、带宽耗尽做好规划;
直播高可用方案
网络可靠性:
  • 根据网络连接速度来自动调整视频质量;
  • 使用短时间的数据缓存来解决直播端不稳定,瞬间断线的问题;
  • 根据网络质量自动降级为音频直播以及播放;
惊群效应:
  • 当多个播放端向同一个 POP 请求直播数据的时候,如果数据不在缓存中;
  • 这时候只有一个请求 A 会到 DC 中请求数据,其他请求会等待结果;
  • 但是如果请求 A 超时没有返回数据的话,所有请求会一起向 DC 访问数据;
  • 这时候就会加大 DC 的压力,触发惊群效应;
  • 解决这个问题的方法就是通过实际的情况来调整请求超时的时间。这个时间如果太长的话会带来直播的延迟,太短的话会经常触发惊群效应(每个时间窗口只允许触发一次,设置允许最大回源数量);
性能优化方案
数据库优化: 数据库是最容易成为瓶颈的组件,考虑从 SQL 优化或者数据库本身去提高它的性能。如果瓶颈依然存在,则会考虑分库分表将数据打散,如果这样也没能解决问题,则可能会选择缓存组件进行优化。
集群最优:存储节点的问题解决后,计算节点也有可能发生问题。一个集群系统如果获得了水平扩容的能力,就会给下层的优化提供非常大的时间空间,由最初的 3 个节点,扩容到最后的 200 多个节点,但由于人力问题,服务又没有什么新的需求,下层的优化就一直被搁置着。
硬件升级:水平扩容不总是有效的,原因在于单节点的计算量比较集中,或者 JVM 对内存的使用超出了宿主机的承载范围。在动手进行代码优化之前,我们会对节点的硬件配置进行升级。
代码优化:代码优化是提高性能最有效的方式,但需要收集一些数据,这个过程可能是服务治理,也有可能是代码流程优化。比如JavaAgent 技术,会无侵入的收集一些 profile 信息,供我们进行决策。
并行优化:并行优化是针对速度慢的接口进行并行调用。所以我们通常使用 ContDownLatch 对需要获取的数据进行并行处理,效果非常不错,比如在 200ms 内返回对 50 个耗时 100ms 的下层接口的调用。
JVM 优化: JVM 发生问题时,优化会获得巨大的性能提升。但在 JVM 不发生问题时,它的优化效果有限。但在代码优化、并行优化、JVM 优化的过程中,JVM 的知识却起到了关键性的作用。
操作系统优化:操作系统优化是解决问题的杀手锏,比如像 HugePage、SWAP、“CPU 亲和性”这种比较底层的优化。但就计算节点来说,对操作系统进行优化并不是很常见。运维在背后会做一些诸如文件句柄的调整、网络参数的修改,这对于我们来说就已经够用了。
流量回放自动化测试
系统级的重构,测试回归的工作量至少都是以月为单位,对于人力的消耗巨大。一种应对方案是,先不改造,到系统实在扛不住了再想办法。另一种应对方案是,先暂停需求,全力进行改造。但在实际工作场景中,上述应对策略往往很难实现。
场景:
1、读服务均是查询,它是无状态的。
2、不管是架构升级还是日常需求,读服务对外接口的出入参格式是没有变化的。
图片来源于网络
  • 日志收集,主要作用是收集被测系统的真实用户请求,基于一定规则处理后作为系统用例;
    Spring 里的 Interceptor 、Servlet 里的 Filter 过滤器,对所有请求的入参和出参进行记录,并通过 MQ 发送出去。(注意错峰、过滤写、去重等)。
  • 数据回放是基于收集的用例,对被测系统进行数据回放,发起自动化测试回归;
    离线回放:只调用新服务,将返回的数据和日志里的出参进行比较,日志比较大
    实时回放:去实时调用线上系统和被测系统,并存储实时返回回放的结果信息,线上有负担。
    并行回放:新版本不即时上线,每次调用老版本接口时概率实时回放新版本接口,耗时间周期。
  • 差异对比,通过差异对比自动发现与预期不一致的用例,进而确定 Bug。
    采用文本对比,可以直观地看到哪个字段数据有差异,从而更快定位到问题。正常情况下,只要存在差异的数据,均可认为是 Bug,是需要进行修复的。
方法论
Discovery
考虑企业战略,分析客户需求,制定产品目标
由外到内:竞争对手的方案,为什么做,以后怎么发展,如何去优化。
自上而下:基于公司的战略,考虑自身能力和所处环境。
自下而上:从资源、历史问题、优先级出发,形成一套可行性实施方法。
Define
基于收集的信息,综合跨业务线的抽象能力和服务,先做什么后做什么,怎么做
设计新的架构,重点设计解决痛点问题。
拆分业务领域,重点划分工作临界上下文。
Design
详细的业务设计,功能设计,交付计划,考核计划
产品愿景,产品形态,相关竞品方案对比,价值、优势、收益
梳理业务范围,要知道电商领域四大流(信息流、商流、资金流、物流)
MVP最小可用比,让客户和老大看到结果,最后通编写story把故事编圆
Delivery
交付阶段,根据反馈及时调整中台战略,减少损失和增大收益
合理制定每个阶段的绩效考核目标:
40%稳定+25%业务创新+20%服务接入+15%用户满意度
【这些年背过的面试题】系列文章欢迎点击阅读原文查看合集!
继续阅读
阅读原文