常见的业务场景解决方案整理

记录一些自己遇到过的,感兴趣的业务场景的解决方案,不定期更新

[TOC]

系统设计的思路

缓存、异步、多线程、分片、高可用、扩容、分布式集群、并发、锁不锁、对持久化、内存的要求、量大的就想办法把量过滤或者拆小,分治处理、对数据库的写操作量很大时,可以考虑时间窗口合并

API协议设计

其实分成了API和协议两部分

  • 一般API会符合Restful规范,由行为 + 资源组合而成;

  • 协议一般就包含了请求/响应头和响应/请求体的内容,参数结构化,比如参数类型是Hash,就不要存成String,值是Hash的序列化后的字符串;

  • 响应结果要统一,尽量不要因为参数的不同而返回不同类型的响应结构,个人比较喜欢的做法是,在网络层面,如果接口没有问题,统一返回HTTP code是200,响应体里分为code、msg、data来表示业务响应;

  • 需要考虑认证和安全相关,比如是否需要签名、票据、token等

  • 多服务之间,保证风格一致;

  • 考虑幂等;

  • 加入版本控制,加在URL上,或者请求头有个字段标识;

  • 接口职责单一,一个接口只干一件事;

  • 考虑兼容性;

解决多重if-else嵌套问题

比如有下面这种代码,优化if-else问题

func Handle() error {
       var err error
       if Operation1(){
             if Operation2(){
                    if Operation3(){
                           if Operation4(){
                                 // do
                           }else{
                                 err = OPERATION4FAILED
                           }
                    }else{
                           err = OPERATION3FAILED
                    }
             }else{
                    err = OPERATION2FAILED
             }
       }else{
             err = OPERATION1FAILED
       }
       return err
}

或者

func Handle() error {
       var err error
       if Operation1(){
             // do something
       } else if Operation2() {
       		// do something
       } else if Operation3() {
       		// do something
       } else if Operation4() {
       		// do something
       } else {
             err = OPERATION1FAILED
       }
       return err
}

解决方案:

  • 分析条件,对于互斥的条件,可以使用表驱动,key是入参,value是方法

  • 短路条件,尽量平铺 if-else 判断,比如 把 if-else 拆分,提前返回

  • 使用switch-case

  • 比如对于多重嵌套的if-else,嵌套条件必须同时成立,可以使用责任链设计模式,将多个条件封装成方法,装进数组或链表中,遍历判断

  • 策略模式 + 工厂模式,抽象一个公共接口,所有条件判断封装成对象,实现这个接口;定义工厂方法,根据参数获取对应的策略对象,执行对应的判断函数,返回判断后的结果

防止表单重复提交

场景:用户点击下单页面,跳转至下单页面,提交订单,此时有可能网络原因或者用户点击多次,导致订单重复提交。

解决:用户跳转至下单页前,会先获取订单号(也作为订单表主键),将订单号绑定在下单页,利用数据库主键唯一的特性,让创建订单的操作变成幂等性。

也可以前端在同一个页面发送订单创建请求时,为页面生成一个requestId,重复下单后端即可通过这个id判重;

订单创建接口,也可以对参数进行排序后MD5的方式判重;

订单场景还好,如果是IM场景,因为每个用户发送信息都比较频繁,如果都每条信息都创建一个请求id,可能每太必要,后端可以对每条信息+user_id进行MD5,判重,但需要处理用户真的重复发送的情况;

还有一种处理方式,为每条消息生成一个时间戳,服务端处理时判断上下两条消息的时间差

解决ABA问题

**场景:**类似MySQL的丢失更新,比如有操作1,操作2先后对记录A进行更新,操作1的响应丢失导致重试,此时操作2已经更新成功,操作1重试时会覆盖操作2的更新。

**解决:**通过版本号解决,订单表增加一列作版本号,版本号可以使用递增序列、时间戳等,通过比较版本号来确定操作的先后顺序,更新成功时也需要更新版本号。

流量大、数据量大的商品详情页数据存储

**场景:**一般商品详情页都是访问量最大的页面,比如用户做商品对比、查看商品详情都需要,另外就是商品详情页一般涉及很多数据,如下,且后端存储的sku量也是巨大的,直接分多张表去存虽然可以实现,但是性能就一般了。

