网站高性能架构设计——高性能NOSQL与缓存

 从公众号转载,关注微信公众号掌握更多技术动态

---------------------------------------------------------------

一、NOSQL简介

1.关系数据库存在如下缺点

(1)关系数据库存储的是行记录,无法存储数据结构

    以微博的关注关系为例,“我关注的人”是一个用户 ID 列表,使用关系数据库存储只能将列表拆成多行,然后再查询出来组装,无法直接存储一个列表。

(2)关系数据库的 schema 扩展很不方便

    关系数据库的表结构 schema 是强约束,操作不存在的列会报错,业务变化时扩充列也比较麻烦,需要执行 DDL(data definition language,如 CREATE、ALTER、DROP 等)语句修改,而且修改时可能会长时间锁表(例如,MySQL 可能将表锁住 1 个小时)。

(3)无法应对每秒上万次的读写请求

    硬盘IO此时也将变为性能瓶颈(由于表之间关联关系导致的)。同时大数据场景下 I/O 较高如果对一些大量数据的表进行统计之类的运算,关系数据库的 I/O 会很高,因为即使只针对其中某一列进行运算,关系数据库也会将整行数据从存储设备读入内存,所以大数据查询SQL效率极低。

(4)关系数据库的全文搜索功能比较弱

    关系数据库的全文搜索只能使用 like 进行整表扫描匹配,性能非常低,在互联网这种搜索复杂的场景下无法满足业务要求。

2.什么是NOSQL

    NoSQL != No SQL,而是 NoSQL = Not Only SQL。非关系型数据库,存储的数据不需要固定的模式,无须多余操作就可以横向扩展,虽然NOSQL可以解决关系型数据库的问题,但是同时它也牺牲了ACID中的一点或者几点。

3.NoSQL数据库的优点

  • 海量数据下,读写性能优异,存储和访问的需求效率高

  • 数据模型灵活,什么样的数据类型都可以(不需要像sql一样在建表的时候定义字段的数据类型)

  • 数据间无关系,易于扩展,实时更改数据库

4.适用场景

    高并发的操作是不建议有关联查询的,互联网公司用数据字段的冗余避免关联查询。分布式项目跨数据库、服务器进行表关联查询是十分不推荐的。

    文件存储格式为BSON(一种JSON的扩展)BSON(Binary Serialized document Format)存储形式是指:存储在集合中的文档,被存储为键-值对的形式。键用于唯一标识一个文档,为字符串类型,而值则可以是各种复杂的文件类型。

    SQL数据库适合那些需求确定和对数据完整性要求严格的项目。NoSQL数据库适用于那些对速度和可扩展性比较看重的那些不相关的,不确定和不断发展的需求。简单来说就是:

  • SQL是精确的。它最适合于具有精确标准的定义明确的项目。典型的使用场景是在线商店和银行系统。

  • NoSQL是多变的。它最适合于具有不确定需求的数据。典型的使用场景是社交网络,客户管理和网络分析系统。

    很少有项目能够很好的适用于一种数据库。如果你对数据的需求比较小或是非标准化的数据任何一种数据库都是可以的。你比我更了解你的项目,我不建议你将SQL上的数据移植到NoSQL上反之亦然,除非它能够提供非常可观的收益。当然选择权在于你自己。在项目的一开始就要考虑好使用它们的利弊,这样才不会导致选择错误。

二、NoSql的五大分类

1.键值(Key-Value)存储数据库(解决关系数据库无法存储数据结构的问题)

    这一类数据库主要会使用到一个哈希表,这个表中有一个特定的键和一个指针指向特定的数据。Key/value模型对于IT系统来说的优势在于简单、易部署。但是如果DBA只对部分值进行查询或更新的时候,Key/value就显得效率低下了。例如:Tokyo Cabinet/Tyrant, Redis, Voldemort, Oracle BDB。Redis 是 K-V 存储的典型代表,它是一款开源(基于 BSD 许可)的高性能 K-V 缓存和存储系统。Redis 的 Value 是具体的数据结构,包括 string、hash、list、set、sorted set、bitmap 和 hyperloglog,所以常常被称为数据结构服务器。

(1)不适用场景

  • 取代通过键查询,而是通过值来查询。Key-Value数据库中根本没有通过值查询的途径。

  • 需要储存数据之间的关系。在Key-Value数据库中不能通过两个或以上的键来关联数据。

  • 事务的支持。Redis 的事务只能保证隔离性和一致性(I 和 C),无法保证原子性和持久性(A 和 D)。虽然 Redis 并没有严格遵循 ACID 原则,但实际上大部分业务也不需要严格遵循 ACID 原则。以上面的微博关注操作为例,即使系统没有将 A 加入 B 的粉丝列表,其实业务影响也非常小,因此我们在设计方案时,需要根据业务特性和要求来确定是否可以用 Redis,而不能因为 Redis 不遵循 ACID 原则就直接放弃。

(2)适用的场景

    储存用户信息,比如会话、配置文件、参数、购物车等等。这些信息一般都和ID(键)挂钩,这种情景下键值数据库是个很好的选择。

缓存:缓存现在几乎是所有中大型网站都在用的必杀技,合理的利用缓存不仅能够提升网站访问速度,还能大大降低数据库的压力。Redis提供了键过期功能,也提供了灵活的键淘汰策略,所以,现在Redis用在缓存的场合非常多。

排行榜:很多网站都有排行榜应用的,如京东的月度销量榜单、商品按时间的上新排行榜等。Redis提供的有序集合数据类构能实现各种复杂的排行榜应用。

计数器:什么是计数器,如电商网站商品的浏览量、视频网站视频的播放数等。为了保证数据实时效,每次浏览都得给+1,并发量高时如果每次都请求数据库操作无疑是种挑战和压力。Redis提供的incr命令来实现计数器功能,内存操作,性能非常好,非常适用于这些计数场景。

分布式会话:集群模式下,在应用不多的情况下一般使用容器自带的session复制功能就能满足,当应用增多相对复杂的系统中,一般都会搭建以Redis等内存数据库为中心的session服务,session不再由容器管理,而是由session服务及内存数据库管理。

分布式锁:在很多互联网公司中都使用了分布式技术,分布式技术带来的技术挑战是对同一个资源的并发访问,如全局ID、减库存、秒杀等场景,并发量不大的场景可以使用数据库的悲观锁、乐观锁来实现,但在并发量高的场合中,利用数据库锁来控制资源的并发访问是不太理想的,大大影响了数据库的性能。可以利用Redis的setnx功能来编写分布式的锁,如果设置返回1说明获取锁成功,否则获取锁失败,实际应用中要考虑的细节要更多。

社交网络:点赞、踩、关注/被关注、共同好友等是社交网站的基本功能,社交网站的访问量通常来说比较大,而且传统的关系数据库类型不适合存储这种类型的数据,Redis提供的哈希、集合等数据结构能很方便的的实现这些功能。

最新列表:Redis列表结构,LPUSH可以在列表头部插入一个内容ID作为关键字,LTRIM可用来限制列表的数量,这样列表永远为N个ID,无需查询最新的列表,直接根据ID去到对应的内容页即可。

消息系统:消息队列是大型网站必用中间件,如ActiveMQ、RabbitMQ、Kafka等流行的消息队列中间件,主要用于业务解耦、流量削峰及异步处理实时性低的业务。Redis提供了发布/订阅及阻塞队列功能,能实现一个简单的消息队列系统。另外,这个不能和专业的消息中间件相比。

