Redis事务及CAS(Check-And-Set)机制

Redis事务

在web/activity中见过事务机制保证发券环节对券码存量校验,这是典型的并发、读写操作的实例。

Redis的事务机制Transaction通过四个命令来完成:MULTI, EXEC, DISCARD and WATCH,建议精读链接文章对Redis事务机制有详细介绍。

Redis事务机制特性

  • 事务(transaction)的定义从multi开始,到exec结束。
  • 同一个事务内的多个命令,具有原子性,不会被打断

    It can never happen that a request issued by another client is served in the middle of the execution of a Redis transaction. This guarantees that the commands are executed as a single isolated operation.

实例一:multi和exec之间的命令不会被其他并发处理打断,事务原子性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
clientA:
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set hello a
QUEUED

// clientB cocurrently set hello to b

127.0.0.1:6379> get hello
QUEUED
127.0.0.1:6379> exec
1) OK
2) "a"

clientB:
127.0.0.1:6379> set hello b
OK

实例二:注意理解incr和set操作的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
clientA: 
127.0.0.1:6379> set hello 1
OK

127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr hello //
QUEUED
// clientB cocurrently set hello to 22
127.0.0.1:6379> get hello
QUEUED
127.0.0.1:6379> exec
1) (integer) 23
2) "23"

// clientB:
127.0.0.1:6379> set hello 22
OK

  • 事务内的命令要么全部执行(仅指入带执行的命令队列成功),要么都不执行

    Either all of the commands or none are processed, so a Redis transaction is also atomic.

multi执行之前断开连接,则全部命令不会执行;exec执行之后,所有命令操作都会被执行。

注意:这里仅仅指命令的执行,但同一事务内命令执行与否,并不意味着执行成功(存在命令执行后,返回异常的情况)。

  • 事务期间的错误情况
  1. 命令入待执行命令队列失败,此时在exec前就会有错误:命令语法错误、内存不足等极端情况, 此时一般会中断事务,自动discard事务。

    1
    2
    3
    4
    5
    6
    7
    8
    127.0.0.1:6379> multi
    OK
    127.0.0.1:6379> set hello 1
    QUEUED
    127.0.0.1:6379> lget hello // 非法的命令导致错误
    (error) ERR unknown command 'lget'
    127.0.0.1:6379> exec
    (error) EXECABORT Transaction discarded because of previous errors.
  2. 在执行exec后部分命令可能失败:即使部分命令失败,其他命令仍将正常执行完成
    实例三:在执行exec后部分命令可能失败

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    127.0.0.1:6379> multi
    OK
    127.0.0.1:6379> set hello 1
    QUEUED
    127.0.0.1:6379> lset hello 0 1 // 该命令可被执行,但执行失败
    QUEUED
    127.0.0.1:6379> exec
    1) OK
    2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
    127.0.0.1:6379> get hello // 事务中的第一步执行成功
    "1"

CAS(Check-And-Set)支持

  • watch已监视的key,只允许在当前终端的multiexec见被修改,其他情况的修改都将导致watch和此事务的失败。

CAS的实现主要通过watch命令完成,也就是说在watch一个key后,其他终端修改此key的值时,都将触发当前事务的失败。
实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// clientA
127.0.0.1:6379> watch hello
OK
127.0.0.1:6379> multi
OK
// clientB cocurrently set hello to 33
127.0.0.1:6379> set hello 2
QUEUED
127.0.0.1:6379> exec // watched hello changed, exec will fail
(nil)

// clientB:
127.0.0.1:6379> set hello 33
OK

// redis monitor
1496247607.225085 [0 127.0.0.1:43168] "watch" "hello"
1496247611.476334 [0 127.0.0.1:43168] "multi"
1496247626.275239 [0 127.0.0.1:43216] "set" "hello" "33"
1496247632.092549 [0 127.0.0.1:43168] "exec"

注意:当前终端的watchmulti之间对key的修改,也会触发事务的失败详细

predis的CAS使用


Laravel项目中有phpredispredis两个扩展支持redis, phpredis是C实现扩展,需要编译安装,性能卓越;predis是php实现扩展,安装方便(composer install),线上部署无需变更