商品
├── 基本信息
│    ├── 标题、副标题
│    ├── 价格:原价、促销价
│    └── 颜色、规格等
├── 商品参数
├── 商品介绍
├── 图片视频
来自其他系统的
├── 促销信息
├── 推荐商品
├── 评论、评价
├── 配送信息
└── 店铺信息

**解决:**分析不同的数据特性,比如有些数据是热点的、相对固定的、不常被修改的、需求变化不大的等各种维度去划分,进行不同存储。动态数据、实时数据还是照旧,该怎么处理怎么处理,其他的可以:

  1. 套一层缓存在数据库外面,查询数据先缓存后数据库

  2. 针对每个不同的spu有不同的商品属性,则可以使用NoSQL来解决

  3. 针对图片、视频等数据,使用对象存储、CDN解决,比如AWS S3,直接通过其提供的API进行访问,将这部分的压力转移到云服务厂商

  4. 将相对固定的数据静态化,比如商品介绍,其包含了大量的文字、图片、视频等,可直接将这一部分保存成HTML文件中,访问时直接返回HTML文件,保存成HTML还可以配合CDN进行加速

查询方面的优化

  • 可以起一个SQL检查脚本,检查执行时间过长的SQL,如果超过指定时间的,进行记录和kill,再进行优化,把慢SQL解决掉,避免多个执行时间过长的SQL拖垮整个数据库。

  • 主从分离,读写分离,服务降级

  • 分析SQL执行和访问量间的关系,数据库CPU利用率变化

  • MySQL单次查询扫描的数据量控制在千万级别内,单次扫描的数据量在百万级别是可以接受,理论上查询都应该使用索引,避免全表扫描

对象存储原理

  • 本质是一个规模很大的分布式Key-value集群,外加一个保存集群节点信息、文件信息和映射关系(统称为元数据)的节点集群,在最外层再加上一个Gateway来对外提供服务即可。

  • 针对图片、视频等大文件,在存储时会将其拆分成多个大小相等的块Block,一般是几十KB到几MB,便于管理,也可以分散到不同节点,提升并行读写性能。

  • 由于分成的块太小,数量多,一般也不是直接进行管理的,而是将一些块进行聚合,放到容器里,类似分片的概念,主从复制时,也是直接复制这些块即可,不用再复制多日志

跨系统数据实时同步

  • 采用Bin Log + MQ的方式,将上游数据实时同步到下游其他系统的数据库中,为了确保数据一致性,必须顺序读取Bin Log,因此MQ的主题也必须设置为只有一个分区,才能保证Bin Log有序。

  • 当下游系统想要扩展消费能力时,不可盲目增加同步线程数和MQ主题分区,由于Bin Log的顺序性,要确保多线程消费时,不会对数据产生影响,所以可以将具有因果一致性的Bin Log发布给同一主题分区,才可以多线程同步消费。具体可参考MySQL 5.6版本后多线程处理Bin Log的做法。

不停机情况下更换数据库

  • 利用Bin Log或者复制状态机理论,增加一个新库和同步服务。先将旧库上的数据快照同步到新库,对于旧库的新数据,使用同步服务进行同步复制

  • 改造旧服务,增加双写新旧两个库的功能,添加功能开关,使其能够只写旧库、只写新库、同步双写的功能

  • 开关打至只写旧服务,利用同步服务同步数据,等改造后的旧服务能稳定运行,验证新旧两个库的数据是否一致;一致之后将改造后的旧服务的开关打至同步双写,关闭同步服务,此时仍然以数据写至旧库为主,写新库失败则进行人工干预,此外,双写时可能会存在数据不一致,此时需要针对这一小段时期上线数据对比与补偿服务,验证和补充新旧数据不一致问题;待最终稳定后,才将开关打至只写新服务,实现数据库替换的平滑过渡。

海量数据处理

针对的是埋点数据、日志数据、访问数据、点击数据、监控数据等,一般采用先存储后计算的方式

  • 使用Kafka存储,上游系统将海量数据采集后发给KafKa,利用Kafka无限消息堆积和超高吞吐,存储数据,再由下游系统进行订阅消费即可。这种方案适合短时间的海量数据处理。关键词:分布式流数据存储。

  • HDFS存储 + Hive查询 或者 ES查询

  • 针对监控数据,可以使用时序数据库,例如Prometheus