2.列存储数据库(解决关系数据库大数据场景下的 I/O 问题,以 HBase 为代表)

    列式数据库就是按照列来存储数据的数据库,与之对应的传统关系数据库被称为“行式数据库”,因为关系数据库是按照行来存储数据的。

(1)适用的场景

    业务同时读取多个列时效率高,因为这些列都是按行存储在一起的,一次磁盘操作就能够把一行数据中的各个列都读取到内存中。

    能够一次性完成对一行中的多个列的写操作,保证了针对行数据写操作的原子性和一致性;否则如果采用列存储,可能会出现某次写操作,有的列成功了,有的列失败了,导致数据不一致。

    行式存储的优势是在特定的业务场景下才能体现,如果不存在这样的业务场景,那么行式存储的优势也将不复存在,甚至成为劣势,典型的场景就是海量数据进行统计。例如,计算某个城市体重超重的人员数据,实际上只需要读取每个人的体重这一列并进行统计即可,而行式存储即使最终只使用一列,也会将所有行数据都读取出来。如果单行用户信息有 1KB,其中体重只有 4 个字节,行式存储还是会将整行 1KB 数据全部读取到内存中,这是明显的浪费。而如果采用列式存储,每个用户只需要读取 4 字节的体重数据即可,I/O 将大大减少。

    除了节省 I/O,列式存储还具备更高的存储压缩比,能够节省更多的存储空间。普通的行式数据库一般压缩率在 3:1 到 5:1 左右,而列式数据库的压缩率一般在 8:1 到 30:1 左右,因为单个列的数据相似度相比行来说更高,能够达到更高的压缩率。

    同样,如果场景发生变化,列式存储的优势又会变成劣势。典型的场景是需要频繁地更新多个列。因为列式存储将不同列存储在磁盘上不连续的空间,导致更新多个列时磁盘是随机写操作;而行式存储时同一行多个列都存储在连续的空间,一次磁盘写操作就可以完成,列式存储的随机写效率要远远低于行式存储的写效率。此外,列式存储高压缩率在更新场景下也会成为劣势,因为更新时需要将存储数据解压后更新,然后再压缩,最后写入磁盘。

基于上述列式存储的优缺点,一般将列式存储应用在离线的大数据分析和统计场景中,因为这种场景主要是针对部分列单列进行操作,且数据写入后就无须再更新删除(比如日志和博客文章等)

  • 数据压缩比较有优势

  • 任何列都可以做索引

  • 查询时只有涉及到的列会被读取

(2)不适用场景

  • 如果需要ACID事务。Vassandra就不支持事务。

  • 原型设计。如果我们分析Cassandra的数据结构,我们就会发现结构是基于我们期望的数据查询方式而定。在模型设计之初,我们根本不可能去预测它的查询方式,而一旦查询方式改变,我们就必须重新设计列族。

  • 每次查询时,都需要对查询到的列进行数据重新组装

  • 插入/更新操作比较困难

(3)列式存储查询过程

网站高性能架构设计——高性能NOSQL与缓存_第1张图片

  • 在列存储中,对于同样的核酸记录表,存储的物理结构如下:

网站高性能架构设计——高性能NOSQL与缓存_第2张图片

在列式存储中,会把每一列存储到一起,如姓名列,是把所有记录中的姓名这列的值使用连续空间存放到一起

而对于各个列之间,是没有必要使用连续空间存放到一起的,所以很多列式数据库都使用了分布式存储的方式,存储各个列

①数据压缩

  • 很多列式数据库都是通过字典表的方式进行数据压缩,因为是把每一列存放到一起的,所以很容易通过对于每一列进行去重,来构建一个字典表,例如:对于姓名列,这列的所有数据如下:

彦祖|德华|路人甲|德华|彦祖

对这列值去重以后,构建一张姓名列字典表,构建算法忽略,就使用自增id的方式,如下:

| id | 姓名列 | |-----|-----| |1| 彦祖 | |2| 德华 | |3| 路人甲 |

  • 这样构建字典表,对于列存储的物理存储结构,就可以执行存储字典表中的id,而不用存储具体的值(而且重复值可以用相同的id替代),有了字典表以后姓名列存储如下:

1|2|3|2|1

  • 同样对于价格列,这列的所有数据如下:

35|20|8|23|20

对这列值去重以后,构建一张价格列字典表,构建算法忽略,就使用自增id的方式,如下:

| id | 价格列 | |-----|-----| |1| 35 | |2| 20 | |3| 8 | |4|23|

  • 有了字典表以后价格列存储如下:

1|2|3|4|2

②查询执行流程

select * from 核酸记录表 where 姓名=彦祖 and 价格=20

对于该sql,执行过程如下:

1.对于where 姓名=彦祖 首先查询姓名字典表,查询到彦祖的id=1

网站高性能架构设计——高性能NOSQL与缓存_第3张图片

2.通过查询到彦祖的id,对于性名列进行对比,构建一个bitmap,把匹配的要的列的索引位设置为1,否则为0

网站高性能架构设计——高性能NOSQL与缓存_第4张图片

3.对于where 价格=20 和上面一样的操作,先查询价格字段表,20的id=2

网站高性能架构设计——高性能NOSQL与缓存_第5张图片

4.通过查询到价格20的id,对于价格列进行对比,构建一个bitmap,把匹配的要的列的索引位设置为1,否则为0

网站高性能架构设计——高性能NOSQL与缓存_第6张图片

5.对于两个where条件的结果bitmap做与运算,bitmap中,位为1的索引就是要查询数据的所有列的索引,如该栗子中,两个结果bitmap与运算后的结果是00001,所以所有列的第5个值,拼接起来就是我们要查询的数据

网站高性能架构设计——高性能NOSQL与缓存_第7张图片

6.所以我们把所有列的第五个值拿出来组装后就是我们需要的数据

3.文档型数据库(解决关系数据库强 schema 约束的问题,以 MongoDB 为代表)

    每个文档都是自包含的数据单元,是一系列数据项的集合。每个数据项都有一个名称与对应的值,值既可以是简单的数据类型,如字符串、数字和日期等;也可以是复杂的类型,如有序列表和关联对象。数据存储的最小单位是文档,同一个表中存储的文档属性可以是不同的,数据可以使用XML、JSON或者JSONB等多种形式存储,因为 JSON 数据是自描述的,无须在使用前定义字段,读取一个 JSON 中不存在的字段也不会导致 SQL 那样的语法错误。。产品:MongoDB、CouchDB、RavenDB,当表结构经常变化并且数据量很大的时候推荐使用

(1)适用的场景

    MongoDB 等文档型数据库,优点在于方便横向扩展,业务上增加新的字段,无须再像关系数据库一样要先执行 DDL 语句修改表结构,程序代码直接读写即可。对于历史数据,即使没有新增的字段,也不会导致错误,只会返回空值,此时代码进行兼容处理即可。

    JSON 是一种强大的描述语言,能够描述复杂的数据结构。例如,我们设计一个用户管理系统,用户的信息有 ID、姓名、性别、爱好、邮箱、地址、学历信息。其中爱好是列表(因为可以有多个爱好);地址是一个结构,包括省市区楼盘地址;学历包括学校、专业、入学毕业年份信息等。如果我们用关系数据库来存储,需要设计多张表,包括基本信息(列:ID、姓名、性别、邮箱)、爱好(列:ID、爱好)、地址(列:省、市、区、详细地址)、学历(列:入学时间、毕业时间、学校名称、专业),而使用文档数据库,一个 JSON 就可以全部描述。

    文档数据库的这个特点,特别适合电商和游戏这类的业务场景。以电商为例,不同商品的属性差异很大。即使是同类商品也有不同的属性。例如,LCD 和 LED 显示器,两者有不同的参数指标。这种业务场景如果使用关系数据库来存储数据,就会很麻烦,而使用文档数据库,会简单、方便许多,扩展新的属性也更加容易。

