在多线程/进程程序中,有些时候会遇到竞态问题,可以使用线程进程间的同步方式解决这个问题,比如说互斥锁,条件变量等。
在redis的使用中,redis服务器是使用单线程处理请求和网络IO的,但是由于其服务于多个客户端,其实还是会存在竞态问题。在redis服务器中本身提供了事务功能,其实事务功能的本质就是一个乐观锁,现在想要在使用redis时实现一个类似于互斥锁的功能。
1.分布式锁的原理
在同一进程中,想要实现线程间的同步是很容易的,借助互斥锁将临界区锁住即可。同一主机中,进程间的同步也可以使用文件和互斥锁进行完成。但是对于redis,想要实现一个互斥锁,用这些是无法办到的,不同主机之间不可能使用某个主机上的文件或者互斥锁进行同步。正确的选择是使用redis服务器的SETNX指令实现
redis> EXISTS job # job 不存在
(integer) 0
redis> SETNX job "programmer" # job 设置成功
(integer) 1
redis> SETNX job "code-farmer" # 尝试覆盖 job ,失败
(integer) 0
redis> GET job # 没有被覆盖
"programmer"
可以看到,如果一个键值不存在,那么SETNX可以将这个键设置成功,如果这个键已经存在,那么SETNX设置失败。可以利用这个指令来实现redis数据库的排他性访问。比如客户端在访问某个键值KEY之前,必须使用SETNX将键lock:KEY设置成功之后,才能对KEY进行操作。如果SETNX设置失败,说明别的客户端正在进行操作,需要等到这个lock:KEY被删除后才能重新SETNX成功。
Client A | Client B |
---|---|
准备发起对键KEY的修改 | 准备发起对键KEY的修改 |
SETNX LOCK:KEY 1 | SETNX LOCK:KEY 1 |
设置成功,redis服务器返回1(获得锁) | 设置失败,redis服务器返回0 |
查看并修改KEY键的值 | 不断尝试获取锁 |
DEL LOCK:KEY(释放锁) | 不断尝试获取锁 |
… | SETNX成功(获得锁) |
… | 查看并修改KET键 |
… | DEL LOCK:KEY(释放锁) |
通过SETNX可以完成实现不同客户端之间的排他性访问。不过也会有问题,如果一个客户端获取锁之后挂掉了,那么这个锁就不会被释放,其他客户端都不能再获取这个锁了,所以需要给每个锁设置一个时间:
redis> SETNX job "programmer"
(integer) 1
redis> EXPIRE job 10
(integer) 1
为了省事儿,可以用SET指令的EX模式代替上面两个指令:
SET job "programmer" NX EX 10
该指令等同于上面两个指令的原子操作。
2.分布式锁的实现
来看一下分布式锁的具体实现,使用C/C++实现,接住了C语言的hiredis库对redis服务器进行通信:
bool getLock(redisContext* conn, string& name, char* uuid_buff, int get_timeout = 10, int lock_timeout = 10)
{
uuid_t uuid;
uuid_generate_random(uuid);//生成唯一标识符
uuid_unparse(uuid, uuid_buff);//将标识符转化为字符串
string lockname = "lock:" + name;//得到锁的名字
time_t end = time(NULL) + get_timeout;//超过end未获取到锁就放弃
while (time(NULL) < end)//尝试获取锁
{
redisReply* reply = (redisReply*)redisCommand(conn, "SET %s %s NX EX %d", lockname.data(), uuid_buff, lock_timeout);//尝试加锁
if (reply->type == REDIS_REPLY_STATUS && strcmp(reply->str, "OK")==0)//加锁成功返回
{
freeReplyObject(reply);
return true;
}
else//加锁失败
{
freeReplyObject(reply);
reply = (redisReply*)redisCommand(conn, "TTL %s", lockname.data());//查看锁的剩余时间
if (reply->type == REDIS_REPLY_INTEGER && reply->integer == -1)//如果锁没有设置超时时间,为它设置一个超时时间
{
redisCommand(conn, "EXPIRE %s %d", lockname, lock_timeout);
}
freeReplyObject(reply);
}
}
return false;
}
bool releaseLock(redisContext* conn, string& name, char* uuid)
{
string lockname = "lock:" + name;//获取锁的名字
while (true)
{
freeReplyObject(redisCommand(conn, "WATCH %s", lockname.data()));//监视锁,防止删除过程中锁已经被别的客户端获取了
redisReply* reply = (redisReply*)redisCommand(conn, "GET %s",lockname.data());
if (reply->type == REDIS_REPLY_STRING && strcmp(reply->str, uuid) == 0)//查看锁的uuid,以验证是否是属于自己的锁
{
redisAppendCommand(conn, "MULTI");//将命令打包
redisAppendCommand(conn, "DEL %s", lockname.data());
redisAppendCommand(conn, "EXEC");
redisGetReply(conn, (void**)&reply);
freeReplyObject(reply);
redisGetReply(conn, (void**)&reply);
freeReplyObject(reply);
redisGetReply(conn, (void**)&reply);
if (reply->type!=REDIS_REPLY_NIL)//如果返回不为nil,删除成功
{
freeReplyObject(reply);
return true;
}
else//删除过程中锁状态发生了改变,尝试重新删除
{
continue;
}
}
freeReplyObject(redisCommand(conn, "UNWATCH", lockname.data()));
break;
}
return false;
}
3.与事务的效率对比
我们现在简单的测试一下分布式锁和事务的效率问题
测试1
现有一个字符串键,键是一个商品名字,值是商品的剩余件数,共计一万件,现在有AB两个客户同时进行购买,每个客户每次只能买两件商品,实现代码如下:
void* ApurchasebyLock(string& name,redisContext* conn)
{//使用分布式锁实现购买
gettimeofday(&t1, NULL);
int a = 0;
while (true)
{
char uuid[36];
getLock(conn, name, uuid);//加锁
redisReply* reply = (redisReply*)redisCommand(conn, "GET %s", name.data());//获取现有数量
int num = atoi(reply->str);
freeReplyObject(reply);
if (num == 0)//如果卖玩了,就释放锁并退出
{
gettimeofday(&t2, NULL);
cout << "A time:" << t2.tv_sec << "." << t2.tv_usec << " " << t1.tv_sec << "." << t1.tv_usec << endl;
cout <<"A"<< " num:" << a << endl;
releaseLock(conn, name, uuid);
return NULL;
}
num -= 2;//买两件
a += 2;//a是一个全局变量,用于记录购买的物品件数
//freeReplyObject(reply);
freeReplyObject(redisCommand(conn, "SET %s %d",name.data(), num));
releaseLock(conn, name, uuid);//释放锁
}
return NULL;
}
void ApurchasebyMulti(string& name,redisContext* conn)
{//使用事务功能实现购买
gettimeofday(&t1, NULL);
int a = 0;
while (true)
{
redisReply* reply = (redisReply*)redisCommand(conn, "WATCH %s", name.data());//对这个字符串键进行监视
freeReplyObject(reply);
reply = (redisReply*)redisCommand(conn, "GET %s", name.data());//获取剩余件数
int num = atoi(reply->str);
freeReplyObject(reply);
if (num == 0)//如果卖完了,退出
{
gettimeofday(&t2, NULL);
cout << "A time:" << t2.tv_sec << "." << t2.tv_usec << " " << t1.tv_sec << "." << t1.tv_usec << endl;
cout << "A" << " num:" << a << endl;
return;
}
num -= 2;
redisAppendCommand(conn, "MULTI");
redisAppendCommand(conn, "SET %s %d", name.data(), num);
redisAppendCommand(conn, "EXEC");//修改剩余件数
redisGetReply(conn, (void**)&reply);
freeReplyObject(reply);
redisGetReply(conn, (void**)&reply);
freeReplyObject(reply);
redisGetReply(conn, (void**)&reply);
if (reply->type != REDIS_REPLY_NIL)//修改成功则购买成功
{
a += 2;
}
continue;//修改失败,重新进行购买
}
}
使用两个函数实现了用分布式锁和事务进行购买,测试时使用fork创建子进程模拟两个客户端进行购买,够没完成后两个客户端的购买件数这和都是10000,证明分布式锁和事务都可以很好的解决并发问题。
从时间效率上分析,两种方式大概都用了2.5秒左右,没有特别大的区别。
但是这并不代表分布式锁没有优点,它最大的有点就是,更加灵活,粒度更小,事务功能只能对某个键进行加锁,但是分布式锁可以对键中的数据进行加锁,比如,分布式锁可以单独对哈希表中的某个键值对加锁,但是事务只能对整个哈希表进行加锁。
测试2
现在测试场景是这样的,使用一个哈希表实现一个市场的功能,哈希表中的每一对键值对表示商品的名字和其剩余的数量,每件商品两万件,现在每个客户端执行相同的操作,即从第一个商品开始购买,购买之后购买第二件商品,同种商品的购买每次购买两件,一直执行到货物购买了10000件时停止,下面用分布式锁和事务分别实现这个功能:
void* HApurchasebyLock(vector<string>& names, redisContext* conn)
{//分布式锁实现,names是存有哈希表中所有商品名字的向量
int a = 0;//已购买的商品数
gettimeofday(&t1, NULL);//开始购买的时间
int i = 0;
while (true)
{
if (a == 10000) //已经买够了就退出
{
gettimeofday(&t2, NULL);
cout << "A time:" << t2.tv_sec << "." << t2.tv_usec << " " << t1.tv_sec << "." << t1.tv_usec << endl;
cout << "A" << " num:" << a << endl;
return NULL;
}
string name = names[i];//将要购买的商品名字
i += 1;
if (i >= names.size())//买到最后一个商品了,下一买第一个商品
{
i = 0;
}
char uuid[36];
getLock(conn, name, uuid);//加锁
redisReply* reply = (redisReply*)redisCommand(conn, "HGET test %s", name.data());
int num = atoi(reply->str);
freeReplyObject(reply);
if (num == 0)//如果卖完了,放弃这次购买
{
releaseLock(conn, name, uuid);
continue;
}
num -= 2;
a += 2;//购买数量加二
//freeReplyObject(reply);
releaseLock(conn, name, uuid);
freeReplyObject(redisCommand(conn, "HSET test %s %d", name.data(), num));//修改剩余数量
}
return NULL;
}
下面是用事务的实现:
void HApurchasebyMulti(vector<string>& names, redisContext* conn)
{
gettimeofday(&t1, NULL);
int i = 0;
int a = 0;
while (true)
{
if (a == 10000)
{
gettimeofday(&t2, NULL);
cout << "A time:" << t2.tv_sec << "." << t2.tv_usec << " " << t1.tv_sec << "." << t1.tv_usec << endl;
cout << "A" << " num:" << a << endl;
return ;
}
string name = names[i];
i += 1;
if (i >= names.size())
{
i = 0;
}
redisReply* reply = (redisReply*)redisCommand(conn, "WATCH test");//监视市场
freeReplyObject(reply);
reply = (redisReply*)redisCommand(conn, "HGET test %s", name.data());
int num = atoi(reply->str);
freeReplyObject(reply);
num -= 2;
redisAppendCommand(conn, "MULTI");//尝试修改剩余数量
redisAppendCommand(conn, "HSET test %s %d", name.data(), num);
redisAppendCommand(conn, "EXEC");
redisGetReply(conn, (void**)&reply);
freeReplyObject(reply);
redisGetReply(conn, (void**)&reply);
freeReplyObject(reply);
redisGetReply(conn, (void**)&reply);
if (reply->type != REDIS_REPLY_NIL)//修改成功则购买成功
{
a += 2;
}
continue;//修改失败,放弃这次购买
}
}
测试之前在市场中放入了1000000件商品,用8个进程模拟8个客户端进行购买,最终运行局结果:分布式锁用了8秒钟左右,事务用了25秒钟左右。
可以看到分布式锁比起事务有明显的优势。简单分析一下原因:因为锁的更加灵活,可以锁住粒度更小的键值,减少大量的访问冲突和重试,所以效率远远高于事务。且这种优势会随着哈希键内部的键值对的增多,访问客户端数量的增多而越来越大。