大文件在有限内存内排序

多路归并排序:将所有原文件按照内存最大大小读取并进行排序,输出到新的、多个的文件中,然后对这些新的已排序的小文件进行归并排序。

https://www.cnblogs.com/GarrettWale/p/14478347.html

库存

这里仅讨论分布式场景下的

  • 库存字段设置为无符号整型,这样在扣到负数时会报错,不过得看具体的数据库实现

  • 悲观锁,利用数据库的锁机制:比如MySQL的 for update

    begin; -- 开启事务
    select inventory from product where sku_id = '1' for update; -- 获取并设置排他锁
    update product set inventory = inventory - 扣减数量  where sku_id = '1';
    commit; -- 提交事务

    但这种方案会把压力转移到数据库,吞吐量并不高。

  • 乐观锁,版本号 + cas + 自旋,也可以利用数据库的能力实现

    update product set inventory = inventory - 扣减数量  where sku_id = '1' and inventory > 扣减数量;

    或者

    update product set inventory = case when inventory >= 扣减数量 then inventory - 扣减数量 else inventory end;

    如果要解决ABA问题,还需要先查询版本号,获取到版本号后,在应用层自旋,执行以下语句

    update product set inventory = inventory - 扣减数量 , version = version + 1 where sku_id = '1' and version = 7;

    如果更新数据库层面有重试机制,那在set的时候就不能使用inventory = inventory - 扣减数量这种方案了,因为这种操作是不幂等的,重试会导致重复扣减,所以得先计算出扣减完之后的inventory值,在set才能保证更新时幂等。

    没办法在开启事务后,通过select语句 + in share mode + update语句来实现,虽然 in share mode不会阻塞其他请求的读请求,但是会导致第二个在更新的时候有问题,比如阻塞或者更新错误,除非加上库存余量判断,但是如果加上更新余量的判断,还不如一开始就直接使用这个去扣减库存。

    在这个场景下,使用这两种锁的差别,像排他锁,比较严格,锁的条数会比较多,可能会影响其他请求的读写,而共享锁,虽然锁完不影响读,适合读多写少的场景,但是也可能会导致业务方面的问题。

  • (建议使用,其他方案都不建议,数据库方案是性能比较差,分布式锁方案是可靠性比较差)库存扣减主要是包含两部分,超卖校验、扣减数据的持久化,因此,可以分离这两部分,只要超卖校验能通过,直接改库即可,可以利用Redis单线程操作并发安全的能力,把库存存在Redis中实现校验。

    比如每次扣减库存时,对存在Redis中的数据进行decrby扣减(或者先查询后扣减,需要使用Lua脚本保证原子),如果返回的数量大于0,说明库存够,之后可以同步或异步把值更新到数据库即可,但要注意的是,本方案不支持回滚;

    lua脚本的方案,预先将脚本存到redis,调用时只需传入返回的sha1的值即可,无需每次都传一遍lua脚本
    -- 查询活动库存,其中KEYS[1]为传入的参数1,即库存key
    local c_s = redis.call('get', KEYS[1])
    -- 判断活动库存是否充足,其中KEYS[2]为传入的参数2,即当前抢购数量
    if not c_s or tonumber(c_s) < tonumber(KEYS[2]) then
       return 0
    end
    -- 如果活动库存充足,则进行扣减操作。其中KEYS[2]为传入的参数2,即当前抢购数量
    redis.call('decrby',KEYS[1], KEYS[2])
  • 分布式锁实现,应用层对sku_id上分布式锁,再进行库存判断

  • 库存读通过Redis缓存 + MySQL bin log + 定时任务更新 来解决,库存写通过在 先查出库存值 + 一秒内窗口合并 + 优先级队列(用于处理大数值库存扣减) + version乐观锁扣减库存 + 重试,合并失败的时候就会按时间窗口内按顺序扣减 实现;时间窗口设计,主要解决时间窗口内数据库频繁操作对缓存的影响。

  • 实际库存和冻结库存,不过这个属于业务上的处理,从实际库存转移到冻结库存仍然有可能出现超卖的情况,这种一般是为了解决在下订单直接扣库存,可能会导致被恶意下单,或者在支付时扣库存扣失败了但是订单仍然存在,属于用户体验上的问题了,所以才衍生出在下订单时将实际库存值扣减,加到冻结库存,支付时才真正的减库存,否则在过期时再把冻结库存补回实际库存,本质上是TCC。

