图8
除了采用动态流量分配的实现,还可以选择相对简单的主次方案,即固定依赖其中一个服务,当该服务出现异常或熔断时,再依赖另一个服务。这种主次方案可以在一定程度上提高服务的可用性,同时也相对简单易行。
(2)弱依赖异步
异步常用方案是依赖独立的消息组件(图9),把原本同步调用的处理改为消息发送。这样做除了能实现依赖关系的解耦,同时能增加系统吞吐量。回顾ADP原则中我们提到的循环依赖,是可以通过消息组件进行解耦规避的。
图9
需要提醒的是使用消息组件会增加系统的复杂性,异步天生要比同步更复杂,需要额外考虑消息乱序、延迟、丢失等问题。针对这些问题可以尝试下面方案:不在服务流程中直接发送消息,而是依赖服务流程产生的数据,进行消息生产,如下图(图10)。帐号系统中使用场景有帐号注册、注销后的业务通知。
图10
选择kafka组件是可以提供消息的有序性的特征。方案中从binlog采集、到推送消息,可以理解成是一个数据传输服务(Data Transmission Service,简称DTS),在vivo内部有自研的“鲁班平台”实现了DTS能力,对于读者朋友可以借助类似开源的Canal项目达成同样的效果。
三、数据架构治理
3.1 缓存
在高并发的系统架构中,缓存是提升系统性能最有效的方式之一。缓存可以分为本地缓存和分布式缓存两种。在帐号系统中,为了应对不同的场景,我们采用了本地缓存和分布式缓存结合的方式。
3.1.1 本地缓存
本地缓存就是将数据缓存到服务本地内存中,好处是响应时间快、不受跨进程通信等外部因素影响。但弊端也非常多,受服务内存大小的限制,以及多节点的一致性问题等,在帐号中使用的场景是缓存相对固定不变的数据。
3.1.2 分布式缓存
分布式缓存能有效规避服务内存大小限制等问题,同时提供了相对数据库更好的读写性能。但是引入分布式缓存同样会带来额外问题,其中最突出的就是数据一致性问题。
(1)数据一致性
处理数据一致性的方案有很多选择,根据帐号使用的业务场景,我们选择的方案是:Cache Aside Pattern。Cache Aside Pattern 具体逻辑如下:
- 数据查询:从缓存取,命中直接返回,未命中则从数据库取并设置到缓存。
- 数据更新:先更新数据到数据库,后直接删除缓存。
图11 (Cache Aside Pattern示意图)
处理的核心要点是数据更新时直接删除缓存,而不是刷新缓存。这是为了规避,并发修改可能导致的数据不一致。当然Cache Aside Pattern是不能杜绝一致性问题。
主要是下面两种场景:
第一种情况删除缓存异常。这种要么可以尝试重试,或直接依赖设定合理的过期时间来降低影响。
第二种情况是理论上的可能性,概率非常低。
一个读操作,没有命中缓存,到数据库中取数据,此时来了一个写操作,写完数据库后删除了缓存,然后之前的读再把老的数据写入缓存。说它理论上存在是因为条件过于苛刻,首先需要发生在读缓存时缓存失效,而且并发一个写操作。然后我们知道数据库的写操作通常会比读操作慢得多,而发生问题是要求读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所以说它只是理论上的可能性。
基于上述情况综合考虑,我们选择的是Cache Aside Pattern方案,尽可能去降低并发脏数据发生的概率,而非通过复杂度更高的2PC或是Paxos协议保证强一致性。
(2)批量读操作优化
尽管使用缓存可以显著提升系统的性能,但并不能解决所有的性能问题。在帐号服务中,我们提供了用户资料查询能力,根据用户标识获取用户的昵称、头像、签名等信息。为了提高接口的性能,我们将相关信息缓存在Redis中。然而,随着用户量和调用量的快速增长,以及批量查询的新增需求,Redis的容量和服务接口的性能都面临着压力。
为了解决这些问题,我们采取了一系列有针对性的优化措施:
首先,我们在将缓存数据写入Redis前,先对其进行压缩。这样可以减小缓存数据的大小,从而降低了数据在网络传输和存储过程中的开销。
接着,我们更换默认的序列化方式,选择了protostuff作为替代方案。protostuff是一种高效的序列化框架,相比其他序列化框架具有以下优势:
- 高性能:protostuff采用了零拷贝技术,直接将对象序列化为字节数组,避免了中间对象的创建和拷贝,从而大幅度提高了序列化和反序列化的性能。
- 空间效率:由于采用了紧凑的二进制格式,protostuff可以将对象序列化为更小的字节数组,从而节省了存储空间。
- 易用性:protostuff是基于protobuf开发,但对Java语言的支持更加完善,只需要定义好Java对象的结构和注解,就可以进行序列化和反序列化操作。
序列化的方案还有很多,例如thrift等,关于它们的性能对比,可以参考下图(图12),读者可以自己项目实际情况进行选择。