(2)不适用场景

    对事务要求严格的业务场景是不能使用文档数据库。例如,使用 MongoDB 来存储商品库存,系统创建订单的时候首先需要减扣库存,然后再创建订单。这是一个事务操作,用关系数据库来实现就很简单,但如果用 MongoDB 来实现,就无法做到事务性。异常情况下可能出现库存被扣减了,但订单没有创建的情况。

    无法实现关系数据库的 join 操作。例如,我们有一个用户信息表和一个订单表,订单表中有买家用户 id。如果要查询“购买了苹果笔记本用户中的女性用户”,用关系数据库来实现,一个简单的 join 操作就搞定了;而用文档数据库是无法进行 join 查询的,需要查两次:一次查询订单表中购买了苹果笔记本的用户,然后再查询这些用户哪些是女性用户。

4.图形(Graph)数据库

    图形结构的数据库同其他行列以及刚性结构的SQL数据库不同,它是使用灵活的图形模型,并且能够扩展到多个服务器上。NoSQL数据库没有标准的查询语言(SQL),因此进行数据库查询需要制定数据模型。它适用于关联关系十分复杂的业务场景。

5.全文搜索引擎(解决关系数据库的全文搜索性能问题,以 Elasticsearch 为代表)

传统的关系型数据库通过索引来达到快速查询的目的,但是在全文搜索的业务场景下,索引也无能为力,主要体现在:

  • 全文搜索的条件可以随意排列组合,如果通过索引来满足,则索引的数量会非常多。

  • 全文搜索的模糊匹配方式,索引无法满足,只能用 like 查询,而 like 查询是整表扫描,效率非常低。

使用Elasticsearch 后的数据存储方案

  • ES + MySQL。将要参与查询的字段信息加上 id,放入 ES,做好分词。将全量信息放入 MySQL,通过 id 快速检索。

  • ES + HBASE。如果要省去分库分表什么的,或许可以抛弃 MySQL ,选择分布式数据库,比如 HBASE , 对于这种 NOSQL 来说,存储能力海量,扩容 easy ,根据 rowkey 查询也很快。

6.时序列数据库TSDB

(1)什么是时序列数据库

时序列数据库用来存储时序列数据并以时间建立索引的软件,其中时序列数据可以定义如下:

  • 可以唯一标识的序列名/ID(比如cpu.load.1)及meta-data;

  • 一组数据点{timestamp, value}。timestamp是一个Unix时间戳,一般精度会比较高,比如influxdb里面是nano秒。一般来说这个精度都会在秒以上。

一般时序列数据都具备如下两个特点:

  • 数据结构简单:可以理解为某一度量指标在某一时间点只会有一个值,没有复杂的结构(嵌套、层次等)和关系(关联、主外键等)。

  • 数据量大:这是由于时序列数据由所监控的大量数据源来产生、收集和发送,比如主机、IoT设备、终端或App等。

(2)TSDB数据库特点

  • 数据写入:

    • 写多于读:95%-99%的操作都是写操作

    • 顺序写:由于是时间序列数据,因此数据多为追加式写入,而且几乎都是实时写入,很少会写入几天前的数据。

    • 很少更新:数据写入之后,不会更新

    • 区块(bulk)删除:基本没有随机删除,多数是从一个时间点开始到某一时间点结束的整段数据删除。比如删除上个月,或者7天前的数据。很少出现删除单独某个指标的数据,或者跳跃时间段的数据。区块删除很容易进行优化,比如可以按区块来分开存储到不同的文件,这样删除一个区块只需要删除一个文件就可以了,成本会比较低。

  • 数据读取(查询)

    • 顺序读,基本都是按照时间顺序读取一段时间内的数据。

    • 基数大,基本数据大,超过内存大小,要选取的只是其一小部分,且没有规律,缓存几乎不起任何作用。

  • 分布式(集群):TSDB应该天生就要考虑到分布式和分区等特性,将存储和查询分发到不同的服务器,以支撑大规模的数据采集和查询请求。

  • 基本数据分析支持:TSDB的数据是用来分析的,所以TSDB还会提供做数据分析所必须的各种运算、变换函数。比如可以方便的对时序列数据进行求和、求平均值等操作,就像传统的RDBMS一样。

(3)时序列数据库选型

  • 性能:主要就是读和写的性能,写入性能必须能跟得上、无延时,并且不能阻塞读操作,且读操作能快速返回最新的数据。

  • 存储方案(或引擎):存储方案主要会影响到读写性能、集群扩展容易程度、以及运维的复杂度。典型的存储方案有HDFS、HBase、Cassandra、LevelDB等。

  • 集群功能:一般来说,集群主要集中为存储和查询的集群功能,也代表其可扩展性,因为时序列数据库的数据量很可能很大,并且增长趋势不可预测,尤其是随着大数据和物联网的兴起,GB已经算入门,TB也是刚起步。

  • API(HTTP API和Client Library):如果你需要定制,或者只是使用TSDB做存储,自己写入数据并通过查询接口进行数据展示,那么API的完善程度将是一个很重要的评判因素。还好大部分TSDB都提供了HTTP API,除了简单的文本格式,有很多还支持JSON格式的输入、输出。

(4)应用场景

运维和监控系统。

例子:淘宝、天猫

商品基本信息:mysql

商品描述、详情、评价信息(多文字类)

多文字信息类,IO读写性能变差,所以我们选择存到文档数据库MongDb中

商品图片:分布式的文件系统

商品的关键字:solr之类的搜索引擎

商品波段性的热点高频信息(就是某一时间段热搜的数据):内存数据库 Redis

三、高性能缓存架构(优先考虑)

1.缓存简介

缓存提升性能的幅度,不只取决于存储介质的速度,还取决于缓存命中率。为了提高命中 率,缓存会基于时间、空间两个维度更新数据。在时间上可以采用 LRU、FIFO 等算法淘汰 数据,而在空间上则可以预读、合并连续的数据。如果只是简单地选择最流行的缓存管理 算法,就很容易忽略业务特性,从而导致缓存性能的下降。

(1)命中率

命中率=命中数/(命中数+没有命中数)当某个请求能够通过访问缓存而得到响应时,称为缓存命中。缓存命中率越高,缓存的利用率也就越高。

(2)最大空间

缓存中可以容纳最大元素的数量。当缓存存放的数据超过最大空间时,就需要根据淘汰算法来淘汰部分数据存放新到达的数据。

⼤容量缓存是能带来性能加速的 收益,但是成本也会更⾼,⽽⼩容量缓存不⼀定就起不到加速访问的效果。⼀般来说,建议把缓存容量 设置为总数据量的15%到30%,兼顾访问性能和内存空间开销。

(3)淘汰算法