秒杀

秒杀主要是要解决两个问题:并发读 和 并发写,还有就是针对意外有一些兜底的方案,比如高可用,另外就是针对这种高并发的场景,最好的做法就是削减请求数,在业务流程的前端尽可能的拦截流量、削峰来达到降低后端的压力。

架构原则

  • 响应的数据要尽量少:比如返回的HTML页面、接口数据,因为涉及到编码、序列化、反序列化、数据库的操作等,会比较消耗资源;

  • 请求数要尽量少:请求数一多就会产生很多连接,一个请求可以获取完所有数据,就不要分多次获取,像一些CSS文件和JS文件可以合并获取;

  • 请求经过的服务或者依赖的服务要尽量少:请求每经过一个服务,都会增加不可靠性,也会产生网络连接,消耗资源;依赖服务支持被降级,以此减少对主流程的影响;

  • 避免单点,支持动态扩容:服务最好是无状态设计,方便迁移到不同配置的机器,也方便动态扩容数量;

针对秒杀场景:

  • 将秒杀服务独立,避免对其他服务的影响

  • 对热点数据(如库存、商品)进行缓存,提高读性能

  • 秒杀答题,防止秒杀器抢单;限流保护,对后端进行保护

  • 对秒杀页面进行动静分离,用户刷新仅刷新部分模块

动静分离方案

  • 静态数据可以缓存到CDN、用户浏览器、服务端缓存,甚至可以以URL为key,缓存整个HTTP响应;

    缓存静态数据时要注意几个点:

    • 分离跟浏览器相关的数据,比如是否已登录、登录的身份等,这些数据可以通过请求获取

    • 分离时间,服务端输出的时间也通过动态请求获取

    • 异步化地域因素,页面上跟地域相关的数据也通过动态请求获取

    • 去掉cookie,缓存的静态数据不能包含有cookie

  • 动态数据处理有两种方案:

    • ESI方案:服务端渲染,由服务端将静态数据与动态数据结合完成之后返回给客户端

    • CSI方案:前端渲染,前端ajax请求获取json数据,再进行整合呈现

  • 缓存的存储:

    • 本机内存:好处是没有网络、序列化等带来的损耗,坏处是缓存管理不便,会加大服务的内存使用

    • 统一的Cache层:好处是管理方便,减少多应用接入Cache的成本,共享内存也能最大化利用到内存,坏处是会带来网络损耗,Cache机器的宕机会导致缓存不可用

    • CDN:使用CDN要注意几个问题:比如缓存失效时间问题,数据分散到不同地区的CDN带来的命中率问题,还有就是当数据有更新时,如何快速分发到不同地区的CDN;

      一般来说,只有那些靠近访问量比较集中的地区,离主站比较远,CDN节点到主站的网络比较稳定的CDN节点才适合放缓存数据,像商品详情的数据,就适合放到CDN的二级Cache,让用户请求先回源CDN的二级Cache,没命中再回源主站获取数据比较合适

      使用CDN有几个好处,可以把整个页面缓存在用户浏览器中,强制刷新页面也会请求CDN,所以静态数据比较适合放到CDN上面

热点数据 与 缓存

热点分为 热点操作 和 热点数据,热点数据又分为静态热点数据和动态热点数据

  • 静态热点数据

    指能够被提前预测的数据,比如提前得知要秒杀的商品,或者分析用户行为、订单记录、购物车记录、TopN被搜索的商品等,判断哪些有可能是热点商品,给这些商品打上标签;

    对于静态热点数据,可以直接缓存

  • 动态热点数据

    指不能被提前预测,在系统运行过程中临时产生的数据,比如某商品因为某条短视频火了,导致它在短时间内被大量抢购,这种商品的发现一般是异步分析,通过分析商品被访问的路径的次数,提前识别哪些商品的访问量高,来给这些商品打上标签;

    对于动态热点数据,由于其临时性,可以采用LRU,或者对动态热点数据缓存分片解决;

  • 对于这些热点数据,可以把它们与普通的数据进行隔离,比如系统隔离,将参加秒杀活动的请求导向不同的域名,指向不同的集群,处理这些热点数据的服务独立部署(但实际上不会把整套系统都部署,这样成本太大,独立部署这些服务后,对于公共服务就需要有能区分请求分发的逻辑了,比如根据商品id,知道它是秒杀商品,就能把它转发给专门处理秒杀的服务);数据隔离,为这些热点数据单独设立Cache层和数据库等,避免影响普通业务流程。

