Redis | MULTI和EXEC
跟常规的数据库相比,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
这就有两个问题:
- 如果数据库在中途崩溃了,比如说修改李四的账户以后数据库崩了,李四的钱就不翼而飞了,并没有加到张三的帐号里面。
- 如果李四还雇了一个人,就要给两个人发工钱。网络很慢,结果从李四的账户里面都取出来了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只保证每个操作都会执行,但是
- 失败了后面的语句还会执行
- 不能回滚
- 没法进行逻辑判断和循环(李四的余额还是有可能会扣到负数)
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还是有可能会丢数据的。