缓存的存储空间有限制,当缓存空间被用满时,如何保证在稳定服务的同时 有效提升命中率?这就由缓存淘汰算法来处理,设计适合自身数据特征的淘汰算法能够有效提升缓存命中率。常见的淘汰算法有:

  • FIFO(first in first out)「先进先出」。最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。策略算法主要比较缓存元素的创建时间。「适用于保证高频数据有效性场景,优先保障最新数据可用」。

  • LFU(less frequently used)「最少使用」,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。策略算法主要比较元素的hitCount(命中次数)。「适用于保证高频数据有效性场景」。

  • LRU(least recently used)「最近最少使用」,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。策略算法主要比较元素最近一次被get使用时间。当遇到爬虫时,缓存的数据变成非热点数据。「比较适用于热点数据场景,优先保证热点数据的有效性。」

LRU策略更加关注数据的时效性,⽽LFU策略更加关注数据的访问频次。

(4)缓存的使用场景

  • 经常需要读取的数据

  • 频繁访问的数据 热点数据缓存

  • IO 瓶颈数据

  • 计算昂贵的数据

  • 无需实时更新的数据

  • 缓存的目的是减少对后端服务的访问,降低后端服务的压力

(5)缓存更新策略

缓存中的数据会和数据源中的真实数据有一段时间窗口的不一致,需要利用某些策略进行更新,下面会介绍几种主要的缓存更新策略。

①LRU/LFU/FIFO算法剔除:剔除算法通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。例如Redis使用maxmemory-policy这个配置作为内存最大值后对于数据的剔除策略。

②超时剔除:通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如Redis提供的expire命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。例如一个视频的描述信息,可以容忍几分钟内数据不一致,但是涉及交易方面的业务,后果可想而知。

③主动更新:应用方对于数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。

有两个建议:

  • 低一致性业务建议配置最大内存和淘汰策略的方式使用。

  • 高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。

(6)缓存粒度控制

缓存粒度问题是一个容易被忽视的问题,如果使用不当,可能会造成很多无用空间的浪费,网络带宽的浪费,代码通用性较差等情况,需要综合数据通用性、空间占用比、代码维护性三点进行取舍。

对于缓存的使用,需要对其存储的内容和数量严格限制,并对大小进行估算,通过文档进行维护。

2.缓存如何提高性能

网站高性能架构设计——高性能NOSQL与缓存_第8张图片

缓存指的将数据存储在相对较高访问速度的存储介质中,以供系统处理。内存是半导体元件。对于内存而言,只要给出了内存地址,就可以直接访问该地址取出 数据。内存的访问速度很快但价格昂贵。而磁盘是机械器件。磁盘访问数据时,需要等磁盘盘片旋转到磁头下,才能读取相应的数 据。尽管磁盘的旋转速度很快,但是和内存的随机访问相比,性能差距非常大。一般来说,如果是随机读写,会有 10 万到 100 万倍左右的差距。但如果是顺序访问 大批量数据的话,磁盘的性能和内存就是一个数量级的。磁盘的最小读写单位是扇区,目前常见的磁盘扇区是 4K 个字节。操作系统一次会读写多个扇区,所以操作系统的最小读 写单位是块(Block),也叫作簇(Cluster)。当要从磁盘中读取一个数据时,操作系 统会一次性将整个块都读出来。因此,对于大批量的顺序读写来说,磁盘的效率会比随机读 写高许多。

一方面缓存访问速度快,可以减少数据访问的时间,另一方面如果缓存的数据是经过计算的处理得到的,那么被缓存的数据无需重复计算即可直接使用,因此缓存还起到减少计算时间的作用。例如,一个论坛需要在首页展示当前有多少用户同时在线,如果使用 MySQL 来存储当前用户状态,则每次获取这个总数都要“count(*)”大量数据,这样的操作无论怎么优化 MySQL,性能都不会太高。如果要实时展示用户同时在线数,则 MySQL 性能无法支撑。

缓存的本质是一个内存的Hash表,网站应用中,数据缓存以一对key、value的形式存储在内存Hash表中。Hash表数据读写的时间复杂度为O(1)。缓存主要用来存放读多写少、很少变化的数据,比如商品的类目信息,热门词的搜索列表信息,热门商品信息等。应用程序读取数据,先到缓存中读取,如果读取不到或数据已失效,再访问数据库并将数据写入到缓存。

网站数据访问通常遵循二八定律(80%访问落在20%数据上),因此利用Hash表和内存的高速访问特性,将这20%的数据缓存起来,可很好改善系统性能,提高数据读取速度,降低存储访问压力,提高吞吐量。以微博为例:一个明星发一条微博,可能几千万人来浏览。如果使用 MySQL 来存储微博,用户写微博只有一条 insert 语句,但每个用户浏览时都要 select 一次,即使有索引,几千万条 select 语句对 MySQL 数据库的压力也会非常大。

获取缓存的时候千万不用通过服务调用获取缓存,调用服务花费的时间远远大于获取缓存,这样意义不大

3.缓存失效的方式

  • 被动失效,主要处理如模板变更和一些对时效性不太敏感数据的失效,采用设置一定时间 长度(如只缓存 3 秒钟)这种自动失效的方式。当然,你也要开发一个后台管理界面, 以便能够在紧急情况下手工失效某些 Cache。

  • 主动失效,一般有 Cache 失效中心监控数据库表变化发送失效请求、系统发布也需要清 空 Cache 数据等几种场景。其中失效中心承担了主要的失效功能,这个失效中心的逻 图如下:

网站高性能架构设计——高性能NOSQL与缓存_第9张图片

    

    失效中心会监控关键数据表的变更(有个中间件来解析 MySQL 的 binglog,然后发现有 Insert、Update、Delete 等操作时,会把变更前的数据以及要变更的数据转成一个消息发 送给订阅方),通过这种方式来发送失效请求给 Cache,从而清除 Cache 数据。

4.缓存带来的复杂性

缓存避免再高峰刷新,避免连接数占满

(1)合理使用缓存

①频繁修改的数据不应该使用缓存

当数据的读写的比例很大的时候才推荐使用缓存。

②对于访问频率低的数据不应该使用缓存

因为缓存以内存为存储,内存资源宝贵,不可能将所有数据都进行缓存。

③数据一致性要求的访问不应该使用缓存

一般会对缓存设置过期时间,而当缓存没到过期时间更新了数据这时可能出现数据不一致与脏读

缓存往往针对的是“资源”,当某一个操作是“幂等”的和“安全”的,那么这样的操作就可以被抽象为对“资源”的获取操作,那么它才可以考虑被缓存。有些操作不幂等、不安全,比银行转账

④缓存是为了解决“开销”的问题

这个开销,可不只有时间的开销。虽然我们在很多情况下讲的开销,确实都是在时间维度上的,但它还可以是 CPU、网络、I/O 等一切资源。所以缓存的目的不仅仅是为了让系统速度更快

⑤写数据库策略

对于读写缓存来说,如果要对数据进⾏增删改,就需要在缓存中进⾏,同时还要根据采取的写回策略,决定是否同步写回到数据库中。

  • 同步直写策略:写缓存时,也同步写数据库,缓存和数据库中的数据⼀致;

  • 异步写回策略:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。使⽤这种策略时,

如果数据还没有写回数据库,缓存就发⽣了故障,那么,此时,数据库就没有最新的数据了。

所以,对于读写缓存来说,要想保证缓存和数据库中的数据⼀致,就要采⽤同步直写策略。不过,需要注意的是,如果采⽤这种策略,就需要同时更新缓存和数据库。所以要在业务应⽤中使⽤事务机制,来保证缓存和数据库的更新具有原⼦性,也就是说,两者要不⼀起更新,要不都不更新,返回错误信息,进⾏重试。否则就⽆法实现同步直写。

