背景
一般来说,在对数据进行“加锁”时,程序首先需要通过获取(acquire)锁来得到对数据进行排他性访问的能力,然后才能对数据执行一系列操作,最后还要将锁释放(release)给其他程序。对于能够被多个线程访问的 共享内存数据结构(shared-memory data structure) 来说,这种“先获取锁,然后执行操作,最后释放锁”的动作非常常见。
Redis 使用 WATCH
命令来代替对数据进行加锁,因为 WATCH 只会在数据被其他客户端抢先修改了的情况下通知执行了这个命令的客户端,而不会阻止其他客户端对数据进行修改,所以这个命令被称为 乐观锁(optimistic locking)。
分布式锁也有类似上面描述的动作,但这种锁既不是给同一个进程中的多个线程使用,也不是给同一台机器上的多个进程使用,而是由不同机器上的不同 Redis 客户端进行获取和释放的。
何时使用以及是否使用 WATCH 或者锁取决于 给定的应用程序。
下面会说明“为什么使用 WATCH 命令来监视被频繁访问的键可能引起性能问题”,还会展示构建一个锁的详细步骤,并最终在某些情况下使用锁去代替 WATCH 命令。
Redis 自带乐观锁 Watch 的使用和高负载情况下的性能分析。(市场在重负载的情况下运行30秒的性能)
为了展示锁对于性能拓展的必要性,我们会模拟市场在3种不同负载情况下的性能表现,这3种情况分别是:
- 1个玩家出售商品,另1个玩家购买商品
- 5个玩家出售商品,另1个玩家购买商品
- 5个玩家出售商品,另外5个玩家购买商品
数据结构
程序分别使用散列(Hash)、集合(Set)和有序集合(ZSet)来表示用户信息(users)和用户包裹(inventory)的结构:
- 用户信息存储在一个散列里面,散列的各个键值对分别记录用户的姓名、用户拥有的钱数等属性。
- 用户包裹使用一个集合来表示,它记录了包裹里面每件商品的唯一编号。
- 为了将被销售的商品的全部信息都存储在市场里面,我们将商品的ID和卖家的ID拼接起来,并将拼接的结果用作成员存储到市场有序集合(ZSET)里面,而商品的售价则用作成员的分值。
market: | zseet |
---|---|
ItemA.4 | 35 |
ItemC.7 | 48 |
ItemE.2 | 60 |
ItemG.3 | 73 |
users:17 | hash |
---|---|
name | Frank |
funds | 43 |
users:27 | hash |
---|---|
name | Bill |
funds | 125 |
inventory:17 | set |
---|---|
ItemL | |
ItemM | |
ItemN |
inventory:27 | Set |
---|---|
ItemO | |
ItemP | |
ItemQ |
1. 1个卖家,1个买家
1.1 数据结构和初始数据
|
|
1.2 主类
|
|
1.3 卖家内部类
|
|
1.4 买家内部类
|
|
1.5 执行结果
|
|
2. 5个卖家,1个卖家
2.1 数据结构和初始数据
|
|
2.2 主类
|
|
2.3 执行结果
|
|
3. 5个卖家,5个买家
3.1 数据结构和初始数据
|
|
3.2 主类
|
|
3.3 执行结果
|
|
4. 结果对比分析
30秒性能对比 | 上架商品数量 | 买入商品数量 | 购买重试次数 | 每次购买的平均等待时间 |
---|---|---|---|---|
1个卖家,1个买家 | 211883 | 44232 | 44291 | 0ms |
5个卖家,1个买家 | 99064 | 2012 | 30461 | 14ms |
5个卖家,5个买家 | 156291 | 352 | 17784 | 85ms |
根据上表模拟的结果显示,WATCH、MULTI 和 EXEC 组成的事务并不具有可拓展性,因为程序在尝试完成一个事务的时候,可能会因为事务执行失败而反复地进行重试。
保证数据的正确是一件非常重要的事情,但使用 WATCH 命令的做法并不完美。我们将用锁来解决这个问题。
注:数据和 《Redis实战》中给出的测试数据有出入