This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,251 @@
<audio id="audio" title="41 | 如何设计更优的分布式锁?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/b5/16fc7f0171856ba9fd44f11fc66b90b5.mp3"></audio>
你好,我是刘超。
从这一讲开始,我们就正式进入最后一个模块的学习了,综合性实战的内容来自我亲身经历过的一些案例,其中用到的知识点会相对综合,现在是时候跟我一起调动下前面所学了!
去年双十一我们的游戏商城也搞了一波活动那时候我就发现在数据库操作日志中出现最多的一个异常就是Interrupted Exception了几乎所有的异常都是来自一个校验订单幂等性的SQL。
因为校验订单幂等性是提交订单业务中第一个操作数据库的,所以幂等性校验也就承受了比较大的请求量,再加上我们还是基于一个数据库表来实现幂等性校验的,所以出现了一些请求事务超时,事务被中断的情况。其实基于数据库实现的幂等性校验就是一种分布式锁的实现。
那什么是分布式锁呢,它又是用来解决哪些问题的呢?
在JVM中在多线程并发的情况下我们可以使用同步锁或Lock锁保证在同一时间内只能有一个线程修改共享变量或执行代码块。但现在我们的服务基本都是基于分布式集群来实现部署的对于一些共享资源例如我们之前讨论过的库存在分布式环境下使用Java锁的方式就失去作用了。
这时,我们就需要实现分布式锁来保证共享资源的原子性。除此之外,分布式锁也经常用来避免分布式中的不同节点执行重复性的工作,例如一个定时发短信的任务,在分布式集群中,我们只需要保证一个服务节点发送短信即可,一定要避免多个节点重复发送短信给同一个用户。
因为数据库实现一个分布式锁比较简单易懂,直接基于数据库实现就行了,不需要再引入第三方中间件,所以这是很多分布式业务实现分布式锁的首选。但是数据库实现的分布式锁在一定程度上,存在性能瓶颈。
接下来我们一起了解下如何使用数据库实现分布式锁,其性能瓶颈到底在哪,有没有其它实现方式可以优化分布式锁。
## 数据库实现分布式锁
首先,我们应该创建一个锁表,通过创建和查询数据来保证一个数据的原子性:
```
CREATE TABLE `order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_no` int(11) DEFAULT NULL,
`pay_money` decimal(10, 2) DEFAULT NULL,
`status` int(4) DEFAULT NULL,
`create_date` datetime(0) DEFAULT NULL,
`delete_flag` int(4) DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_status`(`status`) USING BTREE,
INDEX `idx_order`(`order_no`) USING BTREE
) ENGINE = InnoDB
```
其次,如果是校验订单的幂等性,就要先查询该记录是否存在数据库中,查询的时候要防止幻读,如果不存在,就插入到数据库,否则,放弃操作。
```
select id from `order` where `order_no`= 'xxxx' for update
```
最后注意下,除了查询时防止幻读,我们还需要保证查询和插入是在同一个事务中,因此我们需要申明事务,具体的实现代码如下:
```
@Transactional
public int addOrderRecord(Order order) {
if(orderDao.selectOrderRecord(order)==null){
int result = orderDao.addOrderRecord(order);
if(result&gt;0){
return 1;
}
}
return 0;
}
```
到这,我们订单幂等性校验的分布式锁就实现了。我想你应该能发现为什么这种方式会存在性能瓶颈了。我们在[第34讲](https://time.geekbang.org/column/article/116369)中讲过在RR事务级别select的for update操作是基于间隙锁gap lock实现的这是一种悲观锁的实现方式所以存在阻塞问题。
因此在高并发情况下,当有大量的请求进来时,大部分的请求都会进行排队等待。为了保证数据库的稳定性,事务的超时时间往往又设置得很小,所以就会出现大量事务被中断的情况。
除了阻塞等待之外,因为订单没有删除操作,所以这张锁表的数据将会逐渐累积,我们需要设置另外一个线程,隔一段时间就去删除该表中的过期订单,这就增加了业务的复杂度。
除了这种幂等性校验的分布式锁,有一些单纯基于数据库实现的分布式锁代码块或对象,是需要在锁释放时,删除或修改数据的。如果在获取锁之后,锁一直没有获得释放,即数据没有被删除或修改,这将会引发死锁问题。
## Zookeeper实现分布式锁
除了数据库实现分布式锁的方式以外我们还可以基于Zookeeper实现。Zookeeper是一种提供“分布式服务协调“的中心化服务正是Zookeeper的以下两个特性分布式应用程序才可以基于它实现分布式锁功能。
**顺序临时节点:**Zookeeper提供一个多层级的节点命名空间节点称为Znode每个节点都用一个以斜杠/)分隔的路径来表示,而且每个节点都有父节点(根节点除外),非常类似于文件系统。
节点类型可以分为持久节点PERSISTENT 、临时节点EPHEMERAL每个节点还能被标记为有序性SEQUENTIAL一旦节点被标记为有序性那么整个节点就具有顺序自增的特点。一般我们可以组合这几类节点来创建我们所需要的节点例如创建一个持久节点作为父节点在父节点下面创建临时节点并标记该临时节点为有序性。
**Watch机制**Zookeeper还提供了另外一个重要的特性Watcher事件监听器。ZooKeeper允许用户在指定节点上注册一些Watcher并且在一些特定事件触发的时候ZooKeeper服务端会将事件通知给用户。
我们熟悉了Zookeeper的这两个特性之后就可以看看Zookeeper是如何实现分布式锁的了。
首先我们需要建立一个父节点节点类型为持久节点PERSISTENT 每当需要访问共享资源时就会在父节点下建立相应的顺序子节点节点类型为临时节点EPHEMERAL且标记为有序性SEQUENTIAL并且以临时节点名称+父节点名称+顺序号组成特定的名字。
在建立子节点后对父节点下面的所有以临时节点名称name开头的子节点进行排序判断刚刚建立的子节点顺序号是否是最小的节点如果是最小节点则获得锁。
如果不是最小节点,则阻塞等待锁,并且获得该节点的上一顺序节点,为其注册监听事件,等待节点对应的操作获得锁。
当调用完共享资源后删除该节点关闭zk进而可以触发监听事件释放该锁。
<img src="https://static001.geekbang.org/resource/image/1c/28/1c2df592672c78fd5d006cd23eb11f28.jpg" alt="">
以上实现的分布式锁是严格按照顺序访问的并发锁。一般我们还可以直接引用Curator框架来实现Zookeeper分布式锁代码如下
```
InterProcessMutex lock = new InterProcessMutex(client, lockPath);
if ( lock.acquire(maxWait, waitUnit) )
{
try
{
// do some work inside of the critical section here
}
finally
{
lock.release();
}
}
```
Zookeeper实现的分布式锁例如相对数据库实现有很多优点。Zookeeper是集群实现可以避免单点问题且能保证每次操作都可以有效地释放锁这是因为一旦应用服务挂掉了临时节点会因为session连接断开而自动删除掉。
由于频繁地创建和删除结点加上大量的Watch事件对Zookeeper集群来说压力非常大。且从性能上来说其与接下来我要讲的Redis实现的分布式锁相比还是存在一定的差距。
## Redis实现分布式锁
相对于前两种实现方式基于Redis实现的分布式锁是最为复杂的但性能是最佳的。
大部分开发人员利用Redis实现分布式锁的方式都是使用SETNX+EXPIRE组合来实现在Redis 2.6.12版本之前,具体实现代码如下:
```
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);//设置锁
if (result == 1) {//获取锁成功
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);//通过过期时间删除锁
return true;
}
return false;
}
```
这种方式实现的分布式锁是通过setnx()方法设置锁如果lockKey存在则返回失败否则返回成功。设置成功之后为了能在完成同步代码之后成功释放锁方法中还需要使用expire()方法给lockKey值设置一个过期时间确认key值删除避免出现锁无法释放导致下一个线程无法获取到锁即死锁问题。
如果程序在设置过期时间之前、设置锁之后出现崩溃此时如果lockKey没有设置过期时间将会出现死锁问题。
在 Redis 2.6.12版本后SETNX增加了过期时间参数
```
private static final String LOCK_SUCCESS = &quot;OK&quot;;
private static final String SET_IF_NOT_EXIST = &quot;NX&quot;;
private static final String SET_WITH_EXPIRE_TIME = &quot;PX&quot;;
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
```
我们也可以通过Lua脚本来实现锁的设置和过期时间的原子性再通过jedis.eval()方法运行该脚本:
```
// 加锁脚本
private static final String SCRIPT_LOCK = &quot;if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then redis.call('pexpire', KEYS[1], ARGV[2]) return 1 else return 0 end&quot;;
// 解锁脚本
private static final String SCRIPT_UNLOCK = &quot;if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end&quot;;
```
虽然SETNX方法保证了设置锁和过期时间的原子性但如果我们设置的过期时间比较短而执行业务时间比较长就会存在锁代码块失效的问题。我们需要将过期时间设置得足够长来保证以上问题不会出现。
这个方案是目前最优的分布式锁方案但如果是在Redis集群环境下依然存在问题。由于Redis集群数据同步到各个节点时是异步的如果在Master节点获取到锁后在没有同步到其它节点时Master节点崩溃了此时新的Master节点依然可以获取锁所以多个应用服务可以同时获取到锁。
### Redlock算法
Redisson由Redis官方推出它是一个在Redis的基础上实现的Java驻内存数据网格In-Memory Data Grid。它不仅提供了一系列的分布式的Java常用对象还提供了许多分布式服务。Redisson是基于netty通信框架实现的所以支持非阻塞通信性能相对于我们熟悉的Jedis会好一些。
Redisson中实现了Redis分布式锁且支持单点模式和集群模式。在集群模式下Redisson使用了Redlock算法避免在Master节点崩溃切换到另外一个Master时多个应用同时获得锁。我们可以通过一个应用服务获取分布式锁的流程了解下Redlock算法的实现
在不同的节点上使用单个实例获取锁的方式去获得锁且每次获取锁都有超时时间如果请求超时则认为该节点不可用。当应用服务成功获取锁的Redis节点超过半数N/2+1N为节点数)时,并且获取锁消耗的实际时间不超过锁的过期时间,则获取锁成功。
一旦获取锁成功,就会重新计算释放锁的时间,该时间是由原来释放锁的时间减去获取锁所消耗的时间;而如果获取锁失败,客户端依然会释放获取锁成功的节点。
具体的代码实现如下:
1.首先引入jar包
```
&lt;dependency&gt;
&lt;groupId&gt;org.redisson&lt;/groupId&gt;
&lt;artifactId&gt;redisson&lt;/artifactId&gt;
&lt;version&gt;3.8.2&lt;/version&gt;
&lt;/dependency&gt;
```
2.实现Redisson的配置文件
```
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useClusterServers()
.setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒
.addNodeAddress(&quot;redis://127.0.0.1:7000).setPassword(&quot;1&quot;)
.addNodeAddress(&quot;redis://127.0.0.1:7001&quot;).setPassword(&quot;1&quot;)
.addNodeAddress(&quot;redis://127.0.0.1:7002&quot;)
.setPassword(&quot;1&quot;);
return Redisson.create(config);
}
```
3.获取锁操作:
```
long waitTimeout = 10;
long leaseTime = 1;
RLock lock1 = redissonClient1.getLock(&quot;lock1&quot;);
RLock lock2 = redissonClient2.getLock(&quot;lock2&quot;);
RLock lock3 = redissonClient3.getLock(&quot;lock3&quot;);
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功,且设置总超时时间以及单个节点超时时间
redLock.trylock(waitTimeout,leaseTime,TimeUnit.SECONDS);
...
redLock.unlock();
```
## 总结
实现分布式锁的方式有很多有最简单的数据库实现还有Zookeeper多节点实现和缓存实现。我们可以分别对这三种实现方式进行性能压测可以发现在同样的服务器配置下Redis的性能是最好的Zookeeper次之数据库最差。
从实现方式和可靠性来说Zookeeper的实现方式简单且基于分布式集群可以避免单点问题具有比较高的可靠性。因此在对业务性能要求不是特别高的场景中我建议使用Zookeeper实现的分布式锁。
## 思考题
我们知道Redis分布式锁在集群环境下会出现不同应用服务同时获得锁的可能而Redisson中的Redlock算法很好地解决了这个问题。那Redisson实现的分布式锁是不是就一定不会出现同时获得锁的可能呢
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

View File

@@ -0,0 +1,141 @@
<audio id="audio" title="42 | 电商系统的分布式事务调优" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/4c/cc0bf8492ed489fde077405057ca7e4c.mp3"></audio>
你好,我是刘超。
今天的分享也是从案例开始。我们团队曾经遇到过一个非常严重的线上事故在一次DBA完成单台数据库线上补丁后系统偶尔会出现异常报警我们的开发工程师很快就定位到了数据库异常问题。
具体情况是这样的,当玩家购买道具之后,扣除通宝时出现了异常。这种异常在正常情况下发生之后,应该是整个购买操作都需要撤销,然而这次异常的严重性就是在于玩家购买道具成功后,没有扣除通宝。
究其原因是由于购买的道具更新的是游戏数据库,而通宝是在用户账户中心数据库,在一次购买道具时,存在同时操作两个数据库的情况,属于一种分布式事务。而我们的工程师在完成玩家获得道具和扣除余额的操作时,没有做到事务的一致性,即在扣除通宝失败时,应该回滚已经购买的游戏道具。
**从这个案例中,我想你应该意识到了分布式事务的重要性。**
如今,大部分公司的服务基本都实现了微服务化,首先是业务需求,为了解耦业务;其次是为了减少业务与业务之间的相互影响。
电商系统亦是如此,大部分公司的电商系统都是分为了不同服务模块,例如商品模块、订单模块、库存模块等等。事实上,分解服务是一把双刃剑,可以带来一些开发、性能以及运维上的优势,但同时也会增加业务开发的逻辑复杂度。其中最为突出的就是分布式事务了。
通常,存在分布式事务的服务架构部署有以下两种:同服务不同数据库,不同服务不同数据库。我们以商城为例,用图示说明下这两种部署:
<img src="https://static001.geekbang.org/resource/image/11/5a/111f44892deb9919a1310d636a538f5a.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/48/6c/48d448543aeac5eba4b9edd24e1bcf6c.jpg" alt="">
通常,我们都是基于第二种架构部署实现的,那我们应该如何实现在这种服务架构下,有关订单提交业务的分布式事务呢?
## 分布式事务解决方案
我们讲过在单个数据库的情况下数据事务操作具有ACID四个特性但如果在一个事务中操作多个数据库则无法使用数据库事务来保证一致性。
也就是说,当两个数据库操作数据时,可能存在一个数据库操作成功,而另一个数据库操作失败的情况,我们无法通过单个数据库事务来回滚两个数据操作。
而分布式事务就是为了解决在同一个事务下不同节点的数据库操作数据不一致的问题。在一个事务操作请求多个服务或多个数据库节点时要么所有请求成功要么所有请求都失败回滚回去。通常分布式事务的实现有多种方式例如XA协议实现的二阶提交2PC、三阶提交(3PC)以及TCC补偿性事务。
在了解2PC和3PC之前我们有必要先来了解下XA协议。XA协议是由X/Open组织提出的一个分布式事务处理规范目前MySQL中只有InnoDB存储引擎支持XA协议。
### 1. XA规范
在XA规范之前存在着一个DTP模型该模型规范了分布式事务的模型设计。
DTP规范中主要包含了AP、RM、TM三个部分其中AP是应用程序是事务发起和结束的地方RM是资源管理器主要负责管理每个数据库的连接数据源TM是事务管理器负责事务的全局管理包括事务的生命周期管理和资源的分配协调等。
<img src="https://static001.geekbang.org/resource/image/dc/67/dcbb483b62b1e0a51d03c7edfcf89767.jpg" alt="">
XA则规范了TM与RM之间的通信接口在TM与多个RM之间形成一个双向通信桥梁从而在多个数据库资源下保证ACID四个特性。
这里强调一下JTA是基于XA规范实现的一套Java事务编程接口是一种两阶段提交事务。我们可以通过[源码](https://github.com/nickliuchao/jta)简单了解下JTA实现的多数据源事务提交。
### 2. 二阶提交和三阶提交
XA规范实现的分布式事务属于二阶提交事务顾名思义就是通过两个阶段来实现事务的提交。
在第一阶段应用程序向事务管理器TM发起事务请求而事务管理器则会分别向参与的各个资源管理器RM发送事务预处理请求Prepare此时这些资源管理器会打开本地数据库事务然后开始执行数据库事务但执行完成后并不会立刻提交事务而是向事务管理器返回已就绪Ready或未就绪Not Ready状态。如果各个参与节点都返回状态了就会进入第二阶段。
<img src="https://static001.geekbang.org/resource/image/2a/95/2a1cf8f45675acac6fe07c172a36ec95.jpg" alt="">
到了第二阶段如果资源管理器返回的都是就绪状态事务管理器则会向各个资源管理器发送提交Commit通知资源管理器则会完成本地数据库的事务提交最终返回提交结果给事务管理器。
<img src="https://static001.geekbang.org/resource/image/59/d5/59734e1a229ceee9df4295d0901ce2d5.jpg" alt="">
在第二阶段中如果任意资源管理器返回了未就绪状态此时事务管理器会向所有资源管理器发送事务回滚Rollback通知此时各个资源管理器就会回滚本地数据库事务释放资源并返回结果通知。
<img src="https://static001.geekbang.org/resource/image/87/2f/8791dfe19fce916f77b6c5740bc32e2f.jpg" alt="">
但事实上,二阶事务提交也存在一些缺陷。
第一,在整个流程中,我们会发现各个资源管理器节点存在阻塞,只有当所有的节点都准备完成之后,事务管理器才会发出进行全局事务提交的通知,这个过程如果很长,则会有很多节点长时间占用资源,从而影响整个节点的性能。
一旦资源管理器挂了,就会出现一直阻塞等待的情况。类似问题,我们可以通过设置事务超时时间来解决。
第二,仍然存在数据不一致的可能性,例如,在最后通知提交全局事务时,由于网络故障,部分节点有可能收不到通知,由于这部分节点没有提交事务,就会导致数据不一致的情况出现。
**而三阶事务3PC的出现就是为了减少此类问题的发生。**
3PC把2PC的准备阶段分为了准备阶段和预处理阶段在第一阶段只是询问各个资源节点是否可以执行事务而在第二阶段所有的节点反馈可以执行事务才开始执行事务操作最后在第三阶段执行提交或回滚操作。并且在事务管理器和资源管理器中都引入了超时机制如果在第三阶段资源节点一直无法收到来自资源管理器的提交或回滚请求它就会在超时之后继续提交事务。
所以3PC可以通过超时机制避免管理器挂掉所造成的长时间阻塞问题但其实这样还是无法解决在最后提交全局事务时由于网络故障无法通知到一些节点的问题特别是回滚通知这样会导致事务等待超时从而默认提交。
### 3. 事务补偿机制TCC
以上这种基于XA规范实现的事务提交由于阻塞等性能问题有着比较明显的低性能、低吞吐的特性。所以在抢购活动中使用该事务很难满足系统的并发性能。
除了性能问题JTA只能解决同一服务下操作多数据源的分布式事务问题换到微服务架构下可能存在同一个事务操作分别在不同服务上连接数据源提交数据库操作。
而TCC正是为了解决以上问题而出现的一种分布式事务解决方案。TCC采用最终一致性的方式实现了一种柔性分布式事务与XA规范实现的二阶事务不同的是TCC的实现是基于服务层实现的一种二阶事务提交。
TCC分为三个阶段即Try、Confirm、Cancel三个阶段。
<img src="https://static001.geekbang.org/resource/image/23/a9/23f68980870465ba6c00c0f2619fcfa9.jpg" alt="">
- Try阶段主要尝试执行业务执行各个服务中的Try方法主要包括预留操作
- Confirm阶段确认Try中的各个方法执行成功然后通过TM调用各个服务的Confirm方法这个阶段是提交阶段
- Cancel阶段当在Try阶段发现其中一个Try方法失败例如预留资源失败、代码异常等则会触发TM调用各个服务的Cancel方法对全局事务进行回滚取消执行业务。
以上执行只是保证Try阶段执行时成功或失败的提交和回滚操作你肯定会想到如果在Confirm和Cancel阶段出现异常情况那TCC该如何处理呢此时TCC会不停地重试调用失败的Confirm或Cancel方法直到成功为止。
但TCC补偿性事务也有比较明显的缺点那就是对业务的侵入性非常大。
首先我们需要在业务设计的时候考虑预留资源然后我们需要编写大量业务性代码例如Try、Confirm、Cancel方法最后我们还需要为每个方法考虑幂等性。这种事务的实现和维护成本非常高但综合来看这种实现是目前大家最常用的分布式事务解决方案。
### 4. 业务无侵入方案——Seata(Fescar)
Seata是阿里去年开源的一套分布式事务解决方案开源一年多已经有一万多star了可见受欢迎程度非常之高。
Seata的基础建模和DTP模型类似只不过前者是将事务管理器分得更细了抽出一个事务协调器Transaction Coordinator 简称TC主要维护全局事务的运行状态负责协调并驱动全局事务的提交或回滚。而TM则负责开启一个全局事务并最终发起全局提交或全局回滚的决议。如下图所示
<img src="https://static001.geekbang.org/resource/image/6a/83/6ac3de014819c54fe6904c938240b183.jpg" alt="">
按照[Github](https://github.com/seata/seata)中的说明介绍,整个事务流程为:
- TM 向 TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的 XID
- XID 在微服务调用链路的上下文中传播;
- RM 向 TC 注册分支事务,将其纳入 XID 对应全局事务的管辖;
- TM 向 TC 发起针对 XID 的全局提交或回滚决议;
- TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求。
Seata与其它分布式最大的区别在于它在第一提交阶段就已经将各个事务操作commit了。Seata认为在一个正常的业务下各个服务提交事务的大概率是成功的这种事务提交操作可以节约两个阶段持有锁的时间从而提高整体的执行效率。
那如果在第一阶段就已经提交了事务,那我们还谈何回滚呢?
Seata将RM提升到了服务层通过JDBC数据源代理解析SQL把业务数据在更新前后的数据镜像组织成回滚日志利用本地事务的 ACID 特性,将业务数据的更新和回滚日志的写入在同一个本地事务中提交。
如果TC决议要全局回滚会通知RM进行回滚操作通过XID找到对应的回滚日志记录通过回滚记录生成反向更新SQL进行更新回滚操作。
以上我们可以保证一个事务的原子性和一致性,但隔离性如何保证呢?
Seata设计通过事务协调器维护的全局写排它锁来保证事务间的写隔离而读写隔离级别则默认为未提交读的隔离级别。
## 总结
在同服务多数据源操作不同数据库的情况下我们可以使用基于XA规范实现的分布式事务在Spring中有成熟的JTA框架实现了XA规范的二阶事务提交。事实上二阶事务除了性能方面存在严重的阻塞问题之外还有可能导致数据不一致我们应该慎重考虑使用这种二阶事务提交。
在跨服务的分布式事务下我们可以考虑基于TCC实现的分布式事务常用的中间件有TCC-Transaction。TCC也是基于二阶事务提交原理实现的但TCC的二阶事务提交是提到了服务层实现。TCC方式虽然提高了分布式事务的整体性能但也给业务层带来了非常大的工作量对应用服务的侵入性非常强但这是大多数公司目前所采用的分布式事务解决方案。
Seata是一种高效的分布式事务解决方案设计初衷就是解决分布式带来的性能问题以及侵入性问题。但目前Seata的稳定性有待验证例如在TC通知RM开始提交事务后TC与RM的连接断开了或者RM与数据库的连接断开了都不能保证事务的一致性。
## 思考题
Seata在第一阶段已经提交了事务那如果在第二阶段发生了异常要回滚到Before快照前别的线程若是更新了数据且业务走完了那么恢复的这个快照不就是脏数据了吗但事实上Seata是不会出现这种情况的你知道它是怎么做到的吗
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

View File

@@ -0,0 +1,184 @@
<audio id="audio" title="43 | 如何使用缓存优化系统性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5b/a0/5b27c095eca8e6d40df953571f6a3ca0.mp3"></audio>
你好,我是刘超。
缓存是我们提高系统性能的一项必不可少的技术,无论是前端、还是后端,都应用到了缓存技术。前端使用缓存,可以降低多次请求服务的压力;后端使用缓存,可以降低数据库操作的压力,提升读取数据的性能。
今天我们将从前端到服务端,系统了解下各个层级的缓存实现,并分别了解下各类缓存的优缺点以及应用场景。
## 前端缓存技术
如果你是一位Java开发工程师你可能会想我们有必要去了解前端的技术吗
不想当将军的士兵不是好士兵,作为一个技术人员,不想做架构师的开发不是好开发。作为架构工程师的话,我们就很有必要去了解前端的知识点了,这样有助于我们设计和优化系统。前端做缓存,可以缓解服务端的压力,减少带宽的占用,同时也可以提升前端的查询性能。
### 1. 本地缓存
平时使用拦截器例如Fiddler或浏览器Debug时我们经常会发现一些接口返回304状态码+ Not Modified字符串如下图中的极客时间Web首页。
<img src="https://static001.geekbang.org/resource/image/5a/7b/5ae757f7c5b12901d4422b5722c0647b.png" alt="">
如果我们对前端缓存技术不了解就很容易对此感到困惑。浏览器常用的一种缓存就是这种基于304响应状态实现的本地缓存了通常这种缓存被称为协商缓存。
协商缓存,顾名思义就是与服务端协商之后,通过协商结果来判断是否使用本地缓存。
一般协商缓存可以基于请求头部中的If-Modified-Since字段与返回头部中的Last-Modified字段实现也可以基于请求头部中的If-None-Match字段与返回头部中的ETag字段来实现。
两种方式的实现原理是一样的,前者是基于时间实现的,后者是基于一个唯一标识实现的,相对来说后者可以更加准确地判断文件内容是否被修改,避免由于时间篡改导致的不可靠问题。下面我们再来了解下整个缓存的实现流程:
- 当浏览器第一次请求访问服务器资源时服务器会在返回这个资源的同时在Response头部加上ETag唯一标识这个唯一标识的值是根据当前请求的资源生成的
- 当浏览器再次请求访问服务器中的该资源时会在Request头部加上If-None-Match字段该字段的值就是Response头部加上ETag唯一标识
- 服务器再次收到请求后会根据请求中的If-None-Match值与当前请求的资源生成的唯一标识进行比较如果值相等则返回304 Not Modified如果不相等则在Response头部加上新的ETag唯一标识并返回资源
- 如果浏览器收到304的请求响应状态码则会从本地缓存中加载资源否则更新资源。
本地缓存中除了这种协商缓存,还有一种就是强缓存的实现。
强缓存指的是只要判断缓存没有过期则直接使用浏览器的本地缓存。如下图中返回的是200状态码但在size项中标识的是memory cache。
<img src="https://static001.geekbang.org/resource/image/0a/48/0a169df1141f31326b4b6ab331ab3748.png" alt="">
强缓存是利用Expires或者Cache-Control这两个HTTP Response Header实现的它们都用来表示资源在客户端缓存的有效期。
Expires是一个绝对时间而Cache-Control是一个相对时间即一个过期时间大小与协商缓存一样基于Expires实现的强缓存也会因为时间问题导致缓存管理出现问题。我建议使用Cache-Control来实现强缓存。具体的实现流程如下
- 当浏览器第一次请求访问服务器资源时服务器会在返回这个资源的同时在Response头部加上Cache-ControlCache-Control中设置了过期时间大小
- 浏览器再次请求访问服务器中的该资源时会先通过请求资源的时间与Cache-Control中设置的过期时间大小来计算出该资源是否过期如果没有则使用该缓存否则请求服务器
- 服务器再次收到请求后会再次更新Response头部的Cache-Control。
### 2. 网关缓存
除了以上本地缓存我们还可以在网关中设置缓存也就是我们熟悉的CDN。
CDN缓存是通过不同地点的缓存节点缓存资源副本当用户访问相应的资源时会调用最近的CDN节点返回请求资源这种方式常用于视频资源的缓存。
## 服务层缓存技术
前端缓存一般用于缓存一些不常修改的常量数据或一些资源文件,大部分接口请求的数据都缓存在了服务端,方便统一管理缓存数据。
服务端缓存的初衷是为了提升系统性能。例如数据库由于并发查询压力过大可以使用缓存减轻数据库压力在后台管理中的一些报表计算类数据每次请求都需要大量计算消耗系统CPU资源我们可以使用缓存来保存计算结果。
服务端的缓存也分为进程缓存和分布式缓存在Java中进程缓存就是JVM实现的缓存常见的有我们经常使用的容器类ArrayList、ConcurrentHashMap等分布式缓存则是基于Redis实现的缓存。
### 1. 进程缓存
对于进程缓存虽然数据的存取会更加高效但JVM的堆内存数量是有限的且在分布式环境下很难同步各个服务间的缓存更新所以我们一般缓存一些数据量不大、更新频率较低的数据。常见的实现方式如下
```
//静态常量
public final staticS String url = &quot;https://time.geekbang.org&quot;;
//list容器
public static List&lt;String&gt; cacheList = new Vector&lt;String&gt;();
//map容器
private static final Map&lt;String, Object&gt; cacheMap= new ConcurrentHashMap&lt;String, Object&gt;();
```
除了Java自带的容器可以实现进程缓存我们还可以基于Google实现的一套内存缓存组件Guava Cache来实现。
Guava Cache适用于高并发的多线程缓存它和ConcurrentHashMap一样都是基于分段锁实现的并发缓存。
Guava Cache同时也实现了数据淘汰机制当我们设置了缓存的最大值后当存储的数据超过了最大值时它就会使用LRU算法淘汰数据。我们可以通过以下代码了解下Guava Cache的实现
```
public class GuavaCacheDemo {
public static void main(String[] args) {
Cache&lt;String,String&gt; cache = CacheBuilder.newBuilder()
.maximumSize(2)
.build();
cache.put(&quot;key1&quot;,&quot;value1&quot;);
cache.put(&quot;key2&quot;,&quot;value2&quot;);
cache.put(&quot;key3&quot;,&quot;value3&quot;);
System.out.println(&quot;第一个值:&quot; + cache.getIfPresent(&quot;key1&quot;));
System.out.println(&quot;第二个值:&quot; + cache.getIfPresent(&quot;key2&quot;));
System.out.println(&quot;第三个值:&quot; + cache.getIfPresent(&quot;key3&quot;));
}
}
```
运行结果:
```
第一个值null
第二个值value2
第三个值value3
```
那如果我们的数据量比较大且数据更新频繁又是在分布式部署的情况下想要使用JVM堆内存作为缓存这时我们又该如何去实现呢
Ehcache是一个不错的选择Ehcache经常在Hibernate中出现主要用来缓存查询数据结果。Ehcache是Apache开源的一套缓存管理类库是基于JVM堆内存实现的缓存同时具备多种缓存失效策略支持磁盘持久化以及分布式缓存机制。
### 2. 分布式缓存
由于高并发对数据一致性的要求比较严格我一般不建议使用Ehcache缓存有一致性要求的数据。对于分布式缓存我们建议使用Redis来实现Redis相当于一个内存数据库由于是纯内存操作又是基于单线程串行实现查询性能极高读速度超过了10W次/秒。
Redis除了高性能的特点之外还支持不同类型的数据结构常见的有string、list、set、hash等还支持数据淘汰策略、数据持久化以及事务等。
两种缓存讲完了,接下来我们看看其中可能出现的问题。
### 数据库与缓存数据一致性问题
在查询缓存数据时,我们会先读取缓存,如果缓存中没有该数据,则会去数据库中查询,之后再放入到缓存中。
当我们的数据被缓存之后,一旦数据被修改(修改时也是删除缓存中的数据)或删除,我们就需要同时操作缓存和数据库。这时,就会存在一个数据不一致的问题。
例如在并发情况下当A操作使得数据发生删除变更那么该操作会先删除缓存中的数据之后再去删除数据库中的数据此时若是还没有删除成功另外一个请求查询操作B进来了发现缓存中已经没有了数据则会去数据库中查询此时发现有数据B操作获取之后又将数据存放在了缓存中随后数据库的数据又被删除了。此时就出现了数据不一致的情况。
那如果先删除数据库,再删除缓存呢?
我们可以试一试。在并发情况下当A操作使得数据发生删除变更那么该操作会先删除了数据库的操作接下来删除缓存失败了那么缓存中的数据没有被删除而数据库的数据已经被删除了同样会存在数据不一致的问题。
所以,我们还是需要先做缓存删除操作,再去完成数据库操作。那我们又该如何避免高并发下,数据更新删除操作所带来的数据不一致的问题呢?
通常的解决方案是如果我们需要使用一个线程安全队列来缓存更新或删除的数据当A操作变更数据时会先删除一个缓存数据此时通过线程安全的方式将缓存数据放入到队列中并通过一个线程进行数据库的数据删除操作。
当有另一个查询请求B进来时如果发现缓存中没有该值则会先去队列中查看该数据是否正在被更新或删除如果队列中有该数据则阻塞等待直到A操作数据库成功之后唤醒该阻塞线程再去数据库中查询该数据。
但其实这种实现也存在很多缺陷,例如,可能存在读请求被长时间阻塞,高并发时低吞吐量等问题。所以我们在考虑缓存时,如果数据更新比较频繁且对数据有一定的一致性要求,我通常不建议使用缓存。
### 缓存穿透、缓存击穿、缓存雪崩
对于分布式缓存实现大数据的存储,除了数据不一致的问题以外,还有缓存穿透、缓存击穿、缓存雪崩等问题,我们平时实现缓存代码时,应该充分、全面地考虑这些问题。
缓存穿透是指大量查询没有命中缓存,直接去到数据库中查询,如果查询量比较大,会导致数据库的查询流量大,对数据库造成压力。
通常有两种解决方案一种是将第一次查询的空值缓存起来同时设置一个比较短的过期时间。但这种解决方案存在一个安全漏洞就是当黑客利用大量没有缓存的key攻击系统时缓存的内存会被占满溢出。
另一种则是使用布隆过滤算法BloomFilter该算法可以用于检查一个元素是否存在返回结果有两种可能存在或一定不存在。这种情况很适合用来解决故意攻击系统的缓存穿透问题在最初缓存数据时也将key值缓存在布隆过滤器的BitArray中当有key值查询时对于一定不存在的key值我们可以直接返回空值对于可能存在的key值我们会去缓存中查询如果没有值再去数据库中查询。
BloomFilter的实现原理与Redis中的BitMap类似首先初始化一个m长度的数组并且每个bit初始化值都是0当插入一个元素时会使用n个hash函数来计算出n个不同的值分别代表所在数组的位置然后再将这些位置的值设置为1。
假设我们插入两个key值分别为20,28的元素通过两次哈希函数取模后的值分别为4,9以及14,19因此4,9以及14,19都被设置为1。
<img src="https://static001.geekbang.org/resource/image/d9/a3/d939bf92331838da581c4b500e7473a3.jpg" alt="">
那为什么说BloomFilter返回的结果是可能存在和一定不存在呢
假设我们查找一个元素25通过n次哈希函数取模后的值为1,9,14。此时在BitArray中肯定是不存在的。而当我们查找一个元素21的时候n次哈希函数取模后的值为9,14此时会返回可能存在的结果但实际上是不存在的。
BloomFilter不允许删除任何元素的为什么假设以上20,25,28三个元素都存在于BitArray中取模的位置值分别为4,9、1,9,14以及14,19如果我们要删除元素25此时需要将1,9,14的位置都置回0这样就影响20,28元素了。
因此BloomFilter是不允许删除任何元素的这样会导致已经删除的元素依然返回可能存在的结果也会影响BloomFilter判断的准确率解决的方法则是重建一个BitArray。
那什么缓存击穿呢在高并发情况下同时查询一个key时key值由于某种原因突然失效设置过期时间或缓存服务宕机就会导致同一时间这些请求都去查询数据库了。这种情况经常出现在查询热点数据的场景中。通常我们会在查询数据库时使用排斥锁来实现有序地请求数据库减少数据库的并发压力。
缓存雪崩则与缓存击穿差不多,区别就是失效缓存的规模。雪崩一般是指发生大规模的缓存失效情况,例如,缓存的过期时间同一时间过期了,缓存服务宕机了。对于大量缓存的过期时间同一时间过期的问题,我们可以采用分散过期时间来解决;而针对缓存服务宕机的情况,我们可以采用分布式集群来实现缓存服务。
## 总结
从前端到后端,对于一些不常变化的数据,我们都可以将其缓存起来,这样既可以提高查询效率,又可以降低请求后端的压力。对于前端来说,一些静态资源文件都是会被缓存在浏览器端,除了静态资源文件,我们还可以缓存一些常量数据,例如商品信息。
服务端的缓存包括了JVM的堆内存作为缓存以及Redis实现的分布式缓存。如果是一些不常修改的数据数据量小且对缓存数据没有严格的一致性要求我们就可以使用堆内存缓存数据这样既实现简单查询也非常高效。如果数据量比较大且是经常被修改的数据或对缓存数据有严格的一致性要求我们就可以使用分布式缓存来存储。
在使用后端缓存时,我们应该注意数据库和缓存数据的修改导致的数据不一致问题,如果对缓存与数据库数据有非常严格的一致性要求,我就不建议使用缓存了。
同时,我们应该针对大量请求缓存的接口做好预防工作,防止查询缓存的接口出现缓存穿透、缓存击穿和缓存雪崩等问题。
## 思考题
在基于Redis实现的分布式缓存中我们更新数据时为什么建议直接将缓存中的数据删除而不是更新缓存中的数据呢
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

View File

@@ -0,0 +1,143 @@
<audio id="audio" title="44 | 记一次双十一抢购性能瓶颈调优" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/e2/3ff72ccb7a6d2bc429b9e74a714102e2.mp3"></audio>
你好,我是刘超。今天我们来聊聊双十一的那些事儿,由于场景比较复杂,这一讲的出发点主要是盘点各个业务中高频出现的性能瓶颈,给出相应的优化方案,但优化方案并没有一一展开,深度讲解其具体实现。你可以结合自己在这个专栏的所学和日常积累,有针对性地在留言区提问,我会一一解答。下面切入正题。
每年的双十一都是很多研发部门最头痛的节日,由于这个节日比较特殊,公司一般都会准备大量的抢购活动,相应的瞬时高并发请求对系统来说是个不小的考验。
还记得我们公司商城第一次做双十一抢购活动优惠力度特别大购买量也很大提交订单的接口TPS一度达到了10W。在首波抢购时后台服务监控就已经显示服务器的各项指标都超过了70%CPU更是一直处于400%4核CPU数据库磁盘I/O一直处于100%状态。由于瞬时写入日志量非常大,导致我们的后台服务监控在短时间内,无法实时获取到最新的请求监控数据,此时后台开始出现一系列的异常报警。
更严重的系统问题是出现在第二波的抢购活动中,由于第一波抢购时我们发现后台服务的压力比较大,于是就横向扩容了服务,但却没能缓解服务的压力,反而在第二波抢购中,我们的系统很快就出现了宕机。
这次活动暴露出来的问题很多。首先由于没有限流超过预期的请求量导致了系统卡顿其次我们是基于Redis实现了一个分布式锁分发抢购名额的功能但这个功能抛出了大量异常再次就是我们误判了横向扩容服务可以起到的作用其实第一波抢购的性能瓶颈是在数据库横向扩容服务反而又增加了数据库的压力起到了反作用最后就是在服务挂掉的情况下丢失了异步处理的业务请求。
接下来我会以上面的这个案例为背景,重点讲解抢购业务中的性能瓶颈该如何调优。
## 抢购业务流程
在进行具体的性能问题讨论之前,我们不妨先来了解下一个常规的抢购业务流程,这样方便我们更好地理解一个抢购系统的性能瓶颈以及调优过程。
- 用户登录后会进入到商品详情页面,此时商品购买处于倒计时状态,购买按钮处于置灰状态。
- 当购买倒计时间结束后,用户点击购买商品,此时用户需要排队等待获取购买资格,如果没有获取到购买资格,抢购活动结束,反之,则进入提交页面。
- 用户完善订单信息,点击提交订单,此时校验库存,并创建订单,进入锁定库存状态,之后,用户支付订单款。
- 当用户支付成功后,第三方支付平台将产生支付回调,系统通过回调更新订单状态,并扣除数据库的实际库存,通知用户购买成功。
<img src="https://static001.geekbang.org/resource/image/e4/e0/e47d6d3c0bcd5aa455252a045c9f52e0.jpg" alt="">
## 抢购系统中的性能瓶颈
熟悉了一个常规的抢购业务流程之后,我们再来看看抢购中都有哪些业务会出现性能瓶颈。
### 1. 商品详情页面
如果你有过抢购商品的经验,相信你遇到过这样一种情况,在抢购马上到来的时候,商品详情页面几乎是无法打开的。
这是因为大部分用户在抢购开始之前,会一直疯狂刷新抢购商品页面,尤其是倒计时一分钟内,查看商品详情页面的请求量会猛增。此时如果商品详情页面没有做好,就很容易成为整个抢购系统中的第一个性能瓶颈。
类似这种问题我们通常的做法是提前将整个抢购商品页面生成为一个静态页面并push到CDN节点并且在浏览器端缓存该页面的静态资源文件通过 CDN 和浏览器本地缓存这两种缓存静态页面的方式来实现商品详情页面的优化。
### 2. 抢购倒计时
在商品详情页面中,存在一个抢购倒计时,这个倒计时是服务端时间的,初始化时间需要从服务端获取,并且在用户点击购买时,还需要服务端判断抢购时间是否已经到了。
如果商品详情每次刷新都去后端请求最新的时间,这无疑将会把整个后端服务拖垮。我们可以改成初始化时间从客户端获取,每隔一段时间主动去服务端刷新同步一次倒计时,这个时间段是随机时间,避免集中请求服务端。这种方式可以避免用户主动刷新服务端的同步时间接口。
### 3. 获取购买资格
可能你会好奇,在抢购中我们已经通过库存数量限制用户了,那为什么会出现一个获取购买资格的环节呢?
我们知道,进入订单详情页面后,需要填写相关的订单信息,例如收货地址、联系方式等,在这样一个过程中,很多用户可能还会犹豫,甚至放弃购买。如果把这个环节设定为一定能购买成功,那我们就只能让同等库存的用户进来,一旦用户放弃购买,这些商品可能无法再次被其他用户抢购,会大大降低商品的抢购销量。
增加购买资格的环节,选择让超过库存的用户量进来提交订单页面,这样就可以保证有足够提交订单的用户量,确保抢购活动中商品的销量最大化。
获取购买资格这步的并发量会非常大还是基于分布式的通常我们可以通过Redis分布式锁来控制购买资格的发放。
### 4. 提交订单
由于抢购入口的请求量会非常大,可能会占用大量带宽,为了不影响提交订单的请求,我建议将提交订单的子域名与抢购子域名区分开,分别绑定不同网络的服务器。
用户点击提交订单,需要先校验库存,库存足够时,用户先扣除缓存中的库存,再生成订单。如果校验库存和扣除库存都是基于数据库实现的,那么每次都去操作数据库,瞬时的并发量就会非常大,对数据库来说会存在一定的压力,从而会产生性能瓶颈。与获取购买资格一样,我们同样可以通过分布式锁来优化扣除消耗库存的设计。
由于我们已经缓存了库存,所以在提交订单时,库存的查询和冻结并不会给数据库带来性能瓶颈。但在这之后,还有一个订单的幂等校验,为了提高系统性能,我们同样可以使用分布式锁来优化。
而保存订单信息一般都是基于数据库表来实现的在单表单库的情况下碰到大量请求特别是在瞬时高并发的情况下磁盘I/O、数据库请求连接数以及带宽等资源都可能会出现性能瓶颈。此时我们可以考虑对订单表进行分库分表通常我们可以基于userid字段来进行hash取模实现分库分表从而提高系统的并发能力。
### 5. 支付回调业务操作
在用户支付订单完成之后,一般会有第三方支付平台回调我们的接口,更新订单状态。
除此之外,还可能存在扣减数据库库存的需求。如果我们的库存是基于缓存来实现查询和扣减,那提交订单时的扣除库存就只是扣除缓存中的库存,为了减少数据库的并发量,我们会在用户付款之后,在支付回调的时候去选择扣除数据库中的库存。
此外,还有订单购买成功的短信通知服务,一些商城还提供了累计积分的服务。
在支付回调之后我们可以通过异步提交的方式实现订单更新之外的其它业务处理例如库存扣减、积分累计以及短信通知等。通常我们可以基于MQ实现业务的异步提交。
## 性能瓶颈调优
了解了各个业务流程中可能存在的性能瓶颈,我们再来讨论下,完成了常规的优化设计之后,商城还可能出现的一些性能问题,我们又该如何做进一步调优。
### 1. 限流实现优化
限流是我们常用的兜底策略,无论是倒计时请求接口,还是抢购入口,系统都应该对它们设置最大并发访问数量,防止超出预期的请求集中进入系统,导致系统异常。
通常我们是在网关层实现高并发请求接口的限流如果我们使用了Nginx做反向代理的话就可以在Nginx配置限流算法。Nginx是基于漏桶算法实现的限流这样做的好处是能够保证请求的实时处理速度。
Nginx中包含了两个限流模块[ngx_http_limit_conn_module](http://nginx.org/en/docs/http/ngx_http_limit_conn_module.html) 和 [ngx_http_limit_req_module](http://nginx.org/en/docs/http/ngx_http_limit_req_module.html)前者是用于限制单个IP单位时间内的请求数量后者是用来限制单位时间内所有IP的请求数量。以下分别是两个限流的配置
```
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
location / {
limit_conn addr 1;
}
```
```
http {
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
server {
location / {
limit_req zone=one burst=5 nodelay;
}
}
```
在网关层我们还可以通过Lua编写OpenResty来实现一套限流功能也可以通过现成的Kong安装插件来实现。除了网关层的限流之外我们还可以基于服务层实现接口的限流通过Zuul RateLimit或Guava RateLimiter实现。
### 2. 流量削峰
瞬间有大量请求进入到系统后台服务之后首先是要通过Redis分布式锁获取购买资格这个时候我们看到了大量的“JedisConnectionException Could not get connection from pool”异常。
这个异常是一个Redis连接异常由于我们当时的Redis集群是基于哨兵模式部署的哨兵模式部署的Redis也是一种主从模式我们在写Redis的时候都是基于主库来实现的在高并发操作一个Redis实例就很容易出现性能瓶颈。
你可能会想到使用集群分片的方式来实现但对于分布式锁来说集群分片的实现只会增加性能消耗这是因为我们需要基于Redission的红锁算法实现需要对集群的每个实例进行加锁。
后来我们使用Redission插件替换Jedis插件由于Jedis的读写I/O操作还是阻塞式的方法调用都是基于同步实现而Redission底层是基于Netty框架实现的读写I/O是非阻塞I/O操作且方法调用是基于异步实现。
但在瞬时并发非常大的情况下依然会出现类似问题此时我们可以考虑在分布式锁前面新增一个等待队列减缓抢购出现的集中式请求相当于一个流量削峰。当请求的key值放入到队列中请求线程进入阻塞状态当线程从队列中获取到请求线程的key值时就会唤醒请求线程获取购买资格。
### 3. 数据丢失问题
无论是服务宕机还是异步发送给MQ都存在请求数据丢失的可能。例如当第三方支付回调系统时写入订单成功了此时通过异步来扣减库存和累计积分如果应用服务刚好挂掉了MQ还没有存储到该消息那即使我们重启服务这条请求数据也将无法还原。
重试机制是还原丢失消息的一种解决方案。在以上的回调案例中,我们可以在写入订单时,同时在数据库写入一条异步消息状态,之后再返回第三方支付操作成功结果。在异步业务处理请求成功之后,更新该数据库表中的异步消息状态。
假设我们重启服务那么系统就会在重启时去数据库中查询是否有未更新的异步消息如果有则重新生成MQ业务处理消息供各个业务方消费处理丢失的请求数据。
## 总结
减少抢购中操作数据库的次数,缩短抢购流程,是抢购系统设计和优化的核心点。
抢购系统的性能瓶颈主要是在数据库,即使我们对服务进行了横向扩容,当流量瞬间进来,数据库依然无法同时响应处理这么多的请求操作。我们可以对抢购业务表进行分库分表,通过提高数据库的处理能力,来提升系统的并发处理能力。
除此之外,我们还可以分散瞬时的高并发请求,流量削峰是最常用的方式,用一个队列,让请求排队等待,然后有序且有限地进入到后端服务,最终进行数据库操作。当我们的队列满了之后,可以将溢出的请求放弃,这就是限流了。通过限流和削峰,可以有效地保证系统不宕机,确保系统的稳定性。
## 思考题
在提交了订单之后会进入到支付阶段,此时系统是冻结了库存的,一般我们会给用户一定的等待时间,这样就很容易出现一些用户恶意锁库存,导致抢到商品的用户没办法去支付购买该商品。你觉得该怎么优化设计这个业务操作呢?
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
<img src="https://static001.geekbang.org/resource/image/bb/67/bbe343640d6b708832c4133ec53ed967.jpg" alt="unpreview">