当然,在有些场景下,我们对数据⼀致性的要求可能不是那么⾼,⽐如说缓存的是电商商品的⾮关键属性或者短视频的创建或修改时间等,那么可以使⽤异步写回策略。

(2)数据库缓存数据一致性——最终一致性

网站高性能架构设计——高性能NOSQL与缓存_第10张图片

①缓存先后删除问题

不管是先写MySQL数据库,再删除Redis缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。

先删除缓存

  • 如果先删除Redis缓存数据,然而还没有来得及写入MySQL,另一个线程就来读取

  • 这个时候发现缓存为空,则去Mysql数据库中读取旧数据写入缓存,此时缓存中为脏数据。

  • 然后数据库更新后发现Redis和Mysql出现了数据不一致的问题

后删除缓存

  • 如果先写了库,然后再删除缓存,不幸的写库的线程挂了,导致了缓存没有删除

  • 这个时候就会直接读取旧缓存,最终也导致了数据不一致情况

  • 因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题

②延时双删策略

在写库前后都进行redis.del(key)操作,并且设定合理的超时时间。

  • 先删除缓存

  • 再写数据库

  • 休眠500毫秒(时间的控制是玄学)

  • 再次删除缓存

public void write( String key, Object data ){     redis.delKey( key );      db.updateData( data );      Thread.sleep( 500 );      redis.delKey( key ); }b

问题:这个500毫秒怎么确定的,具体该休眠多久时间呢?

  • 需要评估自己的项目的读数据业务逻辑的耗时。

  • 这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

  • 当然这种策略还要考虑redis和数据库主从同步的耗时。

  • 最后的的写数据的休眠时间:则在读数据业务逻辑的耗时基础上,加几百ms即可。比如:休眠1秒。

设置缓存过期时间是关键点

  • 理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案

  • 所有的写操作以数据库为准,只要到达缓存过期时间,缓存删除

  • 如果后面还有读请求的话,就会从数据库中读取新值然后回填缓存

方案缺点

结合双删策略+缓存超时设置,这样最差的情况就是:

  • 在缓存过期时间内发生数据存在不一致

  • 同时又增加了写请求的耗时。

③异步更新缓存(基于Mysql binlog的同步机制)

    可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中。当应⽤没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进⾏删除或更新。如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时也可以保证数据库和缓存的数据⼀致了。否则的话,还需要再次进⾏重试。如果重试超过的⼀定次数,还是没有成功,就需要向业务层发送报错信息了。

  • 涉及到更新的数据操作,利用Mysql binlog 进行增量订阅消费

  • 将消息发送到消息队列

  • 通过消息队列消费将增量数据更新到Redis上

  • 操作情况

    • 读取Redis缓存:热数据都在Redis上

    • 写Mysql:增删改都是在Mysql进行操作

    • 更新Redis数据:Mysql的数据操作都记录到binlog,通过消息队列及时更新到Redis上

(3)缓存穿透(少量可接受)

    对于像电商中的商品系统、搜索系统这类与用户关联不大的系统,基本不会产生

    缓存穿透是指缓存没有发挥作用,业务系统虽然去缓存查询数据,但缓存中没有数据,业务系统需要再次去存储系统查询数据。通常情况下有两种情况:

①被访问的存储数据不存在

    一般情况下,如果存储系统中没有某个数据,则不会在缓存中存储相应的数据,这样就导致用户查询的时候,在缓存中找不到对应的数据,每次都要去存储系统中再查询一遍,然后返回数据不存在。缓存在这个场景中并没有起到分担存储系统访问压力的作用。

    通常情况下,业务上读取不存在的数据的请求量并不会太大,但如果出现一些异常情况,例如被黑客攻击,故意大量访问某些读取不存在数据的业务,有可能会将存储系统拖垮。

  • 缓存空值(推荐),如果查询存储系统的数据没有找到,则直接设置一个默认值(可以是空值,也可以是具体的值)存到缓存中,这样第二次读取缓存时就会获取到默认值,而不会继续访问存储系统。但是如果后续这个请求有新值了需要把原来缓存的空值删除掉(所以一般过期时间可以稍微设置的比较短)。

  • 通过布隆过滤器。查询缓存之前先去布隆过滤器查询下这个数据是否存在。如果数据不存在,然后直接返回空。这样的话也会减少底层系统的查询压力。

  • 缓存没有直接返回。这种方式的话要根据自己的实际业务来进行选择。比如固定的数据,一些省份信息或者城市信息,可以全部缓存起来。这样的话数据有变化的情况,缓存也需要跟着变化。实现起来可能比较复杂。

②缓存数据生成耗费大量时间或者资源

    如果刚好在业务访问的时候缓存失效了,那么也会出现缓存没有发挥作用,访问压力全部集中在存储系统上的情况。

    典型的就是电商的商品分页,假设我们在某个电商平台上选择“手机”这个类别查看,由于数据巨大,不能把所有数据都缓存起来,只能按照分页来进行缓存,由于难以预测用户到底会访问哪些分页,因此业务上最简单的就是每次点击分页的时候按分页计算和生成缓存。通常情况下这样实现是基本满足要求的,但是如果被竞争对手用爬虫来遍历的时候,系统性能就可能出现问题。

具体的场景有:

    分页缓存的有效期设置为 1 天,因为设置太长时间的话,缓存不能反应真实的数据。

    通常情况下,用户不会从第 1 页到最后 1 页全部看完,一般用户访问集中在前 10 页,因此第 10 页以后的缓存过期失效的可能性很大。

    竞争对手每周来爬取数据,爬虫会将所有分类的所有数据全部遍历,从第 1 页到最后 1 页全部都会读取,此时很多分页缓存可能都失效了。由于很多分页都没有缓存数据,从数据库中生成缓存数据又非常耗费性能(order by limit 操作),因此爬虫会将整个数据库全部拖慢。

    这种情况并没有太好的解决方案,因为爬虫会遍历所有的数据,而且什么时候来爬取也是不确定的,可能是每天都来,也可能是每周,也可能是一个月来一次,我们也不可能为了应对爬虫而将所有数据永久缓存。通常的应对方案要么就是识别爬虫然后禁止访问,但这可能会影响 SEO 和推广;要么就是做好监控,发现问题后及时处理,因为爬虫不是攻击,不会进行暴力破坏,对系统的影响是逐步的,监控发现问题后有时间进行处理。

(4)缓存雪崩

    缓存雪崩是指当缓存失效(过期)后引起系统性能急剧下降的情况。当缓存过期被清除后,业务系统需要重新生成缓存,因此需要再次访问存储系统,再次进行运算,这个处理步骤耗时几十毫秒甚至上百毫秒。而对于一个高并发的业务系统来说,几百毫秒内可能会接到几百上千个请求。由于旧的缓存已经被清除,新的缓存还未生成,并且处理这些请求的线程都不知道另外有一个线程正在生成缓存,因此所有的请求都会去重新生成缓存,都会去访问存储系统,从而对存储系统造成巨大的性能压力。这些压力又会拖慢整个系统,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃。