这里对predis下使用Redis的事务机制展开说明


Check-And-Set:
示例代码:https://github.com/nrk/predis/blob/v1.1/examples/transaction_using_cas.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function zpop($client, $key)
{
$element = null;
$options = array(
'cas' => true, // Initialize with support for CAS operations
'watch' => $key, // Key that needs to be WATCHed to detect changes
'retry' => 3, // Number of retries on aborted transactions, after
// which the client bails out with an exception.
);
$client->transaction($options, function ($tx) use ($key, &$element) {
@list($element) = $tx->zrange($key, 0, 0);
if (isset($element)) {
$tx->multi(); // With CAS, MULTI *must* be explicitly invoked.
$tx->zrem($key, $element);
}
});
return $element;
}
$client = new Predis\Client($single_server);
$zpopped = zpop($client, 'zset');
echo isset($zpopped) ? "ZPOPed $zpopped" : 'Nothing to ZPOP!', PHP_EOL;

在cas模式下,predis使用的注意事项:

  • 必须显式的调用$tx->multi()
  • 在调用multi之前必须显式的调用任何命令$tx->validRedisCmd(),否则直接调用multi并不会执行multi指令
  • cas的目的是监视某个key在此期间不会变化,所以应用场景应该是:取某个key的值,做一些操作,再对此key执行其他命令
    这里不合适的引用下php.net对memcached::cas()的解释来辅助理解

    Memcached::cas() performs a “check and set” operation, so that the item will be stored only if no other client has updated it since it was last fetched by this client.
    cas = check and set, 某项将在没有其他终端修改的前提下保存,即当前终端是最新一次获取此项的值

实例:Lravel下通过predis的事务的CAS(check-and-set)机制保证竞争条件下的数据一致性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use Illuminate\Support\Facades\Redis;

$keyTotal = 'key-total';
$keyOneUser = 'key-one-user';
$total = 10;
$oneUserLimit = 2;
$options = [
"cas" => true,
"watch" => [$keyTotal, $keyOneUser], // watch multi keys
"retry" => 3
];
try {
$redis = Redis::connection('redis_conn');
$transFunc = function ($tx) use ($keyTotal, $keyOneUser, $total, $oneUserLimit) {
$curTotal = $tx->get($keyTotal);
$curLimit = $tx->get($keyOneUser);
if ($curTotal >= $total || $curLimit >= $oneUserLimit) {
\Log::info('[Rule ruleUpdateCasTransaction] out of limit, ' . "$keyTotal:$curTotal>=$total,$keyOneUser:$curLimit>=$oneUserLimit");
return false;
}
$tx->multi(); // cas模式下必须调用multi,调用multi前必须执行其他redis命令初始化配置
$tx->incr($keyTotal);
$tx->incr($keyOneUser);
};
$redis->transaction($options, $transFunc);
return true;
} catch (\Exception $e) {
\Log::error("[Rule ruleCntDec]Fail to $type for total and limit, e:" . $e);
return false;
}

Redis monitor命令下的cas事务执行过程:

1
2
3
4
5
6
7
8
9
1496242439.690785 [0 127.0.0.1:43126] "AUTH" "e3b8d1e0xxxxxxxxxxxxxxVpEi"
1496242439.691136 [0 127.0.0.1:43126] "SELECT" "4"
1496242439.691356 [4 127.0.0.1:43126] "WATCH" "key-total" "key-one-user"
1496242439.691493 [4 127.0.0.1:43126] "GET" "key-total"
1496242439.691643 [4 127.0.0.1:43126] "GET" "key-one-user"
1496242439.691834 [4 127.0.0.1:43126] "MULTI"
1496242439.692136 [4 127.0.0.1:43126] "INCR" "key-total"
1496242439.692146 [4 127.0.0.1:43126] "INCR" "key-one-user"
1496242439.692152 [4 127.0.0.1:43126] "EXEC"

参考阅读

  1. WATCH accounts for modifications by its own connection
  2. Redis 事务机制