Redis | MULTI和EXEC

2020 05 1, Fri

跟常规的数据库相比,redis为了性能舍弃了关系型数据库的很多特性。我之前其实没用过多少关系性数据库,所以从基础说起吧。

ACID

ACID就是原子性(Atomicity,不可分割)、一致性(Consistency,不同状态的更新同步)、隔离性(Isolation,互不影响)和持久性(Durability,不会因为重启而丢失)。

比如说代码里面经常出现的i++。

#include <iostream>

using namespace std;

int main() {
    int i = 0;
    for(int idx = 0; idx < 10000; idx++) {
        i++;
    }
    cout << i << endl;
}

输出一定是10000。因为只有一个线程。

如果我们把这段代码改成多线程的。

#include <iostream>
#include <thread>
#include <vector>

using namespace std;

int i = 0;

void incr(int count) {
    for(int idx = 0; idx < count; idx++) {
        i++;
    }
}

int main() {
    vector<thread*> threads;
    for(int idx = 0; idx < 16; idx++) {
        threads.push_back(new thread(incr, 1000));
    }

    for(auto thread: threads) {
        thread->join();
    }
    cout << i << endl;
}

注意编译需要c++11支持

我们执行的时候就会发现,这代码时灵时不灵。

guochao@ChaodeMacBook-Pro /tmp % g++ test.cpp -o test -std=c++17
guochao@ChaodeMacBook-Pro /tmp % ./test
14376
guochao@ChaodeMacBook-Pro /tmp % ./test
15855
guochao@ChaodeMacBook-Pro /tmp % ./test
16000
guochao@ChaodeMacBook-Pro /tmp % ./test
16000
guochao@ChaodeMacBook-Pro /tmp % ./test
16000
guochao@ChaodeMacBook-Pro /tmp % ./test
15793
guochao@ChaodeMacBook-Pro /tmp % ./test
16000
guochao@ChaodeMacBook-Pro /tmp % ./test

你会看到我们16个线程执行了16*1000,但是偶尔会出现不到16000的情况。

我们把执行的次数增大,让他跑10000次,情况更严重了:

guochao@ChaodeMacBook-Pro /tmp % g++ test.cpp -o test -std=c++17
guochao@ChaodeMacBook-Pro /tmp % ./test
21095
guochao@ChaodeMacBook-Pro /tmp % ./test
30974
guochao@ChaodeMacBook-Pro /tmp % ./test
30245
guochao@ChaodeMacBook-Pro /tmp % ./test
30460
guochao@ChaodeMacBook-Pro /tmp % ./test
33754
guochao@ChaodeMacBook-Pro /tmp % ./test
29210
guochao@ChaodeMacBook-Pro /tmp % ./test
30223
guochao@ChaodeMacBook-Pro /tmp % ./test
27815

这是为什么呢?

这是因为i++实际上分为了三步:

  • 取出来
  • 增1
  • 放回去

如果两个线程同时执行到了i++,在同一时刻执行了取出,就会得到一样的i,然后存回i+1,最后结果还是i+1。

严重的时候,cpu忙碌导致一个线程先取到了i,可是一直没有轮到它存i+1,于是在它存回i+1前,就会有i+2 i+3 i+4…存回去,然后cpu给最早的线程分配了时间片,啪,i又变成了i+1。i++在多线程的情况下,被其他线程打断了。

如果我们需要每次自增都不能被打断,我们可以选择用原子的int:

#include <iostream>
#include <thread>
#include <vector>

using namespace std;

atomic_int i = 0;

void incr(int count) {
    for(int idx = 0; idx < count; idx++) {
        i++;
    }
}

int main() {
    vector<thread*> threads;
    for(int idx = 0; idx < 16; idx++) {
        threads.push_back(new thread(incr, 10000));
    }

    for(auto thread: threads) {
        thread->join();
    }
    cout << i << endl;
}

结果就是对的。

guochao@ChaodeMacBook-Pro /tmp % g++ test.cpp -o test -std=c++17
guochao@ChaodeMacBook-Pro /tmp % ./test
160000
guochao@ChaodeMacBook-Pro /tmp % ./test
160000
guochao@ChaodeMacBook-Pro /tmp % ./test
160000
guochao@ChaodeMacBook-Pro /tmp % ./test
160000

这种竞争主要出现在多线程多进程的环境里面,多线程多进程就会出现同时访问同一个资源的问题。而分布式其实就是多进程的一种特殊的形式,传输数据的时间更长了,需要借助网络了而已。

在mysql中就有类似的问题。用一个用烂掉的例子来说吧:我有一张表存着所有人卡里的余额。

姓名 余额
张三 100
李四 100

张三给李四打了一天工,要李四给张三支付50块钱的工钱。

好,我们从李四的账户里面减去50,在张三的账户里面加上50。看上去没问题。但是仔细想想,运算其实是分成了几步:

  • 从李四账户取出余额:100
  • 算出来结果:50
  • 存到李四账户:50
  • 从张三账户取出余额:100
  • 算出来结果:150
  • 存到张三帐号:150