①更新锁

    对缓存更新操作进行加锁保护,保证只有一个线程能够进行缓存更新,未能获取更新锁的线程要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

    对于采用分布式集群的业务系统,由于存在几十上百台服务器,即使单台服务器只有一个线程更新缓存,但几十上百台服务器一起算下来也会有几十上百个线程同时来更新缓存,同样存在雪崩的问题。因此分布式集群的业务系统要实现更新锁机制,需要用到分布式锁,如 ZooKeeper。

②后台更新

    由后台线程来更新缓存,而不是由业务线程来更新缓存,缓存本身的有效期设置为永久,后台线程定时更新缓存。后台定时机制需要考虑一种特殊的场景,当缓存系统内存不够时,会“踢掉”一些缓存数据,从缓存被“踢掉”到下一次定时更新缓存的这段时间内,业务线程读取缓存返回空值,而业务线程本身又不会去更新缓存,因此业务上看到的现象就是数据丢了。解决的方式有两种:

  • 后台线程除了定时更新缓存,还要频繁地去读取缓存(例如,1 秒或者 100 毫秒读取一次),如果发现缓存被“踢了”就立刻更新缓存,这种方式实现简单,但读取时间间隔不能设置太长,因为如果缓存被踢了,缓存读取间隔时间又太长,这段时间内业务访问都拿不到真正的数据而是一个空的缓存值,用户体验一般。

  • 业务线程发现缓存失效后,通过消息队列发送一条消息通知后台线程更新缓存。可能会出现多个业务线程都发送了缓存更新消息,但其实对后台线程没有影响,后台线程收到消息后更新缓存前可以判断缓存是否存在,存在就不执行更新操作。这种方式实现依赖消息队列,复杂度会高一些,但缓存更新更及时,用户体验更好。

后台更新既适应单机多线程的场景,也适合分布式集群的场景,相比更新锁机制要简单一些。后台更新机制还适合业务刚上线的时候进行缓存预热。缓存预热指系统上线后,将相关的缓存数据直接加载到缓存系统,而不是等待用户访问才来触发缓存加载。

③灰度发布

当系统初始化的时候,比如说系统升级重启或者是缓存刚上线,这个时候缓存是空的,如果大量的请求直接打过来,很容易引发大量缓存穿透导致雪崩。为了避免这种情况,可以采用灰度发布的方式,先接入少量请求,再逐步增加系统的请求数量,直到全部请求都切换完成。

④多级缓存

不同级别缓存时间过时时间不一样,即使某个级别缓存过期了,还有其他缓存级别 兜底。比如我们Redis缓存过期了,还有本地缓存。这样的话即使没有命中redis,有可能会命中本地缓存。

(5)缓存击穿

缓存击穿是指热点key在某个时间点过期的时候,而恰好在这个时间点对这个Key有大量的并发请求过来,从而大量的请求打到db,属于常见的“热点”问题。这个的话可以用缓存雪崩的几种解决方法来避免:

  • 缓存永不过期。Redis中保存的key永久不失效,这样的话就不会出现大量缓存同时失效的问题,但是这种做法会浪费更多的存储空间,一般应该也不会推荐这种做法。

  • 异步重建缓存。这样的话需要维护每个key的过期时间,定时去轮询这些key的过期时间。例如一个key的value设置的过期时间是30min,那我们可以为这个key设置它自己的一个过期时间为20min。所以当这个key到了20min的时候我们就可以重新去构建这个key的缓存,同时也更新这个key的一个过期时间。

  • 互斥锁重建缓存。这种情况的话只能针对于同一个key的情况下,比如你有100个并发请求都要来取A的缓存,这时候可以借助redis分布式锁来构建缓存,让只有一个请求可以去查询DB其他99个(没有获取到锁)都在外面等着,等A查询到数据并且把缓存构建好之后其他99个请求都只需要从缓存取就好了。原理就跟我们java的DCL(double checked locking)思想有点类似。

网站高性能架构设计——高性能NOSQL与缓存_第11张图片

(6)缓存热点

    虽然缓存系统本身的性能比较高,但对于一些特别热点的数据,如果大部分甚至所有的业务请求都命中同一份缓存数据,则这份数据所在的缓存服务器的压力也很大。例如,某明星微博发布“我们”来宣告恋爱了,短时间内上千万的用户都会来围观。

    缓存热点的解决方案就是复制多份缓存副本,将请求分散到多个缓存服务器上,减轻缓存热点导致的单台缓存服务器压力。以微博为例,对于粉丝数超过 100 万的明星,每条微博都可以生成 100 份缓存,缓存的数据是一样的,通过在缓存的 key 里面加上编号进行区分,每次读缓存时都随机读取其中某份缓存。

    缓存副本设计有一个细节需要注意,就是不同的缓存副本不要设置统一的过期时间,否则就会出现所有缓存副本同时生成同时失效的情况,从而引发缓存雪崩效应。正确的做法是设定一个过期时间范围,不同的缓存副本的过期时间是指定范围内的随机值。

①如何识别热点key

  • 凭经验判断哪些是热Key;

  • 客户端统计上报;

  • 服务代理层上报

②如何解决热key问题?

  • Redis集群扩容:增加分片副本,均衡读流量;

  • 将热key分散到不同的服务器中;

  • 使用二级缓存,即JVM本地缓存,减少Redis的读请求。

③热点key重建优化

使用“缓存+过期时间”的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:

  • 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。

  • 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等。在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。

要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:

  • 减少重建缓存的次数

  • 数据尽可能一致。

  • 较少的潜在危险

  • 互斥锁:此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可,整个过程如图所示。

  • 永远不过期

    • 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。

    • 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

(7)拦截非法的查询请求

    可以使用验证码、IP限制等手段限制恶意攻击,并用敏感词过滤器等拦截不合理的非法查询。

(8)无底洞优化

    为了满足业务需要可能会添加大量新的缓存节点,但是发现性能不但没有好转反而下降了。用一句通俗的话解释就是,更多的节点不代表更高的性能,所谓“无底洞”就是说投入越多不一定产出越多。但是分布式又是不可以避免的,因为访问量和数据量越来越大,一个节点根本抗不住,所以如何高效地在分布式缓存中批量操作是一个难点。

无底洞问题分析:

①客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大。

②网络连接数变多,对节点的性能也有一定影响。

如何在分布式条件下优化批量操作?我们来看一下常见的IO优化思路:

  • 命令本身的优化,例如优化SQL语句等。

  • 减少网络通信次数。

  • 降低接入成本,例如客户端使用长连/连接池、NIO等。

这里我们假设命令、客户端连接已经为最优,重点讨论减少网络操作次数。下面我们将结合Redis Cluster的一些特性对四种分布式的批量操作方式进行说明。

①串行命令:由于n个key是比较均匀地分布在Redis Cluster的各个节点上,因此无法使用mget命令一次性获取,所以通常来讲要获取n个key的值,最简单的方法就是逐次执行n个get命令,这种操作时间复杂度较高,它的操作时间=n次网络时间+n次命令时间,网络次数是n。很显然这种方案不是最优的,但是实现起来比较简单。

②串行IO:Redis Cluster使用CRC16算法计算出散列值,再取对16383的余数就可以算出slot值,同时Smart客户端会保存slot和节点的对应关系,有了这两个数据就可以将属于同一个节点的key进行归档,得到每个节点的key子列表,之后对每个节点执行mget或者Pipeline操作,它的操作时间=node次网络时间+n次命令时间,网络次数是node的个数,整个过程如下图所示,很明显这种方案比第一种要好很多,但是如果节点数太多,还是有一定的性能问题。

