分布式 —— 分布式计算 分布式存储

2023年8月,通过xianling哥的讲解,终于大彻大悟分布式系统了!

分布式出现的思想:为了用廉价的、普通的机器完成昂贵的大型主机无法完成的计算、存储任务。其目的是利用更多的机器,处理更多的数据。

一、其实你早就接触了什么是分布式、什么是中间件

但凡有两台服务器不在一起,就是分布式系统。

分布式不是近几年出现的技术,在2000年初期,阿里的业务一个服务器就放不下了,那时候就在研究分布式了。

单体架构 VS 分布式架构

缺点

  1. 但凡是一个标点符号写错了,都要整个系统重新编译发布,维护成本太高

  1. 一个系统的所有功能必须使用同一种语言开发,如果想要换语言,就要全部推翻重来。但是分布式系统的不同功能模块可以用不同的语言开发(比如商品模块用Java,订单模块用go语言),因为消息中间件可以屏蔽语言之间的差异。

二、理解分布式计算和分布式存储

二者区别

分布式计算

  • 应用服务器的解耦。比如阿里巴巴以前只用一台服务器做用户注册、订单、支付、售后等所有的服务。现在拆分成用户注册服务器、订单提交服务器、支付服务器等。
  • 以前不注重服务的可用性的时候,订单系统可能只部署一个。但是我们现在考虑万一这个宕掉了怎么办? 所以同一个服务至少要部署两个(这里服务的意思就是整个project,部署的时候不是以接口为单位,而是以project为单位。当然部署了多个project,那么一个接口也就有多个咯),当然这两个可以在同一台机器上,也可以在不同的机器上。一般是在不同的机器上,因为万一机器宕掉了,两个在同一台机器上的话不就都完蛋了吗

分布式存储:就是mysql集群。数据量太大了,单机mysql放不了了,就把数据分库分表放到10台mysql服务器上,每台服务器各存1/10的数据。

三、CAP和BASE理论

