悲观锁、乐观锁、MySQL和Redis

这个话题算是很多了,关键字:“悲观锁”,“乐观锁”,“Pessimistic and optimistic locking”,相关文章一搜一大把,这里我也懒得再写一遍,关于原理复制粘贴就差不多了,重点是自己做了一遍测试,深切的体会了不同锁的区别。
lock.png

References:

先从为什么需要锁开始说起吧,举个栗子:商品的超发现象
本文基于同一环境测试,这里先简单介绍下环境:

  • Win10 i5-8500 16GB
  • JDK8u162
  • Eclipse
  • Redis
  • MariaDB 10.3.12
    然后是数据库结构,其实也没啥说的,就是最简单的那种,毕竟测试嘛。
    product.png

purchase_record.png

以上分别是商品表和购买记录表。各种锁测试的商品数量都为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)

    然后查看表信息
    Snipaste_2019-05-31_18-11-42.png

Snipaste_2019-05-31_18-11-53.png

可以看到耗时45秒,有30008条购买记录,库存变成负数了,也就是说多发了8件商品。看下超发的原因
Snipaste_2019-06-04_17-18-01.png

超发现象的根本在于共享的数据被多个线程修改,多个线程同时对同一数据操作无法保证执行的顺序,错误的顺序导致结果错误。为了克服这个问题,各种锁的解决方案就被提出来了,下面来看看这些锁。

  • 悲观锁
    悲观锁是在数据库事务读取到产品后就直接锁定,不允许别的线程进行读写操作,直至当前事务完成或回滚才释放锁,这样就不会有超发的问题了,简单粗暴。看下实际测试:

    SELECT
    *
    FROM t_product
    WHERE id = #{id,jdbcType=BIGINT}
    FOR UPDATE

    Snipaste_2019-05-31_18-20-55.png

Snipaste_2019-05-31_18-21-09.png

耗时76秒,共30000条记录,库存为0。没有发生超发现象,但是耗时增加了,这也是加锁的缺点。

  • 乐观锁
    从上面可以看出,悲观锁可以解决并发下的超发现象,但却不是一个高效的方案。为了提高性能,有些人采用了乐观锁的方式。乐观锁是一种不使用数据库锁和不阻塞线程并发的方案。
    以本例来说,一开始读取库存,保存起来,称之为旧值,然后执行业务,需要更新此值时,会先将保存的旧值与当前数据库的值进行比较,如果旧值和当前的值一致,就说明执行业务这段时间这个值没有被修改过,否则就认为数据被修改过,当前操作就失败了并且不修改任何数据。
    Snipaste_2019-06-04_19-58-16.png

这个机制就是CAS,Compare and Swap。看似CAS非常的巧妙,但是又会导致ABA问题,那什么是ABA?此例来说就比如:线程1读取了库存值为A,然后线程2也读取了A,并且线程2将值修改为B,接着线程2又读取了B,并且将值改回A。过了一段时间线程1执行,发现值为A没问题,于是提交了修改。虽然前后都是A,但是这个值已经发生过变化,如果在将值改为B期间执行定时任务的统计呢?是不是数据就错了?可能例子不是很恰当,但这只是希望你能了解这种情况。可以查看参考链接中的文章了解ABA的问题。
Snipaste_2019-06-04_20-25-51.png

那有没有办法解决ABA呢?当然有了,没什么能难倒伟大的劳动人民。
典型的是增加版本号(version),并且规定操作共享值无论业务正常、回退还是异常,版本号只能递增,不能递减。
Snipaste_2019-06-04_20-26-07.png

那实际看一下能否解决超发问题:

UPDATE t_product
SET
version = version + 1,
stock = stock - #{quantity}
WHERE id = #{id,jdbcType=BIGINT}
AND version = #{version}

Snipaste_2019-06-04_15-10-23.png

在进行了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的购买记录这里就不管了,不是本文讨论内容。
那看一下测试结果吧:
Snipaste_2019-06-05_10-13-59.png

同样还是30000的库存,35000请求总数,1000的并发。首先是商品信息,库存为0,说明没有超发。再看下购买记录
Snipaste_2019-06-05_10-15-04.png

Snipaste_2019-06-05_10-15-30.png

购买记录是30000条,和预期一致,30000条的处理时间为40秒??这么久都跟无锁差不多了。表示怀疑,然后就把ab测试的数据导出来并用gnuplot生成折线图看下
benchmark1.png

可以看到前期比较稳定,但是超过32000左右的时候响应时间突然增加。我后来又试了下减少请求总数到20000、15000但是结果不是很稳定,猜测是网络的原因。由于树莓派和电脑是通过路由器交互的,而且都是无线传输,波动可能比较大,那在电脑上搞一个ab测下。
结果如下:
无锁,耗时58秒▼
Snipaste_2019-06-05_14-08-20.png

悲观锁,耗时97秒▼
Snipaste_2019-06-05_14-13-28.png

乐观锁,耗时110秒▼
Snipaste_2019-06-05_14-17-29.png

Redis,耗时21秒▼
Snipaste_2019-06-05_14-24-06.png

可以看出各种锁的基本特征已经展示出来,受各种原因的影响,虽然Redis没有达到预期的值,但也展示出来性能差距了。
Redis虽然快,但不是持久化存储,需要自己另行保存数据到数据库。而且操作不当容易引发数据丢失,因此建议使用单独的服务器并做好备份和容灾措施。

那个人感受最多的是,其实大部分情况数据库锁完全够用了,就以我刚开始用树莓派测试并发来说,无锁45秒,悲观锁76秒,Redis40秒。其实网络的传输使不同锁之间差距变小了,因此并发低的系统根本不用考虑用什么锁,能解决问题就行,反正是不会达到瓶颈的。在流量大的时候不同锁之间的差别就体现出来了,要根据不同情况选择合适的锁。而且理论终归是理论,是比较极限的情况,具体能提升多少性能还得实际测试才知道。

标签: none

添加新评论

ali-01.gifali-58.gifali-09.gifali-23.gifali-04.gifali-46.gifali-57.gifali-22.gifali-38.gifali-13.gifali-10.gifali-34.gifali-06.gifali-37.gifali-42.gifali-35.gifali-12.gifali-30.gifali-16.gifali-54.gifali-55.gifali-59.gif

加载中……