网站高性能架构设计——高性能NOSQL与缓存_第12张图片

③并行IO:此方案是将方案2中的最后一步改为多线程执行,网络次数虽然还是节点个数,但由于使用多线程网络时间变为O(1),这种方案会增加编程的复杂度。

网站高性能架构设计——高性能NOSQL与缓存_第13张图片

④hash_tag实现:Redis Cluster的hash_tag功能,它可以将多个key强制分配到一个节点上,它的操作时间=1次网络时间+n次命令时间。

四种批量操作解决方案对比

网站高性能架构设计——高性能NOSQL与缓存_第14张图片

5.缓存应用模式

写后立刻读,脏数据库入缓存:在主从数据库同步完成之前,如果有读请求,都可能发生读Cache Miss,读从库把旧数据存入缓存的情况。(此时应该避免写后立刻读)

(1)Cache-Aside(解决了并发数据脏读问题)

数据获取策略:大多数缓存,比如拦截过滤器中的缓存,基本上都是按照这种方式来配置和使用的。

①数据读取情形

  • 应用先去查看缓存是否有所需数据;

  • 如果有,应用直接将缓存数据返回给请求方;

  • 如果没有,应用执行原始逻辑,例如查询数据库得到结果数据;

  • 应用将结果数据写入缓存。

网站高性能架构设计——高性能NOSQL与缓存_第15张图片

②数据更新策略:

  • 应用先更新数据库;

  • 应用再令缓存失效(不论数据库是否更新成功)

数据更新的这个策略,通常来说,最重要的一点是必须先更新数据库,而不是先令缓存失效,即这个顺序不能倒过来。原因在于,如果先令缓存失效,那么在数据库更新成功前,如果有另外一个请求访问了缓存,发现缓存数据库已经失效,于是就会按照数据获取策略,从数据库中使用这个已经陈旧的数值去更新缓存中的数据,这就导致这个过期的数据会长期存在于缓存中,最终导致数据不一致的严重问题。

网站高性能架构设计——高性能NOSQL与缓存_第16张图片

数据库更新以后,需要令缓存失效,而不是更新缓存为数据库的最新值。

如果两个几乎同时发出的请求分别要更新数据库中的值为 A 和 B,如果结果是 B 的更新晚于 A,那么数据库中的最终值是 B。但是,如果在数据库更新后去更新缓存,而不是令缓存失效,那么缓存中的数据就有可能是 A,而不是 B。因为数据库虽然是“更新为 A”在“更新为 B”之前发生,但如果不做特殊的跨存储系统的事务控制,缓存的更新顺序就未必会遵从“A 先于 B”这个规则,这就会导致这个缓存中的数据会是一个长期错误的值 A。

如果是更新缓存为数据库最新值,而不是令缓存失效,为什么会产生问题:

网站高性能架构设计——高性能NOSQL与缓存_第17张图片

如果是令缓存失效,这个问题就消失了。因为 B 是后写入数据库的,那么在 B 写入数据库以后,无论是写入 B 的请求让缓存失效,还是并发的竞争情形下写入 A 的请求让缓存失效,缓存反正都是失效了。那么下一次的访问就会从数据库中取得最新的值,并写入缓存,这个值就一定是 B。

虽然说catch aside可以被称之为缓存使用的最佳实践,但与此同时,它引入了缓存的命中率降低的问题,(每次都删除缓存自然导致更不容易命中了),因此它更适用于对缓存命中率要求并不是特别高的场景。如果要求较高的缓存命中率,依然需要采用更新数据库后同时更新缓存的方案。在更新数据库后同时更新缓存,会在并发的场景下出现数据不一致,那我们该怎么规避呢?方案也有两种。

  • 引入分布式锁。在更新缓存之前尝试获取锁,如果已经被占用就先阻塞住线程,等待其他线程释放锁后再尝试更新。但这会影响并发操作的性能。

设置较短缓存时间。设置较短的缓存过期时间能够使得数据不一致问题存在的时间也比较长,对业务的影响相对较小。但是与此同时,其实这也使得缓存命中率降低,又回到了前面的问题里...

(2)Read-Through

这种情况下缓存系统彻底变成了它身后数据库的代理,二者成为了一个整体,应用的请求访问只能看到缓存的返回数据,而数据库系统对它是透明的。

网站高性能架构设计——高性能NOSQL与缓存_第18张图片

有的框架提供的内置缓存,例如一些 ORM 框架,就是按这种 Read-Through 和 Write-Through 来实现的。

数据获取策略

  • 应用向缓存要求数据;

  • 如果缓存中有数据,返回给应用,应用再将数据返回;

  • 如果没有,缓存查询数据库,并将结果写入自己;

  • 缓存将数据返回给应用。

(3)Write-Through

网站高性能架构设计——高性能NOSQL与缓存_第19张图片

和 Read-Through 类似,但 Write-Through 是用来处理数据更新的场景。

数据更新策略:

  • 应用更新数据库成功;

  • 如果缓存中有对应数据,先更新该数据;

  • 缓存告知应用更新完成。

缓存系统需要自己内部保证并发场景下,缓存更新的顺序要和数据库更新的顺序一致。比如说,两个请求分别要把数据更新为 A 和 B,那么如果 B 后写入数据库,缓存中最后的结果也必须是 B。这个一致性可以用乐观锁等方式来保证。

(4)Write-Back

对于 Write-Back 模式来说,更新操作发生的时候,数据写入缓存之后就立即返回了,而数据库的更新异步完成。这种模式在一些分布式系统中很常见。

这种方式带来的最大好处是拥有最大的请求吞吐量,并且操作非常迅速,数据库的更新甚至可以批量进行,因而拥有杰出的更新效率以及稳定的速率,这个缓存就像是一个写入的缓冲,可以平滑访问尖峰。另外,对于存在数据库短时间无法访问的问题,它也能够很好地处

理。

但是它的弊端也很明显,异步更新一定会存在着不可避免的一致性问题,并且也存在着数据丢失的风险(数据写入缓存但还未入库时,如果宕机了,那么这些数据就丢失了)。

6.缓存方式

(1)本地缓存/进程缓存(同一进程)

本地缓存的话是应用和缓存都在同一个进程里面,获取缓存数据的时候纯内存操作,没有额外的网络开销,速度非常快。它适用于缓存一些应用中基本不会变化的数据,比如(国家、省份、城市等)。

本地缓存与业务系统耦合在一起,应用之间无法直接共享缓存的内容。需要每个应用节点单独的维护自己的缓存。每个节点都需要一份一样的缓存,对服务器内存造成一种浪费。本地缓存机器重启、或者宕机都会丢失。

①一致性问题解决

网站高性能架构设计——高性能NOSQL与缓存_第20张图片

第一种方案,可以通过单节点通知其他节点。如上图:写请求发生在server1,在修改完自己内存数据与数据库中的数据之后,可以主动通知其他server节点,也修改内存的数据。

  这种方案的缺点是:同一功能的一个集群的多个节点,相互耦合在一起,特别是节点较多时,网状连接关系极其复杂。

网站高性能架构设计——高性能NOSQL与缓存_第21张图片

第二种方案,可以通过MQ通知其他节点。如上图,写请求发生在server1,在修改完自己内存数据与数据库中的数据之后,给MQ发布数据变化通知,其他server节点订阅MQ消息,也修改内存数据。这种方案虽然解除了节点之间的耦合,但引入了MQ,使得系统更加复杂。

