悲观锁、乐观锁、MySQL和Redis
这个话题算是很多了,关键字:“悲观锁”,“乐观锁”,“Pessimistic and optimistic locking”,相关文章一搜一大把,这里我也懒得再写一遍,关于原理复制粘贴就差不多了,重点是自己做了一遍测试,深切的体会了不同锁的区别。
References:
- https://docs.jboss.org/jbossas/docs/Server_Configuration_Guide/4/html/TransactionJTA_Overview-Pessimistic_and_optimistic_locking.html jboss文档上关于乐观锁和悲观锁的解释
- https://segmentfault.com/a/1190000016611415
- https://www.cnblogs.com/549294286/p/3766717.html CAS和ABA问题
- 《深入浅出Spring Boot 2.x》
先从为什么需要锁开始说起吧,举个栗子:商品的超发现象
本文基于同一环境测试,这里先简单介绍下环境:
- Win10 i5-8500 16GB
- JDK8u162
- Eclipse
- Redis
- MariaDB 10.3.12
然后是数据库结构,其实也没啥说的,就是最简单的那种,毕竟测试嘛。
以上分别是商品表和购买记录表。各种锁测试的商品数量都为30000,测试工具为树莓派上的Apache Bench,树莓派系统为Raspbian(基于Debian),并发请求数量为1000,请求总数为35000。
不带锁的情况:
UPDATE t_product SET stock = stock - #{quantity} WHERE id = #{id,jdbcType=BIGINT}
开始测试,执行ab命令
root@raspberrypi:~# ab -n 35000 -c 1000 192.168.0.138:8080/purchase This is ApacheBench, Version 2.3 <$Revision: 1757674 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking 192.168.0.138 (be patient) Completed 3500 requests Completed 7000 requests Completed 10500 requests Completed 14000 requests Completed 17500 requests Completed 21000 requests Completed 24500 requests Completed 28000 requests Completed 31500 requests Completed 35000 requests Finished 35000 requests Server Software: Server Hostname: 192.168.0.138 Server Port: 8080 Document Path: /purchase Document Length: 49 bytes Concurrency Level: 1000 Time taken for tests: 53.216 seconds Complete requests: 35000 Failed requests: 0 Total transferred: 12250000 bytes HTML transferred: 1715000 bytes Requests per second: 657.70 [#/sec] (mean) Time per request: 1520.451 [ms] (mean) Time per request: 1.520 [ms] (mean, across all concurrent requests) Transfer rate: 224.80 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 3 229 224.1 132 3266 Processing: 214 1237 321.5 1294 3210 Waiting: 214 1237 321.4 1294 2934 Total: 292 1466 234.6 1448 4818 Percentage of the requests served within a certain time (ms) 50% 1448 66% 1515 75% 1569 80% 1606 90% 1712 95% 1827 98% 1953 99% 2113 100% 4818 (longest request)
然后查看表信息
可以看到耗时45秒,有30008条购买记录,库存变成负数了,也就是说多发了8件商品。看下超发的原因
超发现象的根本在于共享的数据被多个线程修改,多个线程同时对同一数据操作无法保证执行的顺序,错误的顺序导致结果错误。为了克服这个问题,各种锁的解决方案就被提出来了,下面来看看这些锁。
悲观锁
悲观锁是在数据库事务读取到产品后就直接锁定,不允许别的线程进行读写操作,直至当前事务完成或回滚才释放锁,这样就不会有超发的问题了,简单粗暴。看下实际测试:SELECT * FROM t_product WHERE id = #{id,jdbcType=BIGINT} FOR UPDATE
耗时76秒,共30000条记录,库存为0。没有发生超发现象,但是耗时增加了,这也是加锁的缺点。
- 乐观锁
从上面可以看出,悲观锁可以解决并发下的超发现象,但却不是一个高效的方案。为了提高性能,有些人采用了乐观锁的方式。乐观锁是一种不使用数据库锁和不阻塞线程并发的方案。
以本例来说,一开始读取库存,保存起来,称之为旧值,然后执行业务,需要更新此值时,会先将保存的旧值与当前数据库的值进行比较,如果旧值和当前的值一致,就说明执行业务这段时间这个值没有被修改过,否则就认为数据被修改过,当前操作就失败了并且不修改任何数据。
这个机制就是CAS,Compare and Swap。看似CAS非常的巧妙,但是又会导致ABA问题,那什么是ABA?此例来说就比如:线程1读取了库存值为A,然后线程2也读取了A,并且线程2将值修改为B,接着线程2又读取了B,并且将值改回A。过了一段时间线程1执行,发现值为A没问题,于是提交了修改。虽然前后都是A,但是这个值已经发生过变化,如果在将值改为B期间执行定时任务的统计呢?是不是数据就错了?可能例子不是很恰当,但这只是希望你能了解这种情况。可以查看参考链接中的文章了解ABA的问题。
那有没有办法解决ABA呢?当然有了,没什么能难倒伟大的劳动人民。
典型的是增加版本号(version),并且规定操作共享值无论业务正常、回退还是异常,版本号只能递增,不能递减。
那实际看一下能否解决超发问题:
UPDATE t_product
SET
version = version + 1,
stock = stock - #{quantity}
WHERE id = #{id,jdbcType=BIGINT}
AND version = #{version}
在进行了35000次请求后库存还剩很多,分析日志后发现很多sql都执行失败了,这里还是加了3次重试的结果。执行时间忘记截图了,我想大家也能猜到结果,没有锁的测试上面已经有了。乐观锁不使用数据库锁,不会造成线程阻塞,但是版本的冲突会造成大量sql执行失败,而且乐观锁也相对比较复杂。
关于悲观锁和乐观锁可以坐下简单的总结:
读的多,冲突几率小,乐观锁。
写的多,冲突几率大,悲观锁。
那不管是乐观锁和悲观锁,在总计35000请求和并发1000的情况下性能都是比较低的,因为数据库是一个写入磁盘的过程。所以现在高并发的情况下,很多都是使用Redis来解决问题,而由于Redis的命令方式计算比较弱,而且对事务支持不是很好,所以这里以Lua脚本的方式解决。
这里设计分为一下两步:
- 先使用Redis响应并发请求,这里不涉及数据库,因为内存的操作比磁盘快太多了。
- 因为Redis的存储不稳定,所以要及时的将数据保存到数据库中,可以采用定时任务保存购买记录。
脚本:
-- purchase.lua
-- 抢购商品脚本
local productSetKEY = KEYS[1]
local productPurchaseListKEY = KEYS[2]
local userId = ARGV[1]
local productId = ARGV[2]
local quantity = tonumber(ARGV[3])
local purchaseDate = ARGV[4]
-- 记录商品编号
redis.call('sadd', productSetKEY, productId)
-- 购买列表
local productPurchaseList = productPurchaseListKEY .. productId
-- 取出库存比较
local productKey = 'product_' .. productId
local stock = tonumber(redis.call('HGET', productKey, "stock"))
if (stock < quantity) then
return 0
else
-- 扣减库存
stock = stock - quantity
redis.call('HSET', productKey, "stock", tostring(stock))
end
-- 本次购买记录
local price = redis.call('HGET', productKey, 'price')
local i_price = tonumber(price)
local purchaseRecord = userId .. ',' .. quantity .. ',' .. price .. ',' .. purchaseDate
-- 保存记录
redis.call('RPUSH', productPurchaseList, purchaseRecord)
return 1
先是将商品库存信息保存到Redis中:
public boolean stockRedis(Product product) {
boolean result = false;
Map<String, Object> obj2Map = ConvertUtil.obj2Map(product);
if (obj2Map != null) {
// FIXME 应该设置Redis失效时间
redisTemplate.opsForHash().putAll("product_" + product.getId().toString(), obj2Map);
result = true;
}
return result;
}
然后是购买:
public boolean purchaseRedis(Long userId, Long productId, int quantity) {
RedisSerializer<String> argStringSerializer = redisTemplate.getStringSerializer();
GenericToStringSerializer<Long> resultSerializer = new GenericToStringSerializer<Long>(Long.class);
List<String> keyList = new ArrayList<>();
keyList.add(RedisScripts.PRODUCT_SCHEDULE_SET);
keyList.add(RedisScripts.PURCHASE_LIST);
String arg1 = userId.toString();
String arg2 = productId.toString();
String arg3 = String.valueOf(quantity);
String arg4 = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now());
Long execute = redisTemplate.execute(purchaseScript, argStringSerializer, resultSerializer, keyList, arg1, arg2, arg3, arg4);
return execute > 0;
}
那关于定时任务保存Redis的购买记录这里就不管了,不是本文讨论内容。
那看一下测试结果吧:
同样还是30000的库存,35000请求总数,1000的并发。首先是商品信息,库存为0,说明没有超发。再看下购买记录
购买记录是30000条,和预期一致,30000条的处理时间为40秒??这么久都跟无锁差不多了。表示怀疑,然后就把ab测试的数据导出来并用gnuplot生成折线图看下
可以看到前期比较稳定,但是超过32000左右的时候响应时间突然增加。我后来又试了下减少请求总数到20000、15000但是结果不是很稳定,猜测是网络的原因。由于树莓派和电脑是通过路由器交互的,而且都是无线传输,波动可能比较大,那在电脑上搞一个ab测下。
结果如下:
无锁,耗时58秒▼
悲观锁,耗时97秒▼
乐观锁,耗时110秒▼
Redis,耗时21秒▼
可以看出各种锁的基本特征已经展示出来,受各种原因的影响,虽然Redis没有达到预期的值,但也展示出来性能差距了。
Redis虽然快,但不是持久化存储,需要自己另行保存数据到数据库。而且操作不当容易引发数据丢失,因此建议使用单独的服务器并做好备份和容灾措施。
那个人感受最多的是,其实大部分情况数据库锁完全够用了,就以我刚开始用树莓派测试并发来说,无锁45秒,悲观锁76秒,Redis40秒。其实网络的传输使不同锁之间差距变小了,因此并发低的系统根本不用考虑用什么锁,能解决问题就行,反正是不会达到瓶颈的。在流量大的时候不同锁之间的差别就体现出来了,要根据不同情况选择合适的锁。而且理论终归是理论,是比较极限的情况,具体能提升多少性能还得实际测试才知道。