流量削峰

流量削峰主要是为了让服务端处理变得更加平稳,节省服务器的资源,通过延缓用户请求的发出,减少和过滤一些无效的请求,避免后端服务一下子处理很多请求。

  • 消息队列,FIFO处理请求;请求序列化成文件,再按顺序读取;这里排队主要是针对服务间请求的排队,而不是用户请求直接经过消息队列。

  • 用户操作延缓:比如购买时增加答题机制,验证码等,通过将发起请求的操作拉长,避免短时间内大量请求;

  • 分层过滤

    • 将动态请求的读数据缓存在Web端,过滤掉无效的数据;

    • 对请求进行校验,如判断用户是否具有秒杀资格、商品状态是否正常、秒杀活动是否结束、请求是否非法,判断对用户是否限购等;

    • 对读数据不做强一致校验,减少因校验产生的瓶颈问题;

    • 对写数据进行基于时间的合理分片,过滤掉过期的失效请求;

    • 对写数据进行限流保护,将超出系统承载能力的请求过滤掉;

    • 对写数据进行强一致校验,比如库存,只保留有效数据;

秒杀中的减库存

减库存一般有三种模式:下单成功减库存;支付成功时减库存;下单预扣库存,支付真正减库存;

下单成功减库存方式逻辑比较简单,性能也比较占优势,如果是追求极致性能的秒杀活动可以选择这种模式。

秒杀活动中,库存是一个热点数据,交易的各个环节都涉及到对库存的查询,针对读请求,可以不需要那么精确,把库存数据放到缓存里可以大大提升查询性能,对库存的处理主要是难在写操作。

如果没有复杂的sku库存和总库存的联动关系,扣库存逻辑完全可以在缓存里执行,如果要用到事务,还是得在数据库中执行。

为了避免对数据库中其他正常业务逻辑的影响,一般会把秒杀商品独立成库,针对商品id进行分表分库,尽量减少锁带来的影响,关键服务独立部署,对于无需独立部署的服务,可以根据商品id进行hash分组转发到独立部署的服务,避免影响其他业务。

然后就是要解决并发锁的问题,一种是在应用层,按照商品维度设置队列顺序执行,减少同一台机器对数据库同一行记录进行操作的并发度,同时也能控制单个商品占用数据库的连接数,防止热点商品占用太多数据库连接;另一种是在数据库层面排队,对单行记录做并发排队。

剩下的就跟上面库存的方案差不多了

兜底方案

  • 服务降级:比如当秒杀的TPS达到设定的量级之后,成交记录的获取展示从20条降级到只展示5条,不相干的业务服务临时不展示,只保证核心业务线的正常流转

  • 限流、熔断

短链

本质上就是给长网址发号,映射成短网址,访问时通过301跳转;

短码一般都由[a-zA-Z0-9]这62个字符组成,一般不超过8位,6位的短码就可以有(26+26+10)^6 = 56800235584,568亿个了。

短码生成方案:

  • 利用数据库生成自增id,但因为有序,容易被遍历出来,不过可以通过对生成的自增id做签名或者62位进制生成最后的短码解决;

    或者使用多个Redis自增,只要将自增的步长设置成不一样就不会产生冲突了,只是要维护数据的持久化和奔溃恢复;

    或者发号器预生成一批序号,预生成主要是解决冲突问题,来一个长网址就分配一个;

    或者直接UUID、雪花算法生成短码;

    或者利用数据库唯一索引,自己通过随机数生成短码实现;

  • 摘要算法生成,比如对长网址直接md5,哈希等,不过要解决冲突,比如利用数据库唯一索引,冲突的时候可以在原始字符串上加上预定义的字符串,重新哈希存入判断,直到插入即可;

