首先,需要了解系统为什么使用分布式,写一些分布式主要的实现和应用的场景,问题不多但也算深入吧,希望对各位有所帮助
1.谈谈业务中使用分布式的场景
随着互联网的发展,传统单工程项目的很多性能瓶颈越发凸显,性能瓶颈可以有几个方面:
1.应用服务层:随着用户量的增加,并发量增加,单项目难以承受如此大的并发请求导致的性能瓶颈
2.底层数据库层:随着业务的发展,数据库压力越来越大,导致的性能瓶颈
场景1:应用系统集群的 Session 共享
应用系统集群最简单的就是服务器集群,比如:Tomcat 集群。应用系统集群的时候,比较凸显的问题是 Session 共享,Session 共享我们一是可以通过服务器插件来解决。另外一种也可以通过 Redis 等中间件实现
场景2:应用系统的服务化拆分
服务化拆分,是目前非常火热的一种方式。现在都在提微服务。通过对传统项目进行服务化拆分,达到服务独立解耦,单服务又可以横向扩容。服务化拆分遇到的经典问题就是分布式事务问题。目前,比较常用的分布式事务解决方案有几种:消息最终一致性、TCC 补偿型事务等
场景3:底层数据库的压力分摊
如果系统的性能压力出现在数据库,那我们就可以读写分离、分库分表等方案进行解决
2.说说Session 分布式方案
基于 nfs(net filesystem) 的 Session 共享
将共享服务器目录 mount 各服务器的本地 session 目录,session 读写受共享服务器 io 限制,不能满足高并发
基于关系数据库的 Session 共享
这种方案普遍使用。使用关系数据库存储 session 数据,对于 mysql 数据库,建议使用 heap 引擎。这种方案性能取决于数据库的性能,在高并发下容易造成表锁(虽然可以采用行锁的存储引擎,性能会下降),并且需要自己实现 session 过期淘汰机制
基于 Cookie 的 Session 共享
这种方案也在大型互联网中普遍使用,将用户的 session 加密序列化后以 cookie 的方式保存在网站根域名下(比如 taobao.com),当用户访问所有二级域名站点式,浏览器会传递所有匹配的根域名的 cookie 信息,这样实现了用户 cookie 化 session 的多服务共享。此方案能够节省大量服务器资源,缺点是存储的信息长度受到 http 协议限制;cookie 的信息还需要做加密解密;请求任何资源时都会将 cookie 附加到 http 头上传到服务器,占用了一定带宽
基于 Web 容器的 Session 机制
利用容器机制,通过配置即可实现
基于 Zookeeper 的分布式 Session 存储
基于 Redis/Memcached 的 Session 共享存储
这些 key/value 非关系存储有较高的性能,轻松达到 2000 左右的 qps,内置的过期机制正好满足 session 的自动实效特性
3.分布式锁的场景与实现
使用场景
首先,我们看这样一个场景:客户下单的时候,我们调用库存中心进行减库存,那我们一般的操作都是:
update store set num = #{num} where id = #{id}
这种通过设置库存的修改方式,我们知道在并发量高的时候会存在数据库的丢失更新,比如 a, b 当前两个事务,查询出来的库存都是 5,a 买了 3 个单子要把库存设置为 2,而 b 买了 1 个单子要把库存设置为 4,那这个时候就会出现 a 会覆盖 b 的更新,所以我们更多的都是会加个条件:
update store set num = #{num} where id = #{id} and num = #{query_num}
即乐观锁的方式来处理,当然也可以通过版本号来处理乐观锁,都是一样的,但是这是更新一个表,如果我们牵扯到多个表呢,我们希望和这个单子关联的所有的表同一时间只能被一个线程来处理更新,多个线程按照不同的顺序去更新同一个单子关联的不同数据,出现死锁的概率比较大。对于非敏感的数据,我们也没有必要去都加乐观锁处理,我们的服务都是多机器部署的,要保证多进程多线程同时只能有一个进程的一个线程去处理,这个时候我们就需要用到分布式锁。分布式锁的实现方式有很多,我们今天分别通过数据库,Zookeeper, Redis 以及 Tair 的实现逻辑
数据库实现
加 xx 锁
更新一个单子关联的所有的数据,先查询出这个单子,并加上排他锁,在进行一系列的更新操作
begin transaction;
select ...for update;
doSomething();
commit();
这种处理主要依靠排他锁来阻塞其他线程,不过这个需要注意几点:
- 查询的数据一定要在数据库里存在,如果不存在的话,数据库会加 gap 锁,而 gap 锁之间是兼容的,这种如果两个线程都加了gap 锁,另一个再更新的话会出现死锁。不过一般能更新的数据都是存在的
- 后续的处理流程需要尽可能的时间短,即在更新的时候提前准备好数据,保证事务处理的时间足够的短,流程足够的短,因为开启事务是一直占着连接的,如果流程比较长会消耗过多的数据库连接的
唯一键
通过在一张表里创建唯一键来获取锁,比如执行 saveStore 这个方法
insert table lock_store ('method_name') values(#{method_name})
其中 method_name 是个唯一键,通过这种方式也可以做到,解锁的时候直接删除改行记录就行。不过这种方式,锁就不会是阻塞式的,因为插入数据是立马可以得到返回结果的
那针对以上数据库实现的两种分布式锁,存在什么样的优缺点呢?
优点
简单,方便,快速实现
缺点
- 基于数据库,开销比较大,性能可能会存在影响
- 基于数据库的当前读来实现,数据库会在底层做优化,可能用到索引,可能不用到索引,这个依赖于查询计划的分析
Zookeeper 实现
获取锁
- 先有一个锁跟节点,lockRootNode,这可以是一个永久的节点
- 客户端获取锁,先在 lockRootNode 下创建一个顺序的瞬时节点,保证客户端断开连接,节点也自动删除
- 调用 lockRootNode 父节点的 getChildren() 方法,获取所有的节点,并从小到大排序,如果创建的最小的节点是当前节点,则返回 true,获取锁成功,否则,关注比自己序号小的节点的释放动作(exist watch),这样可以保证每一个客户端只需要关注一个节点,不需要关注所有的节点,避免羊群效应
- 如果有节点释放操作,重复步骤 3
释放锁
只需要删除步骤 2 中创建的节点即可
使用 Zookeeper 的分布式锁存在什么样的优缺点呢?
优点
- 客户端如果出现宕机故障的话,锁可以马上释放
- 可以实现阻塞式锁,通过 watcher 监听,实现起来也比较简单
- 集群模式,稳定性比较高
缺点
- 一旦网络有任何的抖动,Zookeeper 就会认为客户端已经宕机,就会断掉连接,其他客户端就可以获取到锁。当然 Zookeeper 有重试机制,这个就比较依赖于其重试机制的策略了
- 性能上不如缓存
Redis 实现
我们先举个例子,比如现在我要更新产品的信息,产品的唯一键就是 productId
public boolean lock(String key, V v, int expireTime){
int retry = 0;
//获取锁失败最多尝试10次
while (retry < failRetryTimes){
//获取锁
Boolean result = redis.setNx(key, v, expireTime);
if (result){
return true;
}
try {
//获取锁失败间隔一段时间重试
TimeUnit.MILLISECONDS.sleep(sleepInterval);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
public boolean unlock(String key){
return redis.delete(key);
}
public static void main(String[] args) {
Integer productId = 324324;
RedisLock<Integer> redisLock = new RedisLock<Integer>();
redisLock.lock(productId+"", productId, 1000);
}
}
这是一个简单的实现,存在的问题:
- 可能会导致当前线程的锁误被其他线程释放,比如 a 线程获取到了锁正在执行,但是由于内部流程处理超时或者 gc 导致锁过期,这个时候b线程获取到了锁,a 和 b 线程处理的是同一个 productId,b还在处理的过程中,这个时候 a 处理完了,a 去释放锁,可能就会导致 a 把 b 获取的锁释放了
- 不能实现可重入
- 客户端如果第一次已经设置成功,但是由于超时返回失败,此后客户端尝试会一直失败
针对以上问题我们改进下:
- v 传 requestId,然后我们在释放锁的时候判断一下,如果是当前 requestId,那就可以释放,否则不允许释放
- 入 count 的锁计数,在获取锁的时候查询一次,如果是当前线程已经持有的锁,那锁技术加 1,直接返回 true
Tair 实现
通过 Tair 来实现分布式锁和 Redis 的实现核心差不多,不过 Tair 有个很方便的 api,感觉是实现分布式锁的最佳配置,就是 Put api 调用的时候需要传入一个 version,就和数据库的乐观锁一样,修改数据之后,版本会自动累加,如果传入的版本和当前数据版本不一致,就不允许修改
其实还有分布式事务 和集群的算法想写但是没写 个人感觉不是太会问到 如果大家感兴趣的话可以给我评论 我在补出来
前几期系列有心态篇,基础篇,集合篇,线程篇,锁机制篇,Spring框架篇
本系列总共所涉及Java基础,集合,线程,锁机制,spring框架,分布式,微服务,数据存储,缓存使用,消息队列,安全,性能调优,设计模式以及需求分析