一、什么是消息未读
消息未读包括
会话未读和
总未读。前者指的是当前用户和某一聊天方的未读消息数,后者指的是当前用户的所有未读消息数,也就是所有会话未读的和。比如用户A收到用户B的2条消息,还收到用户C的3条消息,则用户A与B的会话未读数是2,用户A与C的会话未读数是3,用户A的总未读是5。
二、消息未读的维护
会话未读和总未读数一般都是单独维护的。这是因为:
1)总未读的使用场景较多,会被高频使用。如APP角标未读展示;
2)如果不单独维护,则总未读数需要通过计算所有的会话未读数,一旦会话数较多,就需要多次读取存储,多次获取累加的操作容易出现性能瓶颈。而且一旦发生超时等意外,就会无法获取到会话未读数,导致总未读数不准确。
三、消息未读的一致性
单独维护总未读和会话未读数会带来新问题,也就是消息总未读数与(多个)会话未读数不一致的问题。比如APP角标显示5,表示有5条未读消息,但用户点进去却发现没有新消息或只有3条消息,就会给用户造成不好的体验。
消息未读不一致的原因
用户B的初始状态:会话未读数和总未读数都是0。
用户A给用户B发消息,消息到达IM服务后,执行加未读操作:先把用户B与用户A的会话未读数加1,再把用户B的总未读数加1,然后消息推送给用户B。
case1:假设加会话未读数的操作成功、加总未读数的操作失败了,则用户B的最新状态是:会话未读数是1,总未读数是0。
case2:假设加会话未读数的操作成功,由于某些原因服务器响应请求延迟,导致总未读数还没加1,用户就已经点开了消息,也就是执行了清未读操作,用户B和用户A的会话未读清0,用户B的总未读清0,若服务器恢复正常执行加总未读的操作,则用户B的最新状态是:会话未读数是0,总未读数是1。
上面两个case的
消息不一致,归根到底就是两个未读的变更不是原子性的,也就是整个程序中的所有操作,要么全部执行,要么全部不执行,不能停滞在中间某个环节。
消息未读不一致的解决办法
解决消息未读不一致的办法就是保证两个未读更新操作的原子性。常见的解决方案有分布式锁、支持事务操作的资源管理器、原子化嵌入脚本。
1.分布式锁
▶ 分布式锁应该具备的条件:
- 互斥性:在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
- 高可用的获取锁与释放锁;
- 高性能的获取锁与释放锁;
- 具备可重入特性(避免死锁);
- 具备锁失效机制,防止死锁;
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
▶ 分布式锁一般有三种实现方式:
- 基于数据库的分布式锁
- 基于缓存(Redis等)的分布式锁
- 基于ZooKeeper的分布式锁
基于数据库的分布式锁
基于数据库实现分布式锁主要是利用数据库的
唯一索引来实现,因为唯一索引具有排他性,即同一时刻只能允许一个竞争者获取锁。
加锁就是在数据库中插入一条锁记录,利用业务id进行防重。当第一个竞争者加锁成功后,第二个竞争者再来加锁就会抛出唯一索引冲突,如果抛出这个异常,就判定当前竞争者加锁失败。防重业务id需要自定义,例如锁对象是一个方法,则业务防重id就是这个方法名,如果锁定的对象是一个类,则业务防重id就是这个类名。
解锁就是删除这条记录。
表设计
CREATE TABLE `distributed_lock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `method_name` varchar(255) NOT NULL COMMENT '业务防重id', `holder_id` varchar(255) NOT NULL COMMENT '锁持有者id', `create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uidx_method_name` (`method_name`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;
加锁
insert into distributed_lock(method_name, holder_id) values ('method_name', 'holder_id');
如果当前sql执行成功代表加锁成功,如果抛出唯一索引异常(DuplicatedKeyException)则代表加锁失败,即当前锁已经被其他竞争者获取。
解锁
delete from methodLock where method_name='method_name' and holder_id='holder_id';
可行性分析
- 高可用性:单个数据库容易产生单点问题,如果数据库挂了,锁服务就挂了。对于这个问题,可以考虑实现数据库的高可用方案,例如MySQL的MHA高可用解决方案。
- 可重入性:同一个竞争者,在获取锁后未释放锁之前再来加锁,一样会加锁失败,因此是不可重入的。可以在加锁时判断记录中是否存在method_name的记录,且holder_id和当前竞争者id相同,则加锁成功。
- 非阻塞性:这把锁是非阻塞性的,因为数据的insert操作一旦插入失败就会直接报错。没有获得锁的线程不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。可以搞一个while循环,直到insert成功再返回成功。
- 锁失效:这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。可以每次加锁之前先判断已经存在记录的创建时间和当前系统时间的差是否已经超过超时时间,如果已经超过则先删除这条记录,再插入新的记录。
基于Redis的分布式锁
一般使用Redis来实现分布式锁都是利用Redis的SETNX(SET IF NOT EXISTS)这个命令,只有当key不存在时才会执行成功,如果key已经存在则命令执行失败。
使用SETNX实现分布锁有个缺陷,SETNX操作无法设置key的ttl,需要配合exprie key ttl 一起使用。
也可以用unix时间戳+锁的有效期作为锁的值。获取锁的值后,与当前时间进行对比,如果值小于当前时间说明锁已过期失效,可用Redis的DEL命令删除该锁。
加锁:SETNX
$expire = 10;//有效期10秒 $key = 'holderId';//key $value = time() + $expire;//锁的值 = Unix时间戳 + 锁的有效期 $lock = $redis->setnx($key, $value); //判断是否上锁成功,成功则执行下步操作 if(!empty($lock)) { // 操作 }
如果返回 1,则表示当前进程获得锁,并获得了当前插入/更新缓存的操作权限。
如果返回 0,表示锁已被其他进程获取,这是进程可以返回结果或者等待当前锁失效再请求。
解锁:DEL
$lock = $redis->setnx($key, $value); //判断是否上锁成功,成功则执行下步操作 if(!empty($lock)) { $lock_time=$redis->get($key); //锁已过期,删除 if($lock_time < time()){ $this->del($key); } }
删除key,如果删除成功,返回解锁成功,否则解锁失败。
从 Redis 2.6.12 版本开始,set命令集成了 NX 和 EX 操作,
set key value [EX seconds] [PX milliseconds] [NX|XX]
$redis = new Redis(); $redis->connect('127.0.0.1', 6380); $rs = $redis->set('lockKey', holderId, ['nx', 'ex' => expireTime]); var_dump($rs);//返回true代表加锁成功,返回false代表加锁失败
可行性分析
- 高可用性:如果需要保证锁服务的高可用,可以对Redis做高可用方案:Redis集群+主从切换。
- 可重入性:上面实现的锁是不可重入的,如果需要实现可重入,在SET_IF_NOT_EXIST之后,再判断key对应的value是否为当前竞争者id,如果是返回加锁成功,否则失败。
- 锁失效:加锁时我们设置了key的超时,当超时后,如果还未解锁,则自动删除key达到解锁的目的。如果一个竞争者获取锁之后挂了,我们的锁服务最多也就在超时时间的这段时间之内不可用。
基于Zookeeper的分布式锁
Zookeeper一般用作配置中心,其实现分布式锁的原理和Redis类似。在Zookeeper中创建
临时有序节点,利用节点不能重复创建的特性来保证排他性。
加锁、解锁的步骤如下:
加锁
首先,在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点Lock 1。
之后,Client 1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock 1是不是顺序最靠前的一个。如果是第一个节点,则加锁成功。
这时候,如果再有一个客户端Client 2前来加锁,则在ParentLock下载再创建一个临时顺序节点Lock 2。
Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock 2并不是最小的。于是,Client 2向排序仅比它靠前的节点Lock 1注册Watcher,用于监听Lock 1节点是否存在。即Client 2抢锁失败,进入了等待状态。
同样的,如果又来了一个客户端Client 3,则Client 3向排序仅比它靠前的节点Lock 2注册Watcher,用于监听Lock 2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态。
解锁
当任务完成时,Client 1会显示调用删除节点Lock 1的指令。
由于Client 2一直监听着Lock 1的存在状态,当Lock 1节点被删除,Client 2会立刻收到通知。这时候Client 2会再次查询ParentLock下面的所有节点,确认自己创建的节点Lock 2是不是最小的节点。如果是,则Client 2获得锁。
可行性分析
- 高可用性:Zookeeper是集群部署的,只要有一半以上的机器存活,就可以保证服务可用性。
- 可重入性:客户端加锁时将主机和线程信息写入锁中,下一次再来加锁时直接和序列最小的节点对比,如果相同,则加锁成功,锁重入。
- 锁失效:创建的节点是顺序临时节点,如果客户端获取锁成功之后突然session会话断开,ZK会自动删除这个临时节点。
2.自定义支持事务操作的资源管理器
事务提供了一种“将多个命令打包,然后一次性按顺序地执行”的机制,并且事务在执行期间不会主动中断,服务器在执行完事务中的所有命令之后,才会继续处理其他客户端的其他命令。比如:Redis 通过 MULTI、DISCARD 、EXEC 和 WATCH 四个命令来支持事务操作。
一个事务从开始到执行会经历以下三个阶段:
- 开启事务:以MULTI开启一个事务
- 命令入队:批量操作在发送 EXEC 命令前被放入队列缓存。
- 执行事务:收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行。
在事务执行过程,其他客户端提交的命令请求不会插入到事务执行命令序列中。
一旦EXEC命令执行,之前加的监控锁就会取消
Watch命令,监视一个或多个key,如果在事务执行之前key被其他命令所改动,比如某个list已被别的客户端push/pop过了,那么事务将被打断,整个事务队列都不会被执行。在消息未读的应用场景中,可以在每次变更未读前先watch要修改的key,然后事务执行变更会话未读和总未读的操作,如果在最终执行事务时watch到两个未读的key的值已经被修改过,则本次事务失败。
缺点:watch操作实际上是一个乐观锁策略,对于未读变更较频繁的场景,可能需要多次重试才可以最终执行成功,执行效率低、性能差。
3.原子化嵌入脚本
Redis支持通过嵌入Lua脚本来原子化执行多条语句,可以在Lua脚本中实现总未读和会话未读的原子化变更,甚至实现一些复杂的变更逻辑。
后记:这篇《07 | 分布式锁和原子性:你看到的未读消息提醒是真的吗?》专栏文章,大佬在“分布式锁”这个知识点上一带而过,因此自己下去复习、总结了一下。