常用的方案是使用 自增id + 进制转换的方式进行,可以直接数据库自增,也可以用发号器生成id、分布式id等;

生成短码之后,将 原始长网址、短码、短网址、有效时间 存入数据库,之后根据映射进行跳转即可;

提升查询性能就上缓存,全量保存不太现实,可以只保存最近一段时间的,然后LRU淘汰即可;

长链转短链可以一对一也可以一对多,一对一的话可以判断库里是否存在时可以使用布隆过滤器;

关于跳转:

301是永久重定向,302是临时重定向。短地址一经生成就不会变化,所以用301是符合http语义的。同时浏览器会对301请求进行缓存,可以减轻服务器压力。

使用301有利于SEO, Google,百度等搜索引擎,搜索的时候会直接展示真实地址,但此时就无法统计到短地址被点击的次数了,也无法收集用户的Cookie,User Agent 等信息,这些信息可以用来做很多有意思的大数据分析,也是短网址服务商的主要盈利来源,如果需要利用这些信息,就得使用302。

海量数据计数

比如点赞数、评论数、转发数、浏览量、粉丝数、关注数等

以微博为例,存储时可以根据微博Id进行分库分表,比如哈希划分或者时间戳划分,如果存储结构为 微博Id、点赞数、评论数、转发数、浏览量等,虽然一次查询可以查出,但是在写入的时候,由于各个字段的计算是独立计算的,写入时就会有锁竞争,导致写入性能不佳;

写入时可以使用MQ削峰,慢慢消费计数,更新数据;

仅依靠数据库+缓存的方式能承载的量比较有限,也可能产生数据不一致,倒不如直接使用多节点Redis来实现计数 + 查询,主从保证高可用;

计数仍然会存在存储问题,对于热点数据,直接Redis内存即可,对于非热点数据或时间救援的数据,可直接使用磁盘;定制Redis数据结构,设计存储空间更小的结构;

对于未读计数:比如一系统通知列表,记录全量用户未读数,如果全量用户,每个用户都去计算未读数是多少,那效率太差了,正确的做法是:因为这个列表对所有用户共享,所有用户都能看到这一份系统通知数据,不同的人最近看到的消息不同,所以每个人就会有不同的计数,因此可以通过记录每个人看过的最后一条消息的ID,推断出该ID之后还有多少消息未读;

用户红点计数方案:判断用户是否点过某一个页面或功能,可以为每一个用户存储一个时间戳,代表最近点过这个红点的时间,用户点了这个红点,就更新这个时间戳,然后我们再记录一个全局时间戳,通过这个全局时间戳与用户点击的时间戳比较,判断是否要展示红点;

基于关系的信息流未读方案:通用计数器记录每一个用户发布的博文数,在Redis中记录一个人所有关注人的博文数快照,当用户点击未读消息时重置未读数为0时,将他关注所有人的博文数更新到快照中,此时,他关注的所有人的博文数减去快照中的博文数就是他信息流的未读数了,不过此方案会存在一点误差。

计数器Redis实现

  • 记录和更新

    使用hash记录计数值,key为计数器名称:时间精度,field是一个时间戳,表示当前时间片开始时间(计算方式 当前时间 / 时间精度 * 时间精度),hash value是计数值;命令:hincrby counter:5 1682045703000 1,表示统计5秒内的计数

    因为要保存所有的时间精度,又需要保证不会重复,所以使用zset用于清除旧数据,key为随便起,是一个定值,value为时间精度+计数器名称,socre为0,命令:zadd countSort 0 counter:5,socre设置为0是为了让zset只根据value按字母序排列,方便后续获取

  • 查询:hgetall 计数器名称:时间精度,然后排序遍历,即可获取各个时间片的计数

  • 清除旧值

    设定一个异步任务,对于每秒更新一次或每5秒更新一次的计数器,异步任务可以按每分钟的频率清除旧数据,从而保证只存储一定的数量,避免bigkey问题

    比如分页遍历 zset,根据value,查 hash,然后移除hash里失效时间范围内的元素;由于存在覆盖问题,如果要保证计数正确,需要使用lua脚本,保证整个过程原子,如果不care,直接程序处理即可。

存储统计数据Redis实现

