Redis 中的事务和锁机制
Redis 的事务机制是一种将多个命令打包执行的机制,可以确保这些命令要么全部执行成功,要么全部失败,没有中间状态。在 Redis 中,事务通过
MULTI
、EXEC
、DISCARD
和WATCH
四个命令来实现。
- MULTI: 这个命令用于标记事务的开始。一旦调用了
MULTI
,后续的命令不会立即执行,而是会被放入一个队列中等待执行。 - EXEC: 这个命令用于执行事务中的所有命令。一旦调用了
EXEC
,Redis 会按照命令的顺序执行队列中的所有命令。 - DISCARD: 如果在执行
EXEC
之前需要取消事务,可以使用DISCARD
命令。
这会清空事务队列并取消事务。 - WATCH: 该命令用于监视一个或多个键,一旦有其他客户端对这些键进行了修改,当前事务就会被打断。
WATCH
命令可以用于乐观锁的实现,即在事务执行前检查被监视键是否被其他客户端修改过。
multi、exec、discard
从输入 Multi 命令开始,输入的命令都会一次进入命令队列中,但不会执行,知道输入 Exec 后,Redis 会将之前的命令队列中的命令依次执行。
案例
组队阶段和执行阶段均未出错,执行成功!
组队阶段报错,执行失败!
执行阶段报错,部分执行成功!
为什么需要事务
经典的银行转账问题:
假设你有两个账户,分别是 account1 和 account2,你想要在它们之间进行资金转移。在这种情况下,你可以使用 Redis 事务确保转账是原子性的。
假设 account1 的余额为 100 元,account2的余额为50元。现在,你想要将10元从account1转移到 account2。以下是一个使用Redis事务的示例:
redis
MULTI
DECRBY account1 10 # 从 account1 中减少10元
INCRBY account2 10 # 在 account2 中增加10元
EXEC
上述例子中,DECRBY 和 INCRBY 命令分别用于在 account1 中减少10元和在 account2 中增加10元。这两个命令被包含在 MULTI 和 EXEC 之间,形成一个事务。如果执行事务时没有发生错误,那么转账将会以原子方式完成。如果在执行事务时发生了错误,整个事务会被回滚,确保不会发生部分转账。
事务冲突
先来看一个事务冲突的例子:
考虑一个简单的在线购物系统的场景,其中有一个库存系统来跟踪商品的库存量。在这个系统中,多个用户可能同时试图购买同一商品,这可能导致事务冲突。
- 初始状态: 商品A的库存为10。
- 用户A尝试购买:
- User A检查商品A的库存,发现库存为10。
- User A决定购买一个商品A,将库存减少1。
WATCH inventory:A
val = GET inventory:A
val = val - 1
MULTI
SET inventory:A $val
EXEC
- 用户B尝试购买(冲突发生):
- 在User A执行事务的过程中,User B也尝试购买商品A。
- User B检查商品A的库存,发现库存仍为10。
- User B决定购买一个商品A,将库存减少1。
WATCH inventory:A
val = GET inventory:A
val = val - 1
MULTI
SET inventory:A $val
EXEC
在这个例子中,由于User B在User A的事务执行期间也尝试购买商品A,两个事务之间发生了冲突。当User B执行事务时,由于被 WATCH
监视的键(inventory:A
)已经发生变化(被User A的事务修改了),User B的事务将会失败,避免了商品库存的错误减少。
悲观锁
悲观锁(Pessimistic Lock), 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
悲观锁适用于以下场景:
- 频繁写入场景: 当对共享资源进行频繁的写入操作时,悲观锁可以有效地防止多个事务同时修改同一资源,避免并发冲突。这对于要求严格一致性的系统是很重要的。
- 长事务场景: 在长事务的场景中,如果一个事务在执行期间需要对一些共享资源进行多次读取和写入,悲观锁可以确保在事务执行期间资源不被其他事务修改。
- 对资源修改敏感的场景: 当对共享资源的修改对系统产生重大影响时,悲观锁可以用于确保在修改期间其他事务不会干扰。这可以防止在关键时刻出现数据不一致的情况。
- 独占资源场景: 当某个操作需要独占一个资源时,悲观锁是一种有效的方式。例如,某个线程正在修改一个文件,为了防止其他线程同时修改,可以使用悲观锁。
- 避免并发冲突的场景: 在某些情况下,虽然悲观锁可能导致一定的性能损失,但为了避免并发冲突和保证数据的完整性,悲观锁仍然是一个合适的选择。
需要注意的是,悲观锁可能会引入性能开销,因为它在操作前会尝试获取锁,而其他事务可能需要等待。因此,在选择是否使用悲 观锁时,需要权衡一致性和性能之间的取舍,具体取决于应用程序的要求。
乐观锁
**乐观锁(Optimistic Lock), **顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,如 watch 就是一个轻量级的乐观锁操作,可以使用版本号等机制。
乐观锁适用于多读的应用类型,这样可以提高吞吐量。Redis就是利用这种check-and-set机制实现事务的。
乐观锁适用于以下场景:
- 高并发读取场景: 在读取操作比写入操作更频繁的情况下,乐观锁是一种较为适用的机制。多个事务可以同时读取相同的资源,而在写入时会检查是否有冲突。
- 短事务场景: 在事务执行时间较短,对共享资源的写入操作不频繁的情况下,乐观锁可以降低锁的争用,提高并发性能。
- 无锁算法: 一些分布式系统中采用无锁算法,其中乐观锁是一种常见的实现方式。在这种情况下,系统更侧重于尽量避免使用显式锁,而是通过版本号、时间戳等方式来处理并发访问。
- 数据冲突较少的场景: 如果在应用程序中,对于共享资源的写入冲突相对较少,并且系统可以通过检测冲突并进行重试来处理,那么乐观锁是一个合适的选择。
- 支持冲突检测和重试的场景: 乐观锁适用于能够检测到冲突,并在发生冲突时进行适当处理(例如,回滚事务或重新尝试操作)的场景。
总体来说,乐观锁更适合处理读操作频繁、写操作较少,并且系统能够容忍一定程度的冲突和重试的场景。在这样的环境中,乐观锁可以提高并发性能,避免了悲观锁可能引入的性能开销。
watch key [key …]
在执行multi之前,先执行watch key1 [key2],可以监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
举例:
127.0.0.1:6379> watch balance
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incrby balance 10
QUEUED
127.0.0.1:6379> exec
1) (integer) 11
127.0.0.1:6379> watch balance
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> decrby balance 10
QUEUED
127.0.0.1:6379> exec
(nil)
WATCH
在 Redis 中通常被归类为乐观锁
- 乐观锁: 在乐观锁的情况下,系统假定并发冲突的发生是不太可能的。因此,它允许多个事务同时访问资源,并在执行事务时检查是否有冲突。如果发现冲突,系统会回滚事务,要求重新尝试。
- 在使用
WATCH
时,Redis并不立即锁定资源。相反,它会监视一个或多个键,而在事务执行之前,不会阻止其他客户端对这些键进行修改。只有在执行事务时,Redis会检查被监视的键是否发生了变化,如果有变化,事务将被取消,需要重新尝试。
unwatch
取消WATCH命令对所有key 的监视。
如果在执行WATCH命令之后,EXEC命令或DISCARD命令先被执行了的话,那么就不需要再执行UNWATCH了。
Redis 事务的三大特性
- 单独的隔离操作
事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
- 没有隔离级别的概念
队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行。
- 不保证原子性
事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚。