1、什么是消息未读
消息未读包括
会话未读和
总未读。前者指的是当前用户和某一聊天方的未读消息数,后者指的是当前用户的全部未读消息数,也就是全部会话未读的和。好比用户A收到用户B的2条消息,还收到用户C的3条消息,则用户A与B的会话未读数是2,用户A与C的会话未读数是3,用户A的总未读是5。
2、消息未读的维护
会话未读和总未读数通常都是单独维护的。这是由于:
1)总未读的使用场景较多,会被高频使用。如APP角标未读展现;
2)若是不单独维护,则总未读数须要经过计算全部的会话未读数,一旦会话数较多,就须要屡次读取存储,屡次获取累加的操做容易出现性能瓶颈。并且一旦发生超时等意外,就会没法获取到会话未读数,致使总未读数不许确。
3、消息未读的一致性
单独维护总未读和会话未读数会带来新问题,也就是消息总未读数与(多个)会话未读数不一致的问题。好比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;
加锁html
insert into distributed_lock(method_name, holder_id) values ('method_name', 'holder_id');
若是当前sql执行成功表明加锁成功,若是抛出惟一索引异常(DuplicatedKeyException)则表明加锁失败,即当前锁已经被其余竞争者获取。redis
解锁
delete from methodLock where method_name='method_name' and holder_id='holder_id';
可行性分析sql
- 高可用性:单个数据库容易产生单点问题,若是数据库挂了,锁服务就挂了。对于这个问题,能够考虑实现数据库的高可用方案,例如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表明加锁失败
可行性分析session
- 高可用性:若是须要保证锁服务的高可用,能够对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 | 分布式锁和原子性:你看到的未读消息提醒是真的吗?》专栏文章,大佬在“分布式锁”这个知识点上一带而过,所以本身下去复习、总结了一下。