场景:统计前一小时和当前一小时内的数据的最值

  • 记录和更新

    使用 两个string 记录当前小时数、值和上一个小时值,比如 上一个时间是 12:23 ,存储命令就是 set lastHour 12

    使用两个zset保存临时的统计数据,使用zset并非为了按socre进行排序,而是要对这两个zset做并集和聚合运算,使用redis的内置函数,使用命令zunionstore + 聚合函数,比如max()、min()

    将两个临时保存统计数据的zset做一定的运算后得到最终结果zset,然后删除临时的两个zset;

feed流

推模式 - 写扩散

用户发送一条微博,主动将这条微博推送给他的粉丝,即,为该用户和其粉丝insert一条微博数据,粉丝查看微博时,直接一条sql即可查询得到;因为写入压力和存储压力问题,比较适合粉丝数有限的场景;

存在的问题

  • 如果用户的粉丝很多,尽管可以异步或消息队列为其粉丝写入微博数据,仍然会存在一定的延迟,可能出现该用户发了微博之后,其部分粉丝要延迟很久才能看到数据;

  • 对于全平台可见的微博,如果全量为所有用户都insert一条微博数据,数据量太大,如果每个用户下还有分组,写入量就更大了,写入性能会收到很大的影响,存储成本很高;

    尽管只需要维护id的关系映射,但还是架不住量多,微博的展示还是根据id去数据库或缓存里查询;

  • 如果发生删除微博、用户取关操作,对应的微博需要删除,也会产生大量写操作;

解决方案

  • 写入时通过消息队列消费,并行写入,加快写入速度;

  • 存储时使用压缩率高的数据库/存储引擎,比如MySQL的TokuDB(其使用分形树索引,可以将数据的随机写入转成顺序写入,写入速度快,压缩率高,但是删除和查询的性能差写);

  • 写入时分库分表减少压力;定期清理时间太久的数据,转移到其他表中,减少存储压力;

  • 如果发生删除微博、用户取关操作,对应的微博需要删除,压力也会比较大,只能先判断用户是否取关或微博是否删除来判断是否展示即可,尽量减少多余的写操作;

  • 读取时通过缓存读取;

拉模式 - 读扩散

用户主动拉取其关注列表,查询所有人的微博,再将这些微博按发布时间倒叙排列聚合,形成信息流。

相比推模式,拉模式解决了写入延迟以及存储问题

存在的问题:

  • 对查询出的微博做聚合时成本比较高;

  • 查询数据量大,导致缓存查询带宽压力大;

解决方案:

  • 分析用户行为,比如如果大部分用户只查看最近5天的数据,就可以只缓存最近5天的微博id,用于查询聚合;

  • 分散缓存节点,设置多级缓存,查不到再查主缓存

推拉结合模式

  • 对于大V用户,发布微博时,使用推模式,但是不再推送到全量用户,而是给大V用户维护一个定长的活跃粉丝列表 以及 在线用户,只推送 或 优先推送 给这类用户,活跃粉丝列表和用户在线状态会进行定时更新;从而达到可控的推送延迟,不过同时因为要维护活跃粉丝列表和用户状态,又需要更多的存储成本;

    不活跃用户,非在线用户只有上线后主动拉取消息;

  • 普通用户使用拉模式即可;

弹幕

用户进入视频 / 直播间 页面,拉取正在观看视频的用户列表,接收持续发布的弹幕消息,自己发消息。

弹幕要分清是需要实时还是可接受延迟,是直播的时候用,还是视频、录播的时候用,这涉及到弹幕信息要不要保存,一般来说,客户端接收弹幕信息有以下两种方案:

客户端轮询

用户在前端写入消息,消息发往服务端,为消息赋完序号后,直接丢给kafka,下游服务消费kafa的消息,将消息存入Redis(以Zadd的方式写入,score消息的小队时间);

前端每2s向后端发起轮询,后端查询Redis返回;

但是客户端轮询的方式,始终对服务端的压力较大,一般采用websocket长连接的方式进行推送弹幕信息。

Websocket

此方案最佳,CDN + 长连接(websocket) 广播给用户,原理跟聊天室差不多;

与上面的方案差不多,只是把前端轮询部分替换成了由后端与前端建立长连接后进行推送。

