新增功能:点赞
现在几乎所有的媒体内容,无论是商品评价、话题讨论还是朋友圈都支持点赞,点赞功能成为了互联网项目的标配,那么我们也尝试在评价系统中加入点赞功能,实现为每一个评价点赞。
豆瓣短评中的点赞:
要实现的点赞需求细节:
从放弃出发
完整得实现点赞系统功能是很困难的。要支持亿级的用户数量,又要做到数据归档入库,要支持高峰期百万的秒并发写入,又要实现多客户端实时同步,要记录并维护用户的点赞关系,又要展示用户的点赞列表,这样全方位的需求会产生设计上的矛盾,就像CAP矛盾一样。
典型的比如并发量和同步性的矛盾。高并发的本质是速度,网络传输速度和程序运行速度决定了系统所能承载的容量,每个请求处理速度快才能在单位时间内处理更多的请求,只是一味得增大连接数而忽略请求响应时间,并发问题得不到根本性的解决。在我看来,应用程序内部运行速度的瓶颈在于三处,优先级由高到低是网络请求、对象创建、冗余计算,网络请求对响应速度具有决定性的影响力。但是,同步性又要求我们进行网络请求,比如同步数据到mysql或redis之中。鱼与熊掌不可兼得,并发量和同步性具有不可调和的矛盾。
还有存储容量与访问速度的矛盾。要记录用户的点赞列表,就意味着要长期维护用户的点赞关系,日积月累,用户的点赞关系在单台存储系统中装不下,需要写入分布式存储系统中,这带来了额外的复杂度与调度时延,并且需要很好地设计区分维度,不同分区之间数据不耦合。而一旦一次查询跨越了多个存储节点,就会产生级联调用,具有较大的网络时延。
要实现,先舍弃。看到一个新的需求时,我习惯于反向思考,观察这个需求不涉及到哪些功能,哪些功能可以放弃,从这个角度出发,很容易找到取巧而又简单,却能满足当前需求的设计方案。
重新列一个需求清单,上面写了不需要实现哪些功能,这样做设计决策时,就豁然开朗了。
产品经理只会给你提供表格1,他们很少会显示说明什么不需要做。在决定放弃时,还是需要商量一下,因为这些需求往往是软性的,需求文档中没有包含不一定是不需要,也有可能是没考虑到。
如何记录用户的点赞关系
点赞关系是典型的K-V类型或是集合类型,用Redis实现是比较合适的,那么用Redis中的哪种数据类型呢?
下表列出了能想到的数据类型与它们各自的优劣。
比较关键的特性是批量查询和内存占用,批量查询特性使得可以在一次请求中查询全部的点赞关系,内存占用使得可以用尽可能少的redis节点,甚至一台redis解决存储问题。
我选择字符串类型,因为哈希类型真的很难实现点赞数据的淘汰,除非记录点赞时间并且定期全局扫描,或者记录双份哈希键,做新旧替换,代价太高,不合适。而淘汰机制本身就是解决内存占用问题,所以字符串类型不会占用异常多的内存。
点赞操作的原子性
点赞操作需要改写两个值,一个是用户对内容的点赞关系,另一个是内容的点赞总数,这两个能不能放在一个key中表示呢?显然是不行的。所以需要先设置用户的点赞关系,再增加点赞总数,如果点赞关系已经存在,就不能增加点赞总数。
设置点赞关系可以用setnx命令实现,仅当不存在key时才设置,并返回一个是否设置的标志,根据这个标志决定是否增加点赞总数。比如:
1 | lua复制代码if setnx(key1) == 1 |
看似每个操作都是原子性的,但是这样的逻辑如果在客户端执行,整体上仍不满足原子性,仍有可能在两个操作之间发生中断,导致点赞成功但是没有增加计数的情况发生。虽然这对于点赞系统来说不是什么大问题,极少出现的概率可以接受,但是我们完全可以做的更好。
redis的事务或脚本特性可以解决上述的问题。脚本的实现更加灵活自由,而且能减少网络请求,我们选择脚本的方式:
1 | lua复制代码--点赞操作,写入并自增,如果写入失败则不自增,【原子性、幂等性】 |
1 | lua复制代码--取消点赞操作,删除并递减,如果删除失败则不递减,【原子性、幂等性】 |
稳定性的基本要求之一就是数据不能无限膨胀,否则迟早出问题,任何存储方案都必须设计与之对应的销毁方案,才能保证系统的稳定长久运行。所以设置KEY1的有效期非常重要,而KEY2可能需要一直保持,由其他机制来删除它,比如销毁陈旧评价或折叠评价时,需要删除对应的KEY2.
脚本返回了点赞后的总数,这对后续数据归档是有帮助的。
封装脚本操作
既然已经决定了redis存储方式,那么就先来实现它。一步一个脚印,扎扎实实地把点赞功能完成。
首先使用Spring配置Lua脚本,它自动预加载脚本,不用麻烦在redis服务器上用script load预编译。
1 | java复制代码/** |
1 | java复制代码/** |
点赞的流程
点赞的流程可以用如下时序图表示:
- 服务端接收用户的点赞请求
- 执行redis脚本,并返回点赞总数信息,redis保存点赞功能的暂时数据
- 发送普通消息到消息队列
- 以上两步执行成功后响应点赞完成,否则加入重试队列
- 重试队列异步重试请求redis或消息队列,直到成功或重试次数用尽
- 消息队列消费者接收消息,并将消息写入mysql
为什么加入消息队列这个角色?因为消息队列使得同步和异步可以优雅的分离。redis命令需要在当前请求中完成,用户想看到请求的执行结果,希望在其他客户端上立刻看到自己的点赞状态,这个举例可能不太恰当,点赞也可能是单向请求,用户没有那么在乎同步性,这里只是为了演示案例。而数据入库或者是其他操作不需要在当前请求生命周期内完成。
如果同步可以称之为“在线服务”,那么异步可以称之为“半在线半离线服务”,虽然不在请求的生命周期内,但是运行于在线服务器之上,占用cpu和内存,占用网络带宽,势必给线上业务造成影响。当异步模式调整时,需要连同在线业务一起发布,造成逻辑上的耦合。而消息队列让“离线服务”成为可能,消费者可以与在线服务器独立开来,独立开发独立部署,无论是物理上还是逻辑上都完全解耦。当然前提是消息对象的序列化格式一致,所以我喜欢使用字符串作为消息对象的内容,而不是对象序列化。
实现mysql的点赞入库
设计好redis的存储方案后,接下来设计mysql的存储方案。
首先是表结构:
1 | mysql复制代码#点赞/投票归档表 |
显然,这是一个以Insert代替Update的日志表,无论是点赞、取消点赞还是重新点赞,都是追加新的记录,而不是修改原有记录。这样做有两个原因,一是Insert不用锁表,执行效率远高于Update,二是蕴含的信息更丰富,可以看到用户的完整行为,对于大数据分析是有帮助的。
Insert代替Update之后,一大难点就是数据聚合,解决方案就是每一次插入,都冗余地记录聚合状态,就像votes字段一样,分析时只需要拿相关评价的最后一条记录即可知道点赞总数,而不需全表扫描。
入库代码:
1 | java复制代码@Repository |
RocketMQ
Apache RocketMQ是一种低延迟、高并发、高可用、高可靠的分布式消息中间件。消息队列RocketMQ既可为分布式应用系统提供异步解耦和削峰填谷的能力,同时也具备互联网应用所需的海量消息堆积、高吞吐、可靠重试等特性。
消息队列核心概念:
- Topic:消息主题,一级消息类型,生产者向其发送消息。
- Broker:中间人/经纪人,消息队列集群的节点,负责保存和收发消息。
- 生产者:也称为消息发布者,负责生产并发送消息至Topic。
- 消费者:也称为消息订阅者,负责从Topic接收并消费消息。
- Tag:消息标签,二级消息类型,表示Topic主题下的具体消息分类。
- 消息:生产者向Topic发送并最终传送给消费者的数据和(可选)属性的组合。
- 消息属性:生产者可以为消息定义的属性,包含Message Key和Tag。
- Group:一类生产者或消费者,这类生产者或消费者通常生产或消费同一类消息,且消息发布或订阅的逻辑一致。
生产者发送消息到消息队列,最终发送到消费者的示意图如下:
消息类型可以划分为:
- 普通消息。也称并发消息,没有顺序,生产消费都是并行的,拥有极高的吞吐性能
- 事务消息。提供了保证消息一定送达到broker的机制。
- 分区顺序消息。Topic分为多个分区,在一个分区内遵循先入先出原则。
- 全局顺序消息。把Topic分区数设置为1,所有消息都遵循先入先出原则。
- 定时消息。将消息发送到MQ服务端,在消息发送时间(当前时间)之后的指定时间点进行投递
- 延迟消息。将消息发送到MQ服务端,在消息发送时间(当前时间)之后的指定延迟时间点进行投递
消费方式可以划分为:
- 集群消费。任意一条消息只需要被集群内的任意一个消费者处理即可。
- 广播消费。将每条消息推送给集群内所有注册过的消费者,保证消息至少被每个消费者消费一次。
消费者获取消息模式可以划分为:
- Push。开启单独的线程轮询broker获取消息,回调消费者的接收方法,仿佛是broker在推消息给消费者。
- Pull。消费者主动从消息队列拉取消息。
使用RocketMQ
我们使用某云产品的RocketMq消息队列,按照官方文档,先在云控制中心创建Group和Topic,然后引入maven依赖,创建好MqConfig连接配置对象。最后:
配置生产者(在项目A):
1 | java复制代码@Configuration |
配置消费者(在项目B):
1 | java复制代码@Configuration |
创建消息接收、监听器:
1 | java复制代码/** |
发送消息的生产者,再稍稍封装一下:
1 | java复制代码/** |
发布消费者,在云控制中心测试(确保流程走通,步步为营):
能达成一致吗
执行redis命令与发送消息这两步,能做到一致性吗,也就是常说的同时完成与同时失败?如果是同构的系统,可以利用系统本身的特性实现事务,比如同是redis操作可以使用redis事务或脚本,前面已经这么做了,如果同是数据库操作,可以使用数据库事务,其他存储系统应该也有类似的支持。
但它们是异构的系统,只能通过在客户端实现事务逻辑或者由第三方协调。常见的客户端实现方法是回滚:
1 | java复制代码try{ |
但是如果回滚失败呢?如果消息发到MQ但却接收失败呢?如果依赖的服务不支持回滚呢?在苛刻的条件下实现苛刻的一致性是不可能的。
还是应该反向思考,有选择性地舍弃某些不重要的部分,才能实现我们的需求。在目前这个需求中,没有必要为了redis和MQ的同步引入第三方的事务协调,但也不能对明显的事务问题视而不见。
我总结的分布式事务解决思路导图:
我们选择使用重试队列来解决这个问题。
设计重试队列
不局限于当前的分布式事务问题,我们设计一个较为通用的重试队列。
先设计重试队列中的基本概念:任务。一个任务由多个单元组成,可计算单元表示有返回值的方法对象,执行单元表示没有返回值的方法对象,但是会接收上一步可计算单元的返回值作为入参。任务中保持了单元的单向链表,只有当一个单元执行成功后,才会指向下一个单元继续执行,但当执行失败时,会在当前单元不断重试直到成功,已执行通过的单元不会重试。这样就保证了各个单元的稳定、有序运行,每个环节的执行具有容错性。
基础接口,让使用者可以自己实现任务执行失败的日志记录,比如持久化磁盘或是发送到远程服务器,避免任务丢失,是保持事务一致性的兜底方案之一,设置成缺省方法使得使用者有选择地实现,不强制一定要有失败处理方案。
1 | java复制代码/** |
定义执行的基本单元,代表需要执行一个redis操作或是发送MQ操作,接口方法可能会由调度器重复地执行,所以要求接口实现者自身保证幂等性。
1 | java复制代码/** |
对应的派生抽象类,主要是为了引导用户实现接口。
1 | java复制代码/** |
重试的意义
好的重试机制可以起到削峰填谷的作用,而不好的重试机制可能火上浇油。
这不是危言耸听,仔细思考一下,程序什么情况下会失败,大致可以总结为三种情况:
- 参数错误导致的逻辑异常
- 负载过大导致的超时或熔断
- 不稳定的网络与人工意外事故
其中对于情况1进行重试是完全没有意义的,参数错误的问题应该通过改变参数来解决,逻辑异常应该修复逻辑bug,无脑重试只能让错误重复发生,只会浪费cpu。对于情况2的重试得小心,因为遇到流量波峰而失败,短时间内重试很可能再次遭遇失败,并且这次重试还会带来更大的流量压力,像滚雪球一样把自己搞垮,也就是火上浇油。
对于情况3的重试就非常有价值,尤其是对于具有SLA协议的第三方服务。第三方服务可能因为种种意外(比如停服更新),导致服务短暂不可用,但是却不违反SLA协议。将这种失败情况加入重试队列,确保只要第三方服务在较长的一段时间内有响应,任务就可以成功,如果第三方服务一直没有响应而导致任务最终失败,那么他往往也就破坏了SLA协议,可以申请赔偿了。
所以,设计重试策略时首先需要判断什么情况下需要重试,可以设定当出现特定的比如参数错误的异常时,就没必要重试了,直接失败即可。可以设定只要当返回参数不为空时才算成功。可以设置固定的重试间隔,让两个重试之间拉开比较长的时间。
更聪明的做法是,使用断路器模式,借助当前连接对目标服务器的请求结果,如果不符预期(异常比率大),就暂时阻塞重试队列中等待的任务,隔一段时间再试探一下。
重试队列与普通限流降级或熔断的区别:
重试的策略
重试策略决定任务何时发起重试,重试策略接口:
1 | java复制代码/** |
基本实现类:
1 | java复制代码/** |
使用断路器实现重试策略,断路器内部实现省略:
1 | java复制代码/** |
可重试任务
根据上面的结构图,定义可重试任务接口:
1 | java复制代码/** |
然后设计抽象类:
1 | java复制代码/** |
以及实现类:
1 | java复制代码/** |
一个单元测试,当然单元测试有很多,不能全贴出来,这里只展示有代表性的:
1 | java复制代码class SegmentRetryTaskTest { |
重试队列的运作
1 | java复制代码线程安全的重试队列。 |
实现重试队列
1 | java复制代码/** |
单元测试:
1 | java复制代码class RetryQueueTest { |
久等的点赞实现代码
好了,轮子已经造完了,可以开始写点赞服务的代码了:
1 | java复制代码/** |
补充说明:
- 封装工厂对象的目的是为了简化构造方法参数,并且复用不变对象,如重试策略。
- 只要重试队列执行有返回结果,哪怕只是部分成功,仍可以算作接口响应成功,剩余部分加入重试队列。
- 如果重试队列执行全部失败,没有返回结果,则抛出异常,毕竟此刻确实失败了,用户有权知道。
- 只有熔断器闭合时,才会执行任务,否则将会一直等待,可以设置恰当的中止策略来完善这个机制。
- 重试队列这个轮子在其他很多场景也都有用武之地,依照我的理解,它大致算是“仓库层”。
但就点赞实现来说,没有必要使用重试,实际上,mq是多节点高可用的,一般不会出现问题,并且,mq自带了重试功能。mq的重试机制是,在一次请求中,如果失败了,立刻向另外的broker发起请求,是一种负载均衡融合高可用的设计。在不要求刚性事务的情景下,可以认为mq是可靠的。
给评价添加点赞
评价列表的数据是相对静态的,不含用户个性化信息,可以很容易地缓存供所有人访问,但是一旦加上用户对每个评价的点赞关系,或是实时变化的点赞数量信息,就变得难以缓存了。我们选择动静分离,静态的数据按照原先的缓存策略不变,动态的数据专门从redis服务中获取,然后再追加到静态数据上。
服务层、控制层,就是数据的聚合层、任务的委派层。
而至于数据聚合,有三种模式:
我们选择第三种方式,这次设计点赞功能,只是作为评价系统的一部分。
在RemarkService中添加如下代码:
1 | java复制代码/** |
修改查询评价列表接口,聚合内容:
1 | java复制代码/** |
优化点:评价的点赞总数信息是固定的,是用户无关的,可以与评价内容结合在一起缓存在内存中,而用户的点赞信息只能每次请求都去redis查询。
推荐优质评价
完整的评价系统应该能够输出一个优质评价内容的推荐列表,作为用户查看商品评价时的默认展示。
何为”优质内容“呢?我的理解是具有话题性、高热度、内容丰富的评价内容,其中”点赞总数“是衡量高热度的重要指标之一。当前,我们就以点赞数量为唯一指标,算出优质内容并提供查询接口。未来引入其他指标时,也可能会继续沿用这种设计思路。
评价表中有votes字段,可以据此排序生成前n条数据:
1 | sql复制代码select id,consumer_id,order_id,score,header,content,images,user_name,user_face,gmt_create from remark where item_id = ? and status = '1' order by votes desc limit ? |
需要注意的是,votes字段并不随着用户点赞而更新它,因为频繁的更新是低效的。可以通过定期汇总的方式来更新votes字段,点赞表保存着评价的最新点赞总数,所以可以每隔1天或1小时,筛选这期间内对应内容的最近一条点赞,就可以更新votes了。
不管基础数据是在何种数据库何种表中,不管是通过什么方式,我都将这一步骤称为”回源“,回源是缓存未命中时的一种行为概念。
在加载推荐评价时,回源算法为
1 | java复制代码public List<Remark> listRecommendRemarks(/*not null*/ String itemId, int expectCount){ |
接下来所要做的,只要将这部分内容保存到缓存,然后输出就可以了。
原子性地替换列表
推荐评价是一个列表,我选择使用Redis的LIST数据类型,可以方便地进行范围查询,参考上篇文章的评价列表。
但是Redis并未直接提供替换列表的操作,只有DEL、LRPUSH、RENAME等命令组合在一起可以才能实现,但客户端的组合操作是非原子性的,不用多说,又要使用脚本了:
1 | lua复制代码--删除并创建列表 |
查询推荐评价的主要代码如下:
1 | java复制代码@Cacheable(value = "recommend") |
其中,仍使用代理键的模式,使Redis存储主要业务数据的列表永不过期,避免缓存击穿以及频繁的分布式阻塞加锁。
一些重要的redis操作代码:
1 | java复制代码//保存推荐内容并重置过期时间 |
冷启动与空数据
冷启动是指服务的第一次上线或者redis在零缓存下重新启动时,这时,任何的缓存都未加载,或者之前加载过,现在因为意外已经不存在了。这时,代理锁会过期,SETNX命令成功,加锁成功的线程会同步数据库数据到redis,这样业务数据KEY就不再为空了。如果同步过程出现失败,锁会在2秒后自动过期,新的线程会继续接任这项未完成的使命。如果业务数据加载完成,那么就随即延迟代理锁的寿命为1小时,这样1小时之后才会触发同步。整个流程是异步的,用户请求的线程只会读取业务数据KEY,有则返回,无则为空。也就是说,接口只在冷启动的几秒内是返回为空的,这是可以接受的,因为冷启动只在新业务上线或者redis内存无法恢复这些极为特殊的时间点才会出现。
空数据是指数据库的内容是原本就是空的。根据上面的设计思路,可以得出结论,如果数据库内容为空,那么业务数据KEY是空的,也就是nil,不存储占位符,因为代理KEY已经起到占位符的作用了。这一点来看,一个简简单单的代理KEY,可以起到防止缓存击穿、防止同步阻塞、占位符等作用。
后续
可能会更新一些抽奖、秒杀活动的实现方法。
本文转载自: 掘金