前两种方案,节点数量越多,数据冗余份数越多,数据同时更新的原子性越难保证,一致性也就越难保证。

第三种方案,为了避免耦合,降低复杂性,干脆放弃了“实时一致性”,每个节点启动一个timer,定时从后端拉取最新的数据,更新内存缓存。在有节点更新后端数据,而其他节点通过timer更新数据之间,会读到脏数据。

②分类

  • EhCache:需要持久化。使用持久化功能需要,缓存稳定,以免持久化的数据不准确影响结果。有集群解决方案。

  • Guava cache:Guava cache说简单点就是一个支持LRU的ConCurrentHashMap,它没有Ehcache那么多的各种特性,只是提供了增、删、改、查、刷新规则和时效规则设定等最基本的元素。做一个jar包中的一个功能之一,Guava cache极度简洁并能满足觉大部分人的要求。

③什么时候使用——尽量不用

情况一,只读数据,可以考虑在进程启动时加载到内存。

此时也可以把数据加载到redis / memcache,进程外缓存服务也能解决这类问题。

情况二,极其高并发的,如果透传后端压力极大的场景,可以考虑使用进程内缓存。

例如,秒杀业务,并发量极高,需要站点层挡住流量,可以使用内存缓存。

情况三,一定程度上允许数据不一致业务。

例如,有一些计数场景,运营场景,页面对数据一致性要求较低,可以考虑使用进程内页面缓存。

(2)客户端缓存

  • 页面缓存:页面自身对某些元素进行缓存、服务端将静态页面或者动态页面进行缓存给客户端使用

  • 浏览器端缓存:将服务器的资源缓存到本地从而减轻服务器的负担,加快加载速度

  • App缓存

(3)服务端缓存(分布式缓存)

redis天然支持高可用,memcache要想要实现高可用,需要进行二次开发,不过缓存不一定需要实现高可用缓存场景,很多时候,是允许cache miss;缓存挂了,很多时候可以通过DB读取数据

①redis

有持久化需求或者对数据结构和处理有高级要求的应用

适用于对读写效率要求都很高,数据处理业务复杂和对安全性要求较高的系统(如新浪微博的计数和微博发布部分系统,对数据安全性、读写要求都很高)

②memcache

纯KV,数据量非常大,并发量非常大的业务,使用memcache或许更适合。更适合存储一些配置信息

动态系统中减轻数据库负载,提升性能;做缓存,适合多读少写,大数据量的情况,value最大1m(如人人网大量查询用户信息、好友信息、文章信息等)

③Tair

单节点的性能比较方面,redis是性能比tair高大概1/5

在分布式集群支持方面tair支持副本,支持多种集群结构,如:一机房一个集群、双     机房单集群单份、双机房独立集群、双机房单集群双份、双机房主备集群;

④底层实现机制

内存分配

memcache使用预分配内存池的方式管理内存,能够省去内存分配时间。

redis则是临时申请空间,可能导致碎片。

从这一点上,mc会更快一些。

虚拟内存使用

memcache把所有的数据存储在物理内存里。

redis有自己的VM机制,理论上能够存储比物理内存更多的数据,当数据超量时,会引发swap,把冷数据刷到磁盘上。

从这一点上,数据量大时,mc会更快一些。

网络模型

memcache使用非阻塞IO复用模型,redis也是使用非阻塞IO复用模型。

但由于redis还提供一些非KV存储之外的排序,聚合功能,在执行这些功能时,复杂的CPU计算,会阻塞整个IO调度。

从这一点上,由于redis提供的功能较多,mc会更快一些。

线程模型

memcache使用多线程,主线程监听,worker子线程接受请求,执行读写,这个过程中,可能存在锁冲突。

redis使用单线程,虽无锁冲突,但难以利用多核的特性提升整体吞吐量。

从这一点上,mc会快一些。

⑤对比选择

  • 性能上:

    • Memcached单个key-value大小有限,一个value最大只支持1MB,而Redis最大支持512MB。

    • 在100k以上的数据中,Memcached性能要高于Redis。

  • 内存空间和数据量大小:

    • MemCached可以修改最大内存,采用LRU算法。

    • Redis增加了VM的特性,突破了物理内存的限制。

  • 操作便利上:

    • MemCached数据结构单一,仅用来缓存数据。

    • 而Redis支持更加丰富的数据类型,也可以在服务器端直接对数据进行丰富的操作,这样可以减少网络IO次数和数据体积。

  • 可靠性上:

    • MemCached不支持数据持久化,断电或重启后数据消失,但其稳定性是有保证的。

    • Redis支持数据持久化和数据恢复,允许单点故障,但是同时也会付出性能的代价。

  • 应用场景:

    • Memcached:动态系统中减轻数据库负载,提升性能;做缓存,适合多读少写,大数据量的情况(如人人网大量查询用户信息、好友信息、文章信息等)。

    • Redis:适用于对读写效率要求都很高,数据处理业务复杂和对安全性要求较高的系统(如新浪微博的计数和微博发布部分系统,对数据安全性、读写要求都很高)。

7.缓存的误用

(1)把缓存作为服务与服务之间传递数据的媒介

  • 服务1和服务2约定好key和value,通过缓存传递数据

  • 服务1将数据写入缓存,服务2从缓存读取数据,达到两个服务通信的目的

该方案存在的问题是:

  • 数据管道,数据通知场景,MQ更加适合

  • 多个服务关联同一个缓存实例,会导致服务耦合

(2)使用缓存未考虑雪崩

网站高性能架构设计——高性能NOSQL与缓存_第22张图片

  • 服务先读缓存,缓存命中则返回

  • 缓存不命中,再读数据库

提前做容量预估,如果缓存挂掉,数据库仍能扛住,才能执行上述方案。否则,就要进一步设计。

常见方案一:高可用缓存

网站高性能架构设计——高性能NOSQL与缓存_第23张图片

使用高可用缓存集群,一个缓存实例挂掉后,能够自动做故障转移。

常见方案二:缓存水平切分

网站高性能架构设计——高性能NOSQL与缓存_第24张图片

使用缓存水平切分,一个缓存实例挂掉后,不至于所有的流量都压到数据库上。

(3)调用方缓存数据

网站高性能架构设计——高性能NOSQL与缓存_第25张图片

  • 服务提供方缓存,向调用方屏蔽数据获取的复杂性(√)

  • 服务调用方,也缓存一份数据,先读自己的缓存,再决定是否调用服务(×)

该方案存在的问题是:

  • 调用方需要关注数据获取的复杂性

  • 更严重的,服务修改db里的数据,淘汰了服务cache之后,难以通知调用方淘汰其cache里的数据,从而导致数据不一致

  • 或许服务可以通过MQ通知调用方淘汰数据,但是下游的服务不应该依赖上游的调用方

(4)多服务共用缓存实例

网站高性能架构设计——高性能NOSQL与缓存_第26张图片

该方案存在的问题是:

  • 可能导致key冲突,彼此冲掉对方的数据

  • 不同服务对应的数据量,吞吐量不一样,共用一个实例容易导致一个服务把另一个服务的热数据挤出去

  • 共用一个实例,会导致服务之间的耦合,与微服务架构的“数据库,缓存私有”的设计原则是相悖的

 (5)将缓存当数据库

       虽然一些缓存比如redis支持持久化,但其本质上依旧是不稳定的,所以不能只将数据存储到缓存中。

你可能感兴趣的:(系统架构,架构,java,性能优化)