常见直播弹幕方案,由于需要实时:用户把弹幕信息发送到弹幕处理服务,对敏感词等进行处理,接收处理后将弹幕信息发送给Kafka等消息队列,使用发布订阅的方式,消费弹幕消息的消费者服务有多种,比如是弹幕存储服务,持久化弹幕数据;比如有长连接服务,消费弹幕消息,根据弹幕消息数据判断弹幕要推送到哪个直播间,量大的时候,长连接服务需要设置分片,控制每台长连接服务与客户端的连接;如果弹幕消息处理不过来,就随机丢,控制客户端接收弹幕接收数量,或者长连接服务限流控制推送给客户端的弹幕数量。

消息的已读未读

先已读未读发送在两个人对话或者多人群里面,当然两个人也可以理解成一个群,一个群对应一个bitmap。在这个群里面,所有人都要对这条消息存在已读未读的情况,即一个群对应多条消息,每条消息对应多个人,每条消息对每个人存在已读未读状态。

数据量小的,直接DB操作即可;

消息量大的,使用Redis的bitmap实现,前面的set可以用来判断哪个用户进群没进群:

第一种:一个群对应一个set,key是群id,value是userId,每个用户对应一个bitmap,key是群id+userId,offset是消息的id,value表示已读未读,或者只记录已读;

第二种:一个群对应一个set,key是群id,value是userId,每条消息对应一个bitmap,key是群id+消息id,offset是userId,value表示已读未读,或者只记录已读;这种内存消耗会少一点

当id作为bitmap的offset时,需要保证平均散落,比如进行哈希后再放到offset里,防止只用到了一部分的offset。

IP与数字的转换

ipv4本质上是32位的二进制字符串,一个int整数刚好是4个字节32位,所以一个int整数可以表示一个ipv4地址。

ip地址 = 第一段左移24位 + 第二段左移16位 + 第三段左移 8位 + 第四段 = 第一段 * 256 * 256 * 256 + 第二段 * 256 * 256 + 第三段 * 256 + 第四段,范围是 0 ~ 4,294,967,295(2^32 - 1)

// ip字符串转整型:
result := 0
ips := strings.Split(ip, ".")
for _, part := range ips {
	partInt, _ := strconv.Atoi(part)
	result = partInt | (result<<8)
}

// 整型转ip字符串:
result := strconv.Itoa((num >> 24) & 0xff) + "." + strconv.Itoa((num >> 16) & 0xff) + "." + strconv.Itoa((num >> 8) & 0xff) + "." + strconv.Itoa(num & 0xff)

根据IP查询国家(比如给定一批数据是 {开始的ip, 结束ip, 国家}),使用Redis做缓存,实现方案:

使用Redis的zset结构,key是定值,比如叫ip2Country,score是IP转数字,value是国家二字码+一定的标识(需要加标识是因为zset的value是唯一),比如如果ip是开始ip,标识就加begin,结束ip就加end,如果一个国家会对应多条IP起始范围,则标识还要加上数字

但是这种方案有个缺点,就是无法处理ip范围重叠的情况,所以最好还是DB存,存ip的字符串,还有转成数字后的开始ip和结束ip

存储时:

比如有一条数据是 2.255.1.40, 5.2.255.255, CN
即ip在以 2.255.1.40 开始,以 5.2.255.255 结束 范围内,对应的国家二字码是 CN
转成数字就是 50266408, 84082687, CN

存到redis时,对应的命令就是
zadd ip2Country 50266408 begin_1_CN
zadd ip2Country 84082687 end_1_CN

查询时:

比如有ip 2.255.1.41,转成数字是 50266409,查询redis时,使用命令
zrevrangebyscore ip2Country 50266409 0 LIMIT 0 1   
# 后面的LIMIT很关键,这个命令时间复杂度是 O(log(N)+M), N是zset的总数量,M是查询到的结果集的数量

该命令表示查询 key为ip2Country的zset,socre从高到低排列,返回socre从0到50266409范围内的元素的第一个元素,所以会返回最接近且小于等于 50266409 的元素,然后进行判断:
1. 如果返回值为空,说明没找到对应的ip范围
2. 如果是start开头,说明找到了对应的国家二字码
3. 如果是end开头,说明没找到对应的ip范围 

扫码登录

参考

极客时间 - 后端存储实战

极客时间 - 秒杀系统设计

Last updated