今日内容
讲完了【读性能】,我们自然就开始讲【写性能】了。
上一篇文章的末尾我说,【写性能】比【读性能】要复杂一些,那我们就先来分析分析复杂性。知道了复杂性你才能更好地理解后面给出的方案。
01写性能为什么难提升
所谓【写性能】指的是:涉及数据持久化行为的性能。相比于读性能,有以下这些难点:
1) 丢“写”的代价往往比丢“读”大
比如用户浏览商品,丢了10个查询请求也许从转化量上来看,只潜在丢了1笔交易。而丢了10笔交易那就是实打实的订单损失。
从更本质上看,“写”的行为是在给你的系统创造数据,而数据本身就是价值。无论是可以直接收佣的订单,还是用户上传评论,修改个人信息等。
所以,我们针对“读”可以采用拦截流量的方式,因为损失不大(尤其是一些秒杀场景,本来库存就有限),而针对“写”,则往往要求全部都要“扛下来”。
2)“写”必须和磁盘打交道,而“读”是可以使用缓存的
从“持久化”的字面上看就很清楚了,就是数据不能丢!写入内存的数据是有风险,断电则丢失,写入磁盘则是稳妥的(当然磁盘也有损坏率,这个话题我们不做展开)。
3)“写”往往需要加锁,而“读”不需要
大家都知道并发写操作存在相互影响覆盖的情况。
无论是单机内的多线程写内存,还是多机(本质上还是多线程,只是在不同机器上)写数据库,都需要面对这样的问题。
典型的方案就是加锁。一个人抢到,其他人等待。但是,“锁”无疑是性能的第一大杀手。
4)“写”容易造成资损,而“读”一般不涉及资损
“读”的行为一般是查询,大量的并发查询如果处理不好,一般最差的结果就是显示出问题。显示出问题的结果,就是用户对功能不好用的投诉及抱怨。
“写”的行为往往是一种资产的凭证(当然也有不是的,比如用户信息,评论等)。而在系统没有控制好并发,或者没有严格约定好上下游rpc调用出入参的含义时,就非常容易出现资损。
典型的场景就是,调用方把异常当作失败。比如提现操作,提现服务调用出口网关超时,如果当作失败,则余额不扣除。但是超时并不代表银行网关没有收到请求,结果可能就是“余额没减,银行卡加钱”。
02写性能提升整体策略
我们先来看下提升【写性能】的整体策略:
如图所示。因为“写”行为主要是和DB打交道,那自然就推导出两个优化方向。一方面是对DB的优化,一方面则是对使用的优化。
【DB方面优化】分为三块:有钱任性、数据分片、合理选型。
【使用方面优化】分为五块:合理加锁、异步法、批量插入、文件法、缓存法。
今天,我们来讲其中的一部分。
03DB优化方向 - 有钱任性
没错,就是字面上的意思。
现在各大云服务商针对持久化存储都有很好的支持,封装了很多的功能,做了很多的改进。只要你舍得花钱,分库分表,上千qps的写入都可以到手。
但我相信,一般来说也只有企业初期,在规模还较小,并且战略上要快速抢占市场的时候才会这么去做。
就算是各个不差钱的大厂,也难免最后屈服于成本的压力,转向自研或者半自研。
前两天还看到一篇文章说到,国外很多的中大互联网公司,正在逐步降低使用云服务商提供的服务,转向【自采硬件+开源软件+自建运维团队】的方式,因为云产品真的太!贵!了!
04DB优化方向 - 数据分片
数据分片,顾名思义就是把数据拆拆拆。
在扩展性篇章中我们提到,Z轴的扩展就是对数据和服务进行分区。拆分数据可以很好地应对数据量持续上涨的扩展性问题。
此外,数据拆分开了,索引就会变小,数据查询也能做并发,各种资源瓶颈都能被打散,自然也能提升性能。
常规的拆分及访问方式如下:
这种拆分方式很典型,但问题也很明显。如果一个公司有N个服务,每个服务都需要自己去处理请求的路由,整体成本会很大。
不要小看这块的改造,例如DB分片的扩展要做重路由、DB多分片数据查询还涉及到数据merge、如果还想提供例如分片数据查询回来后的排序等操作,工作量是真不小。而且其中坑很多,不能让每个服务都去自己踩一遍吧?
所以,一般我们会用如下三种方案来代替:
方案1:引入中间层,数据服务。
我们行业最经典的话之一:没有什么事情,是不可以通过加一层来解决的。
这种方式好处很明显:功能复用。
各种基础或者高级特性都可以被所有服务使用。同时,团队可以由数据库相关专业方向的同学组成,并且做好专业的运维和稳定性保障。
有好处就有坏处。增加了一个节点后,链路的复杂度变高,大量的数据需要多一层传递,各种超时、性能问题都会出现。同时这个服务的稳定性要求变得奇高,等同于一般对数据库的要求。
方案2:业务服务引入client
client相比数据服务的好处是:没有一个中间服务,也就没有引入一个高可用风险点,数据链路的传输也会变得顺畅高效。
坏处是:如果要对功能做升级,就要和调用方协调client的升级。
干过这个事情的人都知道,这个不容易。除非有组织从上层给支持,不然举步维艰。数据服务的升级就全部自己做主啦。
方案3:单元化访问
单元化的含义我们在扩展性篇章中已经详细讲过了。
简单来说就是:把服务器和数据库成组的进行隔离,一组服务器对应一组数据库(以及一组缓存)。
这个组里的rpc、数据库、缓存访问都在这个组内完成。这样,就不会出现复杂的网状访问结构。
这个方案往往应用于体量已经达到一定规模的产品。而且,在单个区域内也还是需要借助数据服务或者client来进行DB访问的自动路由。
所以,如果你没有那么大的数据体量(以及稳定性需求),可以不用过多考虑。
单元化更多细节不多赘述,大家可以回到扩展性篇章中去翻看。
05DB优化方向 - 选择合适的数据库
在讨论数据库的选型之前,我们先来讨论一个看似没啥意义的问题,那就是“什么是数据库”?
广义上讲,数据库是:存储数据的软件。存储数据的软件就包括:cache(缓存)、NoSQL(非关系型数据库)、RDBMS(关系型数据库)、FS(文件系统)等
狭义上讲,数据库一般满足于两个要求:实时查询 + 持久化。那就包括:NoSQL(非关系型数据库)、RDBMS(关系型数据库)
但是,一般我认为,cache也在数据库狭义定义范畴内,因为现在的cache中间件,都有一定的持久化能力。
那回到“选择合适的数据库”这个问题。既然要做选择,就要知道优劣,我们从5个维度来对这三者作比较,见下图:
【可靠性】虽然cache现在都配有持久化能力,但在极端情况下,是会丢数据的。所以在数据丢失零容忍情况下,我们不会依赖cache的持久化能力。而NoSQL和RDBMS在这方面没问题。
【性能】显然基于内存的cache读写性能都是最好的。其次是NoSQL,这是因为NoSQL的设计不涉及复杂的索引构建,NoSQL的事务也不是典型的事务,并且结合上NoSQL的数据存储格式,其写性能比RDBMS好。
【易扩展】目前市面上比较流行的cache和NoSQL解决方案都有成熟的扩展能力。因为他们就是在互联网浪潮下诞生的产物,天然就考虑了这些功能。而传统关系型数据库则往往依赖额外的能力建设。当然,现在相关的能力也越来越丰富,但易用性还是不太够的。
【事务】cache一般提供批量更新的能力。因为考虑到cache挂掉的时候,一般我们不依赖其持久化数据进行重建,所以对极端情况下的事务保证也没有强要求。NoSQL的事务不是典型的数据库事务,其主要支持批量的数据操作同时提交,但是对ACID的要求并不高。
【复杂查询】多表关联的复杂查询自然是RDBMS的拿手绝活。但cache和NoSQL看到如今的互联网应用,低效的关联查询并不是痛点,于是抛弃对复杂查询的过度支持,转而追求高性能。
所以,不同存储各有好坏,要结合自己的场景选择。有时候,你在那里想半天优化,也许出发点就错了。选择合适的存储,你就解决了一半的问题。
06使用优化 - 合理加锁
上面我们有提到,写逻辑设计并发时,往往为了保证数据的正确性,会采用加锁的方式。
01乐观锁与悲观锁
聊锁,我们就不得不了【乐观锁】和【悲观锁】。这两者的概念大家应该都知道,就不赘述了。但我想说,很多同学在理解这两者性能差别上,是存在问题的。
很多同学认为:乐观锁使用了版本控制,不阻塞线程,性能高。悲观锁使用了分布式锁或者DB行锁,阻塞运行,性能低。
但你仔细想想就会发现有问题。同样是大量的并发请求过来,乐观锁如果反复失败,大量并发线程会重复尝试,性能会直线下降。不见得比悲观锁“傻傻地”等待要好。
如果你了解JVM里synchronized关键字背后的原理,你就应该知道,CAS的乐观锁策略往往在不生效的时候,会转向阻塞的思路。
乐观锁比悲观锁效率高主要是因为:乐观锁将判断和更新通过一次调用就完成了,而悲观锁则需要多步。
那是不是所有的时候都可以使用乐观锁呢?
很抱歉,很多场景下还是需要使用悲观锁。
(场景1)上述提到的乐观锁反复失败的情况下,悲观锁就要出来救场了。
(场景2)如果涉及到多条数据操作,并且要控制事务的时候,乐观锁就力不从心了。
而上面两种场景真的很常见,所以,我们可以说,悲观锁是有价值的!
所以综合上述的说法,我们得到一个切实的用锁策略:尽量使用乐观锁,悲观锁场景控制锁粒度。
02尽量使用乐观锁
在使用锁的地方,我们先判断这里是否可以使用乐观锁。比如下面这个场景:
这个场景就可以使用乐观锁。但,如果是一个热点账户,就不合适了,乐观锁的where判断会大量失败。
但是热点账户的话,悲观锁也同样不合适。那要怎么做呢?我们下一篇文章会提到的。
03悲观锁控制锁粒度
如果你有个商品库存表如下:
这里假设商品库存的扣减还联动其他表一起变更,并且要保证事务。
那么,这里一定会涉及悲观锁(自己在某行上加行锁,或者事务写更新触发行锁)。
那我们要怎么提升性能呢?
我们可以“一锁变十锁”,如下面所示这样:
当用户请求扣库存的时候,需要选择其中一个分片进行扣减库存。
这样的好处是:扣减库存本来只有一个锁,现在变成了十个锁,冲突的概率和等待的时间都会降低。这和jdk8前的hashmap分段锁是相同的设计思路。
这里其实还有很多细节。例如在扣减库存前还需要一个用户维度的锁,不然如果有重复的请求扣减库存,请求落到不同的库存分片上,就会扣两笔。
有同学可能会问,如果某个分片的库存先被扣完了怎么办?
好问题!
一种方式是,不管他。如果你的库存总量本来就不大,但抢购的流量很大,并且均匀分布到各个分片上,那分片库存耗尽的时间不会差太多。
但如果流量不大,就需要做一些设计,不然有些用户会出现“看着有库存,但是抢不到”的情况,客诉就来了。
可以使用下图这种方案:
可以用redis记录每个分片的库存情况。商品上架前初始化redis中库存,并在db扣减库存后将最近库存设置到redis中。
这里有个小细节,为什么是更新库存到redis,而不是将redis库存-1?这个就留给你思考了
今日小结
今天我们就到这里了。对于【写性能】,由于内容较多,我分成两次内容来说,今天讲了上半部分,下一篇我们继续。
今天我们开始讲【写性能】。我们先是讲了提升【写性能】比提升【读性能】要难的各方面原因。
接着我们讲了,在使用DB方面,我们可以做数据分片,并提供了一些典型的实践。
然后,你需要了解各种数据存储软件的特性,并根据你的场景选择。
最后我们讲,【写性能】的一大挑战点是解决锁的问题,我们给出了“尽量使用乐观锁,悲观锁控制粒度”的设计思路。