这就有两个问题:

  1. 如果数据库在中途崩溃了,比如说修改李四的账户以后数据库崩了,李四的钱就不翼而飞了,并没有加到张三的帐号里面。
  2. 如果李四还雇了一个人,就要给两个人发工钱。网络很慢,结果从李四的账户里面都取出来了100,都存回去了50,然后给两个雇员分别加了50。这就凭空冒出来了50块钱。

这两种情况都是不可以接受的。好在mysql提供了transaction,同一个transaction的操作不可以被打断。mysql通过transaction提供了ACID,保证了你的每一个事务的正确。

redis也有transaction,但是redis的transaction只保证了所有语句都执行,并不能保证成功与否。

单线程的redis

但是刚才我们提到了,这些问题主要是在多线程环境下出现的。但是redis我们可以认为是单线程的,单线程的redis每一个操作都是原子的。可是比如说i++或者转账这样的事情,一定需要取出来再存回去,我们怎么用redis来安全的完成呢?

我们分成几种情况来说。

第一种是比较简单的++的情景。redis为了自增提供了专门的语句,叫incr,比如说incr incrby hincry zincrby。

incr语句总是原子的。比如说我启动多个客户端,同时给redis的某个key自增,用incr就一定不会错:

import aioredis
import asyncio

loop = asyncio.get_event_loop()

async def infinite_incr(redis, key):
    for _ in range(10000):
        await redis.incr(key)

async def main():
    key = "test:incr"
    conn = await aioredis.create_redis_pool(("127.0.0.1", 6379))
    await conn.set(key, 0)
    await asyncio.gather(*[infinite_incr(conn, key) for _ in range(8)])

最后test:incr的值一定是80000。

类似的还有一个decr,不过说实话没感觉到有啥用。

multi

对于复杂的需求,单纯的incr就显得特别不够用了。我们在一边incr另一边decr,中间还是有可能被打断的。

redis为这种情况提供了事务(transaction)。比如说刚才发工资的地方,我们就可以写

> MULTI
OK
> incrby 李四的工资 -50
QUEUED
> incrby 张三的工资 50
QUEUED
> EXEC
1) (integer) 50
2) (integer) 150

一个transaction的中途不会被打断,不会执行其他的语句。

但是说实话,这不大够。redis的transaction只保证每个操作都会执行,但是

  1. 失败了后面的语句还会执行
  2. 不能回滚
  3. 没法进行逻辑判断和循环(李四的余额还是有可能会扣到负数)

lua

所以更复杂的场景,redis为我们提供了一个redis解释器,并提供了eval evalsha和load几个语句。

redis保证每次lua脚本执行的时候,都是原子的,并且不会执行其他的语句或者脚本。

那么对发工资的情况,我们就可以用脚本来做处理。

async def main():
    balance_key = "balance:{:s}"

    conn = await aioredis.create_redis_pool(("127.0.0.1", 6379))
    await conn.eval("""
        local b_balance = redis.call("get", KEYS[2])
        if b_balance == nil or b_balance == "" then
            redis.error_reply("no balance record")
        else
            if tonumber(b_balance) >= tonumber(ARGV[1]) then
                redis.call("incrby", KEYS[1], ARGV[1])
                redis.call("incrby", KEYS[2], -tonumber(ARGV[1]))
            else
                redis.error_reply("insufficient balance")
            end
        end
    """, keys=[balance_key.format("a"), balance_key.format("b")], args=[50])

我们可以使用lua5.1的全部能力。这个过程是原子的,而且我们可以在lua中尝试基本的回滚操作。

虽然我们还是不能达到完全的一致性,但是相对来说出错的可能性已经很小了。

另外这种方法还是有一定弊端的,之前反复说到redis是单线程的的,那么如果我们的lua脚本耗时非常长,就会出现整个redis不可用的情况。这个问题一定要避免。

带宽问题

脚本有可能执行很快但是要经历不少判断,加上注释、变量名,保证了代码的可读性,代码就可能非常长。

如果每次都要发送全部的脚本,假设一段脚本有1k,每秒有1000个请求,就会有1M的带宽,这无论是对网络带宽还是对redis处理lua的速度都是一个不小的挑战。

所以redis除了eval,实际上还提供了load和evalsha两个指令。load和eval都会把脚本编译以后存起来,用sha1来区分不同的代码,evalsha的时候直接通过sha1来执行具体的脚本就ok,这样就可以节约大量的带宽。

官方建议程序一开始就把脚本load到redis中,之后全部用evalsha来执行具体的脚本。

用到了lua的库

文档中提到说,其实redis自己也是用lua执行所有的请求。而且实际上巨人们前人们已经在非常多的库中用到了lua。

比如说redisson,是一个java的redis客户端,除了支持了redis本身的功能,还自带了很多redis范式的实现,比如说redlock,其中很多是依赖lua脚本实现的。

其他语言暂时还没有这么全面的一个库,但是多多少少都有用到lua,比如说go的go-redsync。

回到ACID

那么redis的transaction和lua为我们带来了ACID吗?

redis有单线程,那么毫无疑问,redis的基本操作是有原子性的,同时单线程天生具有隔离性。

一致性仍然不是redis带来的,虽然我们可以用lua回滚结果,但是我们如果不能正确处理各种情况,还是会出现一致性的问题。

而redis的持久性就不是transaction和lua可以解决的问题了。极端情况下,redis还是有可能会丢数据的。