四、分布式系统进程间通信(分布式系统的核心

分布式的核心内容是IPC进程间通信,注意是服务器和服务器之间的通信,有如下几种方式:

  • RPC 远程过程调用:屏蔽底层细节,调用远程方法就像调用本地方法一样(同步

  • client stub 和 server stub

  • RMC 远程方法调用(同步

  • MOM 消息中间件(不要求双方在线,异步

  • HTTP(spring cloud NetFlix就是用HTTP通信)

既然是两台机器之间的通信,为什么不直接用TCP/IP?

首先,不是不用TCP/IP,而是把TCP/IP作为通信的底层,在TCP/IP之上构建RPC、RMC、和MOM的框架。因为TCP/IP的字段不能满足进程间的需求,我们要新增一些字段,所以就要在TCP/IP协议的基础上构造中间件自己的协议。换句话说,消息中间件的底层协议还是TCP/IP协议

其实可以用HTTP请求,但是我们不用,道理很简单,比如出门防晒你其实可以把塑料袋套头上,但是哪有戴帽子合适呀!不用HTTP实现两个机器的通信是因为,HTTP一般用于客户端对服务器的一次性请求,建立短链接。但是分布式系统的进程间通信不是客户端和服务器的通信,而是多个服务器之间的通信。比如有一个订单业务服务器,一个会员业务服务器,二者之间如何交互,这是一个长期通信的过程,需要建立长链接。

 我们需要在TCP/IP基础上自己重新构建一种通信框架,也就是RPC、RMC和MOM。

MOM分类

  • 分布式消息中间件

  • ActiveMQ

  • 不用了

  • RabbitMQ

  • 开源

  • 和spring是一家开发出来的,和spring的适配性最高

  • 美团、滴滴都在用

  • Kafka

  • 性能最好,但不支持事务

  • 京东的JMQ就是在kafaka外面套了一个壳

  • RocketMQ

  • 阿里和滴滴自研的

消息中间件MOM理解

RPC和RMC通信的缺点

  1. 要求client和server同时在线

  1. client会被阻塞,什么都不能干(这是同步的一种特点,异步的话就是两个线程各干各的,谁也不需要等谁)

MOM的优点

  1. 不要求client和server同时在线

  1. 延迟隐藏。client请求完了以后,直接去做下一件的任务,感受不到client的阻塞

MOM 都有哪些协议?

MOM如何实现的异步?

借助消息队列。

MOM工作流程

不同消息格式的转换,

client订购消息,server推送消息

五、分布式系统下如何生成UUID

林哥给出的生成UUID唯一的方法:

  1. 法一:MYSQL层校验
  2. 法二:在service层生成UUID,去MySQL校验是否存在,存在再重新生成(for循环

        首先要明确一下,一提到UUID本身指的就是分布式系统下的唯一ID

        在单体系统中,我们可以用时间戳,也就是当前时间距离1970年1月1日零点的毫秒数的方式实现ID,但是也会存在同一毫秒内产生两个实体的情况,所以我们采用毫秒数+随机数(浪潮就是这么实现的),但是这样做缺少了验证这一步,虽然99.9999%是唯一的,但是还是不能保证百分之百哦,所以林哥才会反复提到“MySQL层校验”。

生成分布式UUID的常见方案:

  • 借助数据库主键
  • redis
  • 算法,就是想个办法拼凑出来全局唯一的ID
    • java原生的UUID(不推荐使用,但是jd也在用)
    • 改进java原生的UUID:
      java 原生的UUID为36位 or 32位,太长. 这里提供一个位数较短的UUID.
      * UUID生成规则,当前时间减去'零时'的毫秒数 + N位随机数,转变成62进制的String类型.
      * 当前配置可满足30年内每毫秒10^9分之一的碰撞.
      * 实测现在长度为13位,想要更短的话可以调整下方的几个参数
    • Twitter开源的雪花算法,没有两篇雪花是相同的。用时间戳,机房ID,机器ID,每台机器上的自增数合成
    • 美团开源的落叶算法,同样,“没有两片相同的落叶”
    • car-fence用的糊涂工具包hutool

法(一)数据库主键自增

        如何让数据库主键自增呢?就要每次索要UUID的时候向表里插入一条数据,当然这条数据本身没有任何意义,只是为了推动主键自增,然后把生成的主键返回给java程序

CREATE TABLE `sequence_id` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `stub` char(10) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  UNIQUE KEY `stub` (`stub`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

优点:实现简单,仅此而已

缺点

  • 每次获取UUID都要访问数据库,访问数据库可比访问redis缓存慢多了,由此可见这种实现方式比不上接下来要将的借助redis实现。
  • ID没有业务含义,通过主键看不出这是个啥
  • 可以通过主键的差值判断一天产生了多少订单量,这可是商业机密呀
  • 淘宝用户有上亿个,每个用户都有UUID,一个表肯定放不下,那么就要分库分表,分库分表也挺累的。

法(二)数据库号段(其实这个我没看懂哈)

        批量获取,然后存在在内存里面,需要用到的时候,直接从内存里面拿就舒服了,主要是减少了访问数据库的次数。

        数据库的号段模式也是目前比较主流的一种分布式 ID 生成方式。像滴滴开源的Tinyidopen in new window 就是基于这种方式来做的。不过,TinyId 使用了双号段缓存、增加多 db 支持等方式来进一步优化

1. 创建一个数据库表。

CREATE TABLE `sequence_id_generator` (
  `id` int(10) NOT NULL,
  `current_max_id` bigint(20) NOT NULL COMMENT '当前最大id',
  `step` int(10) NOT NULL COMMENT '号段的长度',
  `version` int(20) NOT NULL COMMENT '版本号',
  `biz_type`    int(20) NOT NULL COMMENT '业务类型',
   PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

 一次获取step长度的id,获取的批量 id 为:current_max_id ~ current_max_id+step,version 字段主要用于解决并发问题(乐观锁)

2. 先插入一行数据

INSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`)
VALUES
 (1, 0, 100, 0, 101);

3. 通过 SELECT 获取指定业务下的批量唯一 ID

业务和业务之间的UUID是独立的,可以重复。可能后续会把biz_type字段拼到UUID里

SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101

4. 不够用的话,更新之后重新 SELECT 即可。

UPDATE sequence_id_generator SET current_max_id = 0+100, version=version+1 WHERE version = 0  AND `biz_type` = 101
SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101

优点:相比于上一个方法,减少了访问数据库的次数

缺点: 就是上一个方法的另外3个缺点

  • ID没有业务含义,通过主键看不出这是个啥
  • 可以通过主键的差值判断一天产生了多少订单量,这可是商业机密呀
  • 淘宝用户有上亿个,每个用户都有UUID,一个表肯定放不下,那么就要分库分表,分库分表也挺累的。

 法(三)redis的incr自增   

incr UUID 

法(四) 算法们

上面也都说过了

六、分布式锁

        先来看一个用Redis实现分布式锁的完整代码:

public List<Node> getDataByRedisLock(){
    List<Node> nodeList = null;
    //===================加锁
    String uuid = UUID.randomUUID().toString();
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("redis-lock", uuid,100,TimeUnit.SECONDS);
    
    if (lock){
        try {
            nodeList = getNodesByMysql();
        }finally {
            //===================解锁
            //lua脚本
            String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then return redis.call(\"del\",KEYS[1]) else return 0 end";
            redisTemplate.execute(
                new DefaultRedisScript<Long>(script,Long.class),
                Arrays.asList("redis-lock"),
                uuid
            );
        }
    }else { //注意,如果是用synchronize这种本地锁的话,
//没获取到锁的时候会自动阻塞,然后尝试下一次加锁,
//这是synchronize底层帮我们写好了的;但是这里redis得靠我们手动实现
        try {
            System.out.println("没有获取锁重试");
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //重试
        getDataByRedisLock();
    }
 
    return nodeList;
}

        是不是很简单? 虽然接下来我们要讲一大堆分布式锁的原理,但是实际使用起来,只有两行代码而已,当然咯简单是因为底层封装的好,我们当然要去看一下底层。

        分布式锁的实现有很多形式,只不过这里选择了用Redis的setIfAbsent方法实现分布式锁,底层其实是retranlock,是一个悲观锁。

6.1 为什么要有分布式锁?或者说,分布式锁和本地锁的区别在于?

        其独特性在于分布式系统中,相同的服务被部署在不同的机器上。单体系统下,对多线程加锁,生成的这个锁放在JVM运行时数据区里,因为所有线程都在同一台机器上,所以大家都能访问到。但是分布式系统下,我在一台机器上生成一个锁,其他机器是访问不到的呀。所以分布式锁必须放在多台机器都能访问到的地方,比如Redis,数据库这种所有机器都能访问到的公共存储区。

6.2 分布式锁的实现方式

常见分布式锁实现方案如下:

  • 借助关系型数据库比如 MySQL 实现分布式锁(×,一般是依托于mysql的唯一索引或排它锁去实现,性能太差)
  • 借助分布式协调服务 ZooKeeper 实现分布式锁(√)
  • 借助分布式键值存储系统比如 Redis 实现分布式锁(√)

6.3 我们今天主要讲的是用Redis实现分布式锁

(1)保证上锁的原子性

        可以自己先想一下如何实现。

        肯定不是用watch命令哈,watch命令实现的是Redis级别的乐观锁,但是我们现在要实现的java程序级别的分布式锁。

        加锁就是“占坑”,告诉别人我已经开始用这个资源了,并且加锁之前还要判断别人有没有这个锁,是不是想起来了setnx命令?因为setnx命令就是在set之前要判断一下这个key是否已经存在了。但是还有一个问题,为了避免还没解锁前服务就宕掉了导致这个资源被一直锁着,所以我们必须在加锁的同时给这个锁设定过期时间,也就是说我们的① set一个键值对,② 判断是否已经存在,③设置过期时间 这3个操作不能被打断,也就说必须放在同一个原子操作里,而我们又知道由于Redis是单线程的,所以Redis的一条命令就是一个原子操作,那么我们就需要一条命令同时去做上面的事情。setex可以实现吗?No

        好了不绕弯子了,Redis命令是这样实现的:

set 锁的key 锁的value nx ex 50

        对应到java里,Redistemplate帮我们封装了这样一个方法:

redisTemplate.opsForValue().setIfAbsent(key, value, releaseTime, TimeUnit.SECONDS);

        我们又会想,给键值对设置过期时间,万一被锁的资源没用完呢,键值对就过期了怎么办?会出现两个问题:

问题①:这个时候线程2跑来尝试加锁,发现居然加上了,那么此时线程1和线程2同时操作资源,会出问题的呀

问题②:而且就算同时操作资源这一步没出问题,此时只有一把锁,但是线程1和2都还没执行删锁操作,线程1会把线程2的锁给删掉!

        没关系,还有续约操作~  PS: 有的系统不注重性能,可能会把过期时间设置的特别大,这样它就不用考虑续约了。

(2)看门狗续约

        续约操作也很复杂,所以直接写了一个第三方包。

        对于 Java 开发的小伙伴来说,已经有了现成的解决方案:Redissonopen in new window 。其他语言的解决方案,可以在 Redis 官方文档中找到,地址:https://redis.io/docs/manual/patterns/distributed-locks/

        jar包:

    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.15.6</version>
    </dependency>

         配置bean:

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient(){
        Config config = new Config();
        config.setTransportMode(TransportMode.NIO);
        SingleServerConfig singleServerConfig = config.useSingleServer();
        //可以用"rediss://"来启用SSL连接
        singleServerConfig.setAddress("redis://linux100:6379");
        RedissonClient redisson = Redisson.create(config);
        return redisson;
    }
}

         redisson使用起来就跟本地锁一样:

@RestController
public class UserController {
    @Autowired
    private RedissonClient redissonClient;
    @GetMapping("/test")
    public String test() {
        RLock lock = redissonClient.getLock("test-lock");
        try {
            System.out.println("加锁成功!!!");
            Thread.sleep(10000);
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            System.out.println("释放锁成功!!!");
            lock.unlock();
        }
        return "success";
    }
}

         如果我们不就是不想导入redisson,不采用续约的操作呢?那就会出现上述两个问题

         而下面保证删锁原子性的操作只能解决第二个问题,还是存在第一种问题的隐患!

(3)保证删锁的原子性(如果采用看门狗的话,那么直接删就行了,完全不用设置UUID)

        理想情况下(java线程顺序执行的情况下)肯定是用完资源以后,用redis的del命令删掉这个锁,然后第二个线程继续上锁。但是存在这样一种情况,线程1加锁用资源的时候,超出了过期时间还没用完,因为没采用看门狗续约此时锁过期了,线程2尝试加锁发现加上了!因为锁是同名的,所以线程1最后在finally里执行删锁操作的时候把线程2加的锁给删掉了。

        解决方法有两个: 

法(一):对过期时间进行续约,这样就不会出现线程1的锁过期,线程2来加锁的情况了,这是从源头解决问题

法(二):给每个锁配备一个UUID作为value,删锁之前用UUID判断一下是不是自己设置的锁

对于法(二),实现如下

 还会有一个问题(我靠这个太难想到了,但是操作系统里举过这个例子),因为判断UUID和删锁的操作是分两步实现的:

  • 判断UUID
  • 删锁

如果线程1判断完UUID确实是自己的,可以开始删了!这个时候刚好线程1被CPU切换走了,刚好线程1的锁过期了,另一台机器上的线程2刚好尝试来加锁发现加上了,然后CPU调度回线程1的时候线程1直接第二步执行删锁操作,又出错了!解决方法只有一个就是让判断UUID和删锁的操作不要分成两步,而是作为一个原子性操作,java实在无能为力了,只能借用Lua脚本

七、高性能

八、高可用

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值