并发
这里针对Redis操作中的并发,有两个层面的意思:
- 在单进程中,同时多个线程执行Redis写操作
- 在同一个连接中
- 在不同的连接中
- 在多个进程中,同时执行Redis写操作,相当于上述场景同时存在多个
假设以下应用,对 hash 表执行以下操作(更新值):
- hget 读取指定 field 的 value
- 修改 value
- hset 重新写回
在单线程中执行上述操作是没有任何问题的,下面分析一下多线程的情况。
事务
在多线程环境中,当只有一个连接时,可能会出现以下问题:
- 线程A执行,步骤1完成,开始执行步骤2
- 线程B执行步骤1,此时读取的值仍然是旧值
- 线程A步骤2执行完成,使用hset更新了值
- 线程B执行步骤2和步骤3
如此一来,线程B更新的就不是最新的值,已经产生了数据竞争。
这种情况,可以使用事务来解决。
Redis中事务使用 multi…exec 指令来完成,在事务中的指令:
- 事务是一个被隔离的操作,事务中的方法都会被 Redis 进行序列化并按顺序执行,事务在执行的过程中不会被其他客户端发生的命令所打断
- 事务是一个原子性的操作,它要么全部执行,要么就什么都不执行
于是,使用了事务,线程之间的指令就会被序列化执行,不会再出现竞争的情况。
示例代码如下:
auto tx = redis.transaction();
tx.set("key", "1").incr("2").exec();
这样,set设置key和incr增加值的操作就是原子的。
但由于事务的所有操作都是在调用exec后执行的,所以读取操作不能放在事务中。
而如果先读取,再执行事务的话,可能会有其他线程在执行事务中再次读取,又造成数据一致性问题。
所以还要使用 WATCH。
乐观锁
想要原子地完成获取值、修改值、更新值操作,需要借助WATCH指令实现一个乐观锁的功能。
WATCH用于监视指定的key是否被改变,如果被改变,事务会执行失败,否则才执行成功。
这样就保证了,在当前线程更新值时,如果有其他线程更新了值,就拒绝执行,一般应用层重试即可。
示例代码如下:
auto redis = Redis(opts, pool_opts);
// If the watched key has been modified by other clients, the transaction might fail.
// So we need to retry the transaction in a loop.
while (true) {
try {
// Create a transaction without creating a new connection.
auto tx = redis.transaction(false, false);
// Create a Redis object from the Transaction object. Both objects share the same connection.
auto r = tx.redis();
// Watch a key.
r.watch("key");
// Get the old value.
auto val = r.hget("key", "field");
auto num = 0;
if (val) {
num = std::stoi(*val);
} // else use default value, i.e. 0.
// Incr value.
++num;
// Execute the transaction.
auto replies = tx.hset("key", "field", std::to_string(num)).exec();
// Transaction has been executed successfully. Check the result and break.
assert(replies.size() == 1 && replies.get<bool>(0) == false);
break;
} catch (const WatchError &err) {
// Key has been modified by other clients, retry.
continue;
} catch (const Error &err) {
// Something bad happens, and the Transaction object is no longer valid.
throw;
}
}
这样,就保证了线程间数据安全。一般情况下,乐观锁应用于发生数据碰撞概率较低的场景下,所以重试次数一般不会太多。
使用这种模式,即使是多个进程同时操作一个Redis实例,也可以保证各客户端的数据一致性。
针对hash中的field
Redis 支持的WATCH指令只能针对key。
对于hash表来说,一个key下可能有成千上万的field,当更改其中一条记录时,其他记录的更改并不影响。
如果直接WATCH key,那么任何一个field的变动都会导致事务执行失败,这会影响执行效率。
这里就提供一个变通的思路,只要规定任何修改hash数据的操作都遵循以下步骤:
- WATCH
key:field:lock
,其中key:field:lock
为根据hash的key和field组建的唯一字符串,由监听key变通为监听这个字符串 - HGET KEY FIELD, 并修改当前值为updated_value
- MULTI,开始事务
- SET
key:field:lock
“”,在事务中修改监听的字符串,如果当前事务执行成功,其他修改该值的事务必然失败,以此达到同步的目的 - HSET KEY FIELD updated_value
- EXEC
以上,就把监听整个hash key的需求转变成了监听由hash的key-field组成的唯一字符串了,这样,修改hash value的操作和修改string的操作绑定在了一起。
这样就不会key下任意field改动都会导致事务失败了。
小结
涉及到并发的情况,在应用程序开发中都要特别注意。
一旦出现数据一致性问题,会比较难调试。
在分布式系统中,操作数据库时的数据一致性,也是需要特别关注的。