背景
在研发公司智能客服云服务业务时,后台服务经历了从单机、主备、到分布式的技术演化,为了实现集群部署、动态扩容的无状态服务目标,就需要对一些重要的业务数据,例如:客服在线服务信息、会话超时信息等做服务间的数据同步;现有实现分布式锁的方式一般有数据库、Zookeeper、Redis等几种,经过综合比较结合我们的实际业务需求,在此使用了基于Redis的分布式锁。功能已在项目中落地并提供了稳定可靠的服务,在此就简单的聊一聊开发技巧以及心得体会。

命令介绍
Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争关系; Redis提供一些命令SETNX,GETSET,可以比较方便实现分布式锁,下面就简单介绍一下开发中用到的几个重要的命令。
1、GET命令
用法:GET key
功能:返回 key 所关联的字符串值,如果 key 不存在那么返回特殊值 nil 。
2、DEL命令
用法:DEL key [KEY …]
功能:删除给定的一个或多个 key,不存在的 key 会被忽略。
3、SETNX命令
用法:SETNX key value
功能:当且仅当 key 不存在,将 key 的值设为 value ,并返回1;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。
4、GETSET命令
用法:GETSET key value
功能:将给定 key 的值设为 value ,并返回 key 的旧值 (old value),当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。
5、TIME命令
用法:TIME
功能:获取当前Redis服务器的时间,精确到纳秒,保证时间一致性;使用此命令时需要开启脚本权限(默认开启),方便获取时间
加锁实现
1、SETNX上锁
首先获取超时时间戳,然后通命令SETNX 可以直接加锁操作,比如说对某个信息AgentList加锁,客户端可以尝试 SETNX agent.list.lock <time>来设置时间戳,如果返回1,表示客户端已经获取锁,否则就需要其它处理逻辑。

2、解决死锁问题
在客户端获取到锁后,如果出现执行时间过长、进程挂掉、或其它异常崩溃等情况,就有可能导致锁无法被正常释放掉,从而造成死锁。所以,需要对锁做时效性进行检测,防止因为获取到锁的进程出现异常而造成死锁的发生,为了应对这种情况,我们就需要获取当前的时间加上一定的超时时长组合成时间戳,作为value存入此锁中;当客户端申请锁时,先获取锁中的时间戳,通过与当前的时间进行比较,如果超时则说明锁已失效,可以通过GETSET进行重新设置,设置成功者则获取到锁!
那么如果遇到锁超时,什么我们不能直接简单粗爆的DEL锁,重新SETNX上锁呢?看官请听小的细细道来,假如有A B C三个服务:
A获取到了锁,处理业务逻辑时,进程突然崩溃;
B、C调用SETNX上锁时,返回0;然后开始获取agent.list.lock的时间戳,通过比对时间戳,发现锁超时;
B发送DEL命令,删除锁;
B发送SETNX获取锁;
C发送DEL命令,但是此时C发送DEL时,删除的其实是B的锁;
C发送SETNX获取锁;
此时B、C都获取了锁,产生了竟争条件,数据同步就会失效;如果在更高并发的情况下,可能会有更多的客户端获取到了锁,从而产生严重的后果。
因此在发现锁超时后,我们应该使用GETSET命令来重新获取锁,为什么GETSET就能有效解决这种情况呢?
A获取到了锁,处理业务逻辑时,进程突然崩溃;
B、C调用SETNX上锁时,返回0;然后开始获取agent.list.lock的时间戳T1,通过比对时间戳,发现锁超时;
B发送GETSET 获取锁及返回时间戳T2;
C发送GETSET 获取锁及返回时间戳T3;
如果T1=T2,说明B获得时间戳。
如果T1=T3,说明C获得时间戳。
在此过程中,可能会出现C把B设置时间戳重新设置的情况,但是只要我们保证每个客户端设置的时间戳是唯一的,只有那个读取值与设置后返回值相等的客户端,才算真正获取到锁的,就能保证锁的有效性。

2、DEL释放锁
在完成业务逻辑处理后,首先获取锁的时间戳,如果获取成功,时间戳与设置的相同且不超时,则调用 DEL agent.list.lock 释放锁。

4、时间戳的一致性
判断是否上锁以及锁的有效性,都是通过设置的时间戳来实现的,如果值中没有时间戳那说明无锁状态,如果有时间戳且没有超时那说明上锁中,如果有时间戳但已超时则说明锁无效;所以为了实现同步效果,保证锁有效,就一定要同步各服务器的时间,如果各服务器间时间有差异,时间不一致,就有可能导致在判断锁超时,出现偏差,从而产生竞争条件。
锁的超时与否,严格依赖时间戳,时间戳本身也是有精度限制,假如我们的时间精度为秒,从加锁到执行操作再到解锁,一般操作肯定都能在一秒内完成,这样的话,我们上面的CASE,就很容易出现。所以,最好把时间精度提升到毫秒级。这样的话,可以保证毫秒级别的锁是相对安全的。好在Redis为我们提供了获取时间的命令,而且可以组合成纳秒级别的时间戳,由于Redis是串行执行命令的,基本可以保证时间的唯一性;当我们在服务中设置时间戳时,先调用Redis获取时间就能比较好地解决时差问题了。


总结
总得来看时间戳的时长也是非常重的环节,如果超时时间过短,可能会出现客户端因为某些操作被阻塞了相当长时间,其它客户端把锁当成超时无效而再次获得锁,从而导致死锁。解决这个问题的办法,一个是超时时长设置要合理,另一个是可以通过重新上锁,防止超时的发生,具体看实际应用的需求;另外获取锁等待时,因为sleep设置不合理,导致Redis在大并发下被压垮的情况也需要注意。具体实现时,一是有效利Redis提供的获取时间的功能达到时间的一致性,注意开启运行脚本功能;二是在阻塞模式下,每次等待的时间最好随机化,能比较好的提升性能;
有人说脱离实际业务谈技术那就是在耍流氓,所有的技术实现都需要落地检测才最有说服力;现在实现的分布式锁还比较初级,还有很多不完善的方,无法满足很多特定的场景,在此仅起到抛砖引玉的作用;希望通过项目中的实践,为大家后来项目中的使用提供一种经验,一种思路,也希望各位大伽能够多多交流共同进步。
附一些开发注意事项
(a)键值KEY
1、易读性
以业务名(或数据库名)+环境名为前缀(防止key冲突),用冒号分隔,比如 业务名:表名:id:dev
scrm:agentinfo:sessionid:dev
2、简洁性
在保证语义易读的前提下,尽量控制key的长度,当key较多时,内存占用也不容忽视,例如:
users:{userid}:messages:{messageid} 简化为 u:{uid}:m:{mid}
3、不要包含特殊字符
尽量不要包含空格、换行、单双引号以及其他转义字符,降低数据转换时出错的几率
(b)内容VALUE
1、拒绝bigkey
string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。防止网卡流量,引发慢查询。
2、选择适合的数据类型
例如:实体类型(要合理控制和使用数据结构内存编码优化配置,例如ziplist,但也要注意节省内存和性能之间的平衡)
反例:set user:1:name tom set user:1:age 19 set user:1:favor football
正例:hmset user:1 name tom age 19 favor football
(c)生命周期
注意控制key的生命周期,redis不是垃圾桶。
建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期),不过期的数据重点关注idletime