在多线程/进程程序中,有些时候会遇到竞态问题,可以使用线程进程间的同步方式解决这个问题,比如说互斥锁,条件变量等。

在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秒钟左右。

可以看到分布式锁比起事务有明显的优势。简单分析一下原因:因为锁的更加灵活,可以锁住粒度更小的键值,减少大量的访问冲突和重试,所以效率远远高于事务。且这种优势会随着哈希键内部的键值对的增多,访问客户端数量的增多而越来越大。