Java面试必知-分布式


分布式系统

分布式系统一定是由多个节点组成的系统。其中,节点指的是计算机服务器,而且这些节点一般不是孤立的,而是互通的。

这些连通的节点上部署了我们的节点,并且相互的操作会有协同。

分布式应用用到的技术: 网络通信,基于消息方式的系统间通信和基于远程调用的系统间通信。

基于Java实现消息方式的系统间通信的方式有: TCP/IP+BIO、TCP/IP+NIO、UDP/IP+BIO、UDP/IP+NIO 4种方式

TCP/IP+BIO 在 Java 中可基于 Socket、ServerSocket 来实现 TCP/IP+BIO 的系统间通信。

TCP/IP +NIO 异步通信:JAVA NIO 通道技术实现。

  • 分布式与单机情况下最大的不同在于其不是多线程而是多进程

  • 多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。

分布式锁

基本应用

	public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
        String uuid = UUID.randomUUID().toString();
        // 分布式锁, setIfAbsent就是setnx
        // 相当于redis命令:set lock 111 EX 30 NX
        // ttl lock  可查看当前过期时间
        // 分布式锁, setIfAbsent就是setnx
        // 设置过期时间, 防止无法及时释放锁或者忘记释放锁
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
        // 抢占key为lock的锁成功
        Map<String, List<Catelog2Vo>> dataFromDb = null;
        if(lock) {
            try {
                dataFromDb = getDataFromDb();
            } finally {
                // lua脚本释放锁
                String script = "if redis.call('get', KEY[1]) == ARGV[1] then return.call('del', KEYS[1]) else return 0 end";
                // 原子释放锁
                redisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class), Arrays.asList("lock"), uuid);
            }
//            String lockValue = redisTemplate.opsForValue().get("lock"); // 在返回查询结果过程中该lock刚好过期,可能造成误删
//            if(uuid.equals(lockValue)) {
//                redisTemplate.delete(lockValue); // 释放锁
//            }
            return dataFromDb;
        } else {
            // 加锁失败,重试
            return getCatalogJsonFromDbWithRedisLock(); // 自旋的方式
        }
    }

为了防止分布式系统中的多个进程之间相互干扰,我们需要一种分布式协调技术来对这些进程进行调度。而这个分布式协调技术的核心就是来实现这个分布式锁

可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。

这把锁要是一把可重入锁(避免死锁)

这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)

这把锁最好是一把公平锁(根据业务需求考虑要不要这条)

有高可用的获取锁和释放锁功能

获取锁和释放锁的性能要好

分布式锁应该具备哪些条件

  • 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
  • 高可用的获取锁与释放锁
  • 高性能的获取锁与释放锁
  • 具备可重入特性(可理解为重新进入,由多于一个任务并发使用,而不必担心数据错误)
  • 具备锁失效机制,防止死锁
  • 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败

分布式锁的实现有哪些

  • Redis:和 Memcached 的方式类似,利用 Redis 的 setnx 命令。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。
  • Zookeeper:利用 Zookeeper 的顺序临时节点,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。
  • MySQL基于数据库实现分布式锁
  • Memcached:利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,也就意味着线程得到了锁。
  • Chubby:Google 公司实现的粗粒度分布式锁服务,底层利用了 Paxos 一致性算法。

从理解的难易程度角度(从低到高)数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高)Zookeeper >= 缓存 > 数据库

从性能角度(从高到低)缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)Zookeeper > 缓存 > 数据库

MySQL实现分布式锁

基于数据库实现原理

新建锁表记录
CREATE TABLE `methodLock` ( 
    `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',  
    `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
    `desc` varchar(1024) NOT NULL DEFAULT '备注信息',  
    `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',  
    PRIMARY KEY (`id`),  
    UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE 
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

1.唯一约束实现

当想要锁住某个方法时执行insert方法,插入一条数据,method_name有唯一约束,可以保证多次提交只有一次成功,而成功的这次就可以认为其获得了锁,而执行完成后执行delete语句释放锁

缺点:

这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。

这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。

这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。

这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。

2.排它锁(悲观锁)实现

还是使用上方的表结构,可以通过数据库的排他锁来实现分布式锁

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过connection.commit();操作来释放锁

代码:

public boolean lock(){
    connection.setAutoCommit(false)
    while(true){
        try{
            result = select * from methodLock where method_name=xxx for update;
            if(result==null){
            	return true;
            }
        } catch(Exception e){}
        sleep(1000);
    }
    return false;
}

public void unlock(){
	connection.commit();
}

3.乐观锁实现

一般是通过为数据库表添加一个 “version”字段来实现读取出数据时,将此版本号一同读出,之后更新时,对此版本号加1,在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败,实际就是个diff过程

缺点:

(1). 这种操作方式,使原本一次的update操作,必须变为2次操作: select版本号一次;update一次。增加了数据库操作的次数。

(2). 如果业务场景中的一次业务流程中,多个资源都需要用保证数据一致性,那么如果全部使用基于数据库资源表的乐观锁,就要让每个资源都有一张资源表,这个在实际使用场景中肯定是无法满足的。而且这些都基于数据库操作,在高并发的要求下,对数据库连接的开销一定是无法忍受的。

(3). 乐观锁机制往往基于系统中的数据存储逻辑,因此可能会造成脏数据被更新到数据库中。

数据库锁现在使用较多的就上面说的3种方式,排他锁(悲观锁),版本号(乐观锁),记录锁,各有优缺点

数据库锁的优点就是 直接借助DB简单易懂

缺点也很明显:

会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。

操作数据库需要一定的开销,性能问题需要考虑

Redis分布式锁

获取锁

最简单的方法是使用 setnx 命令。key 是锁的唯一标识,按业务来决定命名。比如想要给一种商品的秒杀活动加锁,可以给 key 命名为 “lock_sale_商品ID” 。

当一个线程执行 setnx 返回 1,说明 key 原本不存在,该线程成功得到了锁;当一个线程执行 setnx 返回 0,说明 key 已经存在,该线程抢锁失败。

释放锁

有加锁就得有解锁。当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行 del 指令:del(lock_sale_id)

释放锁之后,其他线程就可以继续执行 setnx 命令来获得锁。

锁超时

锁超时是什么意思呢?如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住(死锁),别的线程再也别想进来。所以,setnxkey 必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放。setnx 不支持超时参数,所以需要额外的指令,伪代码:expire(lock_sale_id, 30);

伪代码示例:

if(setnx(lock_sale_商品ID,1) == 1){ // 获得锁
    expire(lock_sale_商品ID,30) // 设置锁超时时间
    try {
        // 业务逻辑代码
    } finally {
        del(lock_sale_商品ID)  // 释放锁
    }
}

setnxexpire 的非原子性

setnx 刚执行成功,还未来得及执行 expire 指令,节点 挂掉了

这样一来,这把锁就没有设置过期时间,变成死锁,别的线程再也无法获得锁了。

解决:setnx 指令本身是不支持传入超时时间的,set 指令增加了可选参数,伪代码:set(lock_sale_商品ID,1,30,NX)

del 导致误删

如果某些原因导致线程 A 执行的很慢很慢,过了 30 秒都没执行完,这时候锁过期自动释放,线程 B 得到了锁。随后,线程 A 执行完了任务,线程 A 接着执行 del 指令来释放锁。但这时候线程 B 还没执行完,线程A实际上 删除的是线程 B 加的锁

解决:可以在 del 释放锁之前做一个判断,验证当前的锁是不是自己加的锁。至于具体的实现,可以在加锁的时候把当前的线程 ID 当做 value,并在删除之前验证 key 对应的 value 是不是自己线程的 ID。

加锁:

String threadId = Thread.currentThread().getId()
set(key,threadId ,30,NX)

解锁:

if(threadId .equals(redisClient.get(key))){
    del(key)
}

出现并发的可能性

同一时间有 A,B 两个线程在访问代码块,可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续航”。当过去了 29 秒,线程 A 还没执行完,这时候守护线程会执行 expire 指令,为这把锁“续命”20 秒。守护线程从第 29 秒开始执行,每 20 秒执行一次,直到线程A执行完毕释放锁,会显式关掉守护线程。

另一种情况,如果节点 1 忽然断电,由于线程 A 和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续命,也就自动释放了。

使用RestTemplate方案

获取锁的命令:SET resource_name my_random_value NX PX 30000

方案:

try{
	lock = redisTemplate.opsForValue().setIfAbsent(lockKey, LOCK);
	logger.info("cancelCouponCode是否获取到锁:"+lock);
	if (lock) {
		// TODO
		redisTemplate.expire(lockKey,1, TimeUnit.MINUTES); //成功设置过期时间
		return res;
	}else {
		logger.info("cancelCouponCode没有获取到锁,不执行任务!");
	}
}finally{
	if(lock){	
		redisTemplate.delete(lockKey);
		logger.info("cancelCouponCode任务结束,释放锁!");		
	}else{
		logger.info("cancelCouponCode没有获取到锁,无需释放锁!");
	}
}

缺点:

在这种场景(主从结构)中存在明显的竞态:
客户端A从master获取到锁,
在master将锁同步到slave之前,master宕掉了。
slave节点被晋级为master节点,
客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。安全失效

分布式锁框架Redisson

不用Redison:

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
        String uuid = UUID.randomUUID().toString();
        // 分布式锁, setIfAbsent就是setnx
        // 设置过期时间, 防止无法及时释放锁或者忘记释放锁
    	// 相当于redis命令:set lock 111 EX 30 NX
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
        // 抢占key为lock的锁成功
        Map<String, List<Catelog2Vo>> dataFromDb = null;
        if(lock) {
            try {
                dataFromDb = getDataFromDb();
            } finally {
                // lua脚本释放锁
                String script = "if redis.call('get', KEY[1]) == ARGV[1] then return.call('del', KEYS[1]) else return 0 end";
                // 原子释放锁
                redisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class), Arrays.asList("lock"), uuid);
            }
//            String lockValue = redisTemplate.opsForValue().get("lock"); // 在返回查询结果过程中该lock刚好过期,可能造成误删
//            if(uuid.equals(lockValue)) {
//                redisTemplate.delete(lockValue); // 释放锁
//            }
            return dataFromDb;
        } else {
            // 加锁失败,重试
            return getCatalogJsonFromDbWithRedisLock(); // 自旋的方式
        }
    }

Redisson程序化配置

    @Bean(destroyMethod = "shutdown") // 销毁方法
    public RedissonClient redisson() throws IOException {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.200.128:6379");
        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }

基本使用

	/**
	 * RLock锁有看门狗机制 会自动帮我们续期,默认三秒自动过期
	 * lock.lock(10,TimeUnit.SECONDS); 十二猴子的时间一定要大于业务的时间 否则会出现死锁的情况
	 * 如果我们传递了锁的超时时间就给redis发送超时脚本 默认超时时间就是我们指定的
	 * 如果我们未指定,就使用 30 * 1000 [LockWatchdogTimeout]
	 * 只要占锁成功 就会启动一个定时任务 任务就是重新给锁设置过期时间 这个时间还是 [LockWatchdogTimeout] 的时间 1/3 看门狗的时间续期一次 		续成满时间
	 */
	@ResponseBody
    @GetMapping("/index/hello")
    public String hello() {
        RLock lock = redissonClient.getLock("my-lock");
        lock.lock(); // 阻塞等待,默认加锁的都是30秒
        try {
            // 如果业务超时,看门狗会自动延时新的30s
            Thread.sleep(30000);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
        return "hello";
    }

读写锁, 读锁是排它锁、写锁的共享锁

写 + 写:阻塞

写 + 读:等待写锁释放

读 + 读:相当于无锁

读 + 写:等待读锁释放

 	@ResponseBody
    @GetMapping("/index/write")
    public String writeValue() {
        RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
        RLock rLock = lock.writeLock(); // 读写加锁转换
        String s = "";
        try {
        	// 改数据加写锁,读数据加读锁
            rLock.lock();
            s = UUID.randomUUID().toString();
            Thread.sleep(3000);
            stringRedisTemplate.opsForValue().set("rw-lock", s);
        } catch(Exception e) {
            e.printStackTrace();
        } finally {
            rLock.unlock();
        }
        return s;
    }

闭锁,

  	// 闭锁 只有设定的人全通过才关门
	@ResponseBody
    @GetMapping("index/lockDoor")
    public String lockDoor() {
        RCountDownLatch door = redissonClient.getCountDownLatch("door"); 
        door.trySetCount(5); 
        door.await(); // 每次被获取到door锁就减一直到为0。5个锁都被获取完才接着往下走
        return "5个人全部通过";
    }

    @ResponseBody
    @GetMapping("index/go/{id}")
    public String go(@PathVariable("id") Long id) {
        RCountDownLatch door = redissonClient.getCountDownLatch("door");
        door.countDown();// 减一
        return id + "走了";
    }

信号量,设置一定信号量,可用于限流

    /**
     * 尝试获取车位 [信号量]
     * 信号量:也可以用作限流
     */
    @ResponseBody
    @GetMapping("/index/park")
    public String park() {

        RSemaphore park = redissonClient.getSemaphore("park");
        boolean acquire = park.tryAcquire(); // 一直尝试获取信号量
        return "获取车位 =>" + acquire;
    }

    /**
     * 尝试获取车位
     */
    @ResponseBody
    @GetMapping("/index/go/park")
    public String goPark() {
        RSemaphore park = redissonClient.getSemaphore("park");
        park.release();// 释放信号量
        return "ok => 车位+1";
    }

zookeeper分布式锁

Zookeeper的数据存储结构就像一棵树,这棵树由节点组成,这种节点叫做Znode。

Znode分为四种类型:

1.持久节点 (PERSISTENT)

默认的节点类型。创建节点的客户端与zookeeper断开连接后,该节点依旧存在 。

2.持久节点顺序节点(PERSISTENT_SEQUENTIAL)

所谓顺序节点,就是在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号:

3.临时节点(EPHEMERAL)

和持久节点相反,当创建节点的客户端与zookeeper断开连接后,临时节点会被删除:

4.临时顺序节点(EPHEMERAL_SEQUENTIAL)

顾名思义,临时顺序节点结合和临时节点和顺序节点的特点:在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号;当创建节点的客户端与zookeeper断开连接后,临时节点会被删除。

Zookeeper分布式锁的原理

Zookeeper分布式锁恰恰应用了临时顺序节点。具体如何实现呢?让我们来看一看详细步骤:

获取锁

首先,在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点 Lock1。

之后,Client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。

这时候,如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。

Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。

于是,Client2向排序仅比它靠前的节点Lock1注册Watcher,用于监听Lock1节点是否存在。这意味着Client2抢锁失败,进入了等待状态。

这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。

Client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。

于是,Client3向排序仅比它靠前的节点Lock2注册Watcher,用于监听Lock2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态。

这样一来,Client1得到了锁,Client2监听了Lock1,Client3监听了Lock2。这恰恰形成了一个等待队列,很像是Java当中ReentrantLock所依赖的

释放锁

释放锁分为两种情况:

1.任务完成,客户端显示释放

当任务完成时,Client1会显示调用删除节点Lock1的指令。

2.任务执行过程中,客户端崩溃

获得锁的Client1在任务执行过程中,如果Duang的一声崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之自动删除。

由于Client2一直监听着Lock1的存在状态,当Lock1节点被删除,Client2会立刻收到通知。这时候Client2会再次查询ParentLock下面的所有节点,确认自己创建的节点Lock2是不是目前最小的节点。如果是最小,则Client2顺理成章获得了锁。

同理,如果Client2也因为任务完成或者节点崩溃而删除了节点Lock2,那么Client3就会接到通知。最终,Client3成功得到了锁。

方案

可以直接使用zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务。

Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。

缺点:

性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。

其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)

分布式事务

分布式事务指事务的操作位于不同的节点上,需要保证事务的 ACID 特性。例如在下单场景下,库存、订单、购物车如果不在同一个节点上,就涉及分布式事务。

两阶段提交(2PC)

XA 事务是基于两阶段提交协议的,所以需要有一个事务协调者(transaction manager)来保证所有的事务参与者都完成了准备工作(第一阶段)。如果事务协调者(transaction manager)收到所有参与者都准备好的消息,就会通知所有的事务都可以提交了(第二阶段)。MySQL 在这个XA事务中扮演的是参与者的角色,而不是事务协调者(transaction manager)。

通过引入协调者(Coordinator,类似Seata的协调者)来协调参与者的行为,并最终决定这些参与者是否要真正执行事务

准备阶段:

所有的参与者准备执行事务并锁住需要的资源,协调者询问参与者事务是否执行成功,参与者发回事务执行结果。

提交阶段

如果事务在每个参与者上都执行成功,事务协调者发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。

需要注意的是,在准备阶段,参与者执行了事务,但是还未提交。只有在提交阶段接收到协调者发来的通知后,才进行提交或者回滚。

存在的问题

  • 同步阻塞:所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,无法进行其它操作。

  • 单点问题:协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在阶段二发生故障,所有参与者会一直等待状态,无法完成其它操作。

  • 数据不一致:在阶段二,如果协调者只发送了部分 Commit 消息,此时网络发生异常,那么只有部分参与者接收到 Commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。

  • 太过保守:任意一个节点失败就会导致整个事务失败,没有完善的容错机制。

补偿事务(TCC)

TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:

  • Try 阶段主要是对业务系统做检测及资源预留
  • Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
  • Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些

缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。

常见问题:

1. 幂等处理

因为网络抖动等原因,分布式事务框架可能会重复调用同一个分布式事务中的一个分支事务的二阶段接口。所以分支事务的二阶段接口Confirm/Cancel需要能够保证幂等性。如果二阶段接口不能保证幂等性,则会产生严重的问题,造成资源的重复使用或者重复释放,进而导致业务故障。

从上图中红色部分可以看到:如果当TC调用参与者的二阶段方法时,发生了异常(TC本身异常或者网络异常丢失结果)。此时TC无法感知到调用的结果。为了保证分布式事务能够走到终态,此时TC会按照一定的规则重复调用参与者的二阶段方法。

对于幂等类型的问题,通常的手段是引入幂等字段进行防重放攻击。对于分布式事务框架中的幂等问题,同样可以祭出这一利器。我们可以通过增加一张事务状态控制表来实现,这个表的关键字段有以下几个:

  1. 主事务ID
  2. 分支事务ID
  3. 分支事务状态

其中1和2构成表的联合主键来唯一标识一笔分布式事务中的一条分支事务。3用来标识该分支事务的状态,一共有3种状态:

  1. INIT(I) - 初始化
  2. CONFIRMED© - 已提交
  3. ROLLBACKED® - 已回滚

2. 空回滚

当没有调用参与方Try方法的情况下,就调用了二阶段的Cancel方法,Cancel方法需要有办法识别出此时Try有没有执行。如果Try还没执行,表示这个Cancel操作是无效的,即本次Cancel属于空回滚;如果Try已经执行,那么执行的是正常的回滚逻辑。

可以通过查询控制表的对应记录进行判断。如果记录存在且状态为INIT,就表示一阶段已成功执行,可以正常执行回滚操作,释放预留的资源;如果记录不存在则表示一阶段未执行,本次为空回滚,不释放任何资源。

3. 资源悬挂

当分布式事务到终态后,参与者的一阶段Try才被执行,此时参与者会根据业务需求预留相关资源。预留资源只有当前事务才能使用,然而此时分布式事务已经走到终态,后续再没有任何手段能够处理这些预留资源。至此,就形成了资源悬挂。

处理方案为事务状态控制记录作为控制手段,二阶段发现无记录时插入记录,一阶段执行时检查记录是否存在

本地消息表(异步确保)

本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性,并且使用了消息队列来保证最终一致性。

  1. 在分布式事务操作的一方完成写业务数据的操作之后向本地消息表发送一个消息,本地事务能保证这个消息一定会被写入本地消息表中。
  2. 之后将本地消息表中的消息转发到 Kafka 等消息队列中,如果转发成功则将消息从本地消息表中删除,否则继续重新转发
  3. 在分布式事务操作的另一方从消息队列中读取一个消息,并执行消息中的操作。

优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。

缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。

MQ 事务消息

有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。

以阿里的 RocketMQ 中间件为例,其思路大致为:

第一阶段Prepared消息,会拿到消息的地址。 第二阶段执行本地事务,第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。

也就是说在业务方法内要向消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

优点: 实现了最终一致性,不需要依赖本地数据库事务。

缺点: 实现难度大,主流MQ不支持,RocketMQ事务消息部分代码也未开源。

面试

多台机器同时执行某一任务,要求某一时刻最多只有一台机器执行

思路:分布式锁:在mysql中插入一条记录,表明获取锁。删除一条记录,表明释放锁。 且在mysql表中设置一个unique key字段, 当有一台机器获得锁后, 其他机器无法获取。

​ 1. 如果一台机器获得锁,在释放锁之前进程挂了, 那么其他机器无法获取到锁。 可以引入锁有效时间的概念,超时后,删除记录,释放锁(必须做到可删除), 同时产生告警。

​ 2. 万一获取锁的操作失败了,就直接做错误处理, 也不太好。 可以引入循环重试的方式来解决,控制重试次数。

处理亿级流量以上的高并发

https://mp.weixin.qq.com/s/CxqnaB9cUemvCcWkw2nUcwhttps://mp.weixin.qq.com/s/CxqnaB9cUemvCcWkw2nUcw

可以围绕“支撑高并发的业务场景怎么设计系统才合理、硬件和软件层面怎么支撑高并发” 进行回答

面对超高的并发,首先硬件层面机器要能扛得住,其次架构设计做好微服务的拆分,代码层面各种缓存、削峰、解耦等等问题要处理好,数据库层面做好读写分离、分库分表,稳定性方面要保证有监控,熔断限流降级该有的必须要有,发生问题能及时发现处理

一、集群+微服务

集群架构的架构开始出现,单机无法抗住的压力,最简单的办法就是水平拓展横向扩容了,这样,通过负载均衡把压力流量分摊到不同的机器上,暂时是解决了单点导致服务不可用的问题。

集群:硬件上横向扩展 =》微服务:根据业务拆分服务

把每个独立的业务拆分开独立部署,开发和维护的成本降低,集群能承受的压力也提高了,再也不会出现一个小小的改动点需要牵一发而动全身了。

微服务化的拆分带来的好处和便利性是显而易见的,但是与此同时各个微服务之间的通信就需要考虑了。传统HTTP的通信方式对性能是极大的浪费,这时候就需要引入诸如Dubbo类的RPC框架,基于TCP长连接的方式提高整个集群通信的效率。

二、RPC

Dubbo工作原理

  1. 服务启动的时候,provider和consumer根据配置信息,连接到注册中心register,分别向注册中心注册和订阅服务
  2. register根据服务订阅关系,返回provider信息到consumer,同时consumer会把provider信息缓存到本地。如果信息有变更,consumer会收到来自register的推送
  3. consumer生成代理对象,同时根据负载均衡策略,选择一台provider,同时定时向monitor记录接口的调用次数和时间信息
  4. 拿到代理对象之后,consumer通过代理对象发起接口调用
  5. provider收到请求后对数据进行反序列化,然后通过代理调用具体的接口实现

Dubbo负载均衡策略

  1. 加权随机:假设我们有一组服务器 servers = [A, B, C],他们对应的权重为 weights = [5, 3, 2],权重总和为10。现在把这些权重值平铺在一维坐标值上,[0, 5) 区间属于服务器 A,[5, 8) 区间属于服务器 B,[8, 10) 区间属于服务器 C。接下来通过随机数生成器生成一个范围在 [0, 10) 之间的随机数,然后计算这个随机数会落到哪个区间上就可以了。
  2. 最小活跃数:每个服务提供者对应一个活跃数 active,初始情况下,所有服务提供者活跃数均为0。每收到一个请求,活跃数加1,完成请求后则将活跃数减1。在服务运行一段时间后,性能好的服务提供者处理请求的速度更快,因此活跃数下降的也越快,此时这样的服务提供者能够优先获取到新的服务请求。
  3. 一致性hash:通过hash算法,把provider的invoke和随机节点生成hash,并将这个 hash 投射到 [0, 2^32 - 1] 的圆环上,查询的时候根据key进行md5加密然后进行hash,得到第一个节点的值大于等于当前hash的invoker。

4、加权轮询:比如服务器 A、B、C 权重比为 5:2:1,那么在8次请求中,服务器 A 将收到其中的5次请求,服务器 B 会收到其中的2次请求,服务器 C 则收到其中的1次请求。

集群容错

  1. Failover Cluster失败自动切换:dubbo的默认容错方案,当调用失败时自动切换到其他可用的节点,具体的重试次数和间隔时间可用通过引用服务的时候配置,默认重试次数为1也就是只调用一次。
  2. Failback Cluster快速失败:在调用失败,记录日志和调用信息,然后返回空结果给consumer,并且通过定时任务每隔5秒对失败的调用进行重试
  3. Failfast Cluster 失败自动恢复:只会调用一次,失败后立刻抛出异常
  4. Failsafe Cluster 失败安全:调用出现异常,记录日志不抛出,返回空结果
  5. Forking Cluster并行调用多个服务提供者:通过线程池创建多个线程,并发调用多个provider,结果保存到阻塞队列,只要有一个provider成功返回了结果,就会立刻返回结果
  6. Broadcast Cluster广播模式:逐个调用每个provider,如果其中一台报错,在循环调用结束后,抛出异常。

三、消息队列

削峰填谷、解耦。依赖消息队列,同步转异步的方式,可以降低微服务之间的耦合。

对于一些不需要同步执行的接口,可以通过引入消息队列的方式异步执行以提高接口响应时间。在交易完成之后需要扣库存,然后可能需要给会员发放积分,本质上,发积分的动作应该属于履约服务,对实时性的要求也不高,我们只要保证最终一致性也就是能履约成功就行了。对于这种同类性质的请求就可以走MQ异步,也就提高了系统抗压能力了。

消息可靠性

消息丢失可能发生在生产者发送消息、MQ本身丢失消息、消费者丢失消息3个方面。

生产者丢失消息

生产者丢失消息的可能点在于程序发送失败抛异常了没有重试处理,或者发送的过程成功但是过程中网络闪断MQ没收到,消息就丢失了。

由于同步发送的一般不会出现这样使用方式,所以我们就不考虑同步发送的问题,我们基于异步发送的场景来说。

异步发送分为两个方式:异步有回调和异步无回调,无回调的方式,生产者发送完后不管结果可能就会造成消息丢失,而通过异步发送+回调通知+本地消息表的形式我们就可以做出一个解决方案。以下单的场景举例。

  1. 下单后先保存本地数据和MQ消息表,这时候消息的状态是发送中,如果本地事务失败,那么下单失败,事务回滚。
  2. 下单成功,直接返回客户端成功,异步发送MQ消息
  3. MQ回调通知消息发送结果,对应更新数据库MQ发送状态
  4. JOB轮询超过一定时间(时间根据业务配置)还未发送成功的消息去重试
  5. 在监控平台配置或者JOB程序处理超过一定次数一直发送不成功的消息,告警,人工介入。

一般而言,对于大部分场景来说异步回调的形式就可以了,只有那种需要完全保证不能丢失消息的场景我们要做一套完整的解决方案。

MQ丢失

如果生产者保证消息发送到MQ,而MQ收到消息后还在内存中,这时候宕机了又没来得及同步给从节点,就有可能导致消息丢失。

比如RocketMQ:

RocketMQ分为同步刷盘和异步刷盘两种方式,默认的是异步刷盘,就有可能导致消息还未刷到硬盘上就丢失了,可以通过设置为同步刷盘的方式来保证消息可靠性,这样即使MQ挂了,恢复的时候也可以从磁盘中去恢复消息,但是性能会有所下降

比如Kafka也可以通过配置做到:

acks=all 只有参与复制的所有节点全部收到消息,才返回生产者成功。这样的话除非所有的节点都挂了,消息才会丢失。
replication.factor=N,设置大于1的数,这会要求每个partion至少有2个副本
min.insync.replicas=N,设置大于1的数,这会要求leader至少感知到一个follower还保持着连接
retries=N,设置一个非常大的值,让生产者发送失败一直重试

虽然我们可以通过配置的方式来达到MQ本身高可用的目的,但是都对性能有损耗,怎样配置需要根据业务做出权衡。

消费者丢失

消费者丢失消息的场景:消费者刚收到消息,此时服务器宕机,MQ认为消费者已经消费,不会重复发送消息,消息丢失。

RocketMQ默认是需要消费者回复ack确认,而kafka需要手动开启配置关闭自动offset。

消费方不返回ack确认,重发的机制根据MQ类型的不同发送时间间隔、次数都不尽相同,如果重试超过次数之后会进入死信队列,需要手工来处理了。(Kafka没有这些)

消息最终一致性

事务消息可以达到分布式事务的最终一致性,事务消息就是MQ提供的类似XA的分布式事务能力。

半事务消息就是MQ收到了生产者的消息,但是没有收到二次确认,不能投递的消息。

实现原理如下:

  1. 生产者先发送一条半事务消息到MQ
  2. MQ收到消息后返回ack确认
  3. 生产者开始执行本地事务
  4. 如果事务执行成功发送commit到MQ,失败发送rollback
  5. 如果MQ长时间未收到生产者的二次确认commit或者rollback,MQ对生产者发起消息回查
  6. 生产者查询事务执行最终状态
  7. 根据查询事务状态再次提交二次确认

最终,如果MQ收到二次确认commit,就可以把消息投递给消费者,反之如果是rollback,消息会保存下来并且在3天后被删除。

数据库

对于整个系统而言,最终所有的流量的查询和写入都落在数据库上,数据库是支撑系统高并发能力的核心。怎么降低数据库的压力,提升数据库的性能是支撑高并发的基石。主要的方式就是通过读写分离和分库分表来解决这个问题。

对于整个系统而言,流量应该是一个漏斗的形式。比如我们的日活用户DAU有20万,实际可能每天来到提单页的用户只有3万QPS,最终转化到下单支付成功的QPS只有1万。那么对于系统来说读是大于写的,这时候可以通过读写分离的方式来降低数据库的压力。

读写分离也就相当于数据库集群的方式降低了单节点的压力。而面对数据的急剧增长,原来的单库单表的存储方式已经无法支撑整个业务的发展,这时候就需要对数据库进行分库分表了。针对微服务而言垂直的分库本身已经是做过的,剩下大部分都是分表的方案了。

水平分表

首先根据业务场景来决定使用什么字段作为分表字段(sharding_key),比如我们现在日订单1000万,我们大部分的场景来源于C端,我们可以用user_id作为sharding_key,数据查询支持到最近3个月的订单,超过3个月的做归档处理,那么3个月的数据量就是9亿,可以分1024张表,那么每张表的数据大概就在100万左右。

比如用户id为100,那我们都经过hash(100),然后对1024取模,就可以落到对应的表上了。

分表后ID唯一

因为我们主键默认都是自增的,那么分表之后的主键在不同表就肯定会有冲突了。有几个办法考虑:

  1. 设定步长,比如1-1024张表我们分别设定1-1024的基础步长,这样主键落到不同的表就不会冲突了。
  2. 分布式ID,自己实现一套分布式ID生成算法或者使用开源的比如雪花算法这种
  3. 分表后不使用主键作为查询依据,而是每张表单独新增一个字段作为唯一主键使用,比如订单表订单号是唯一的,不管最终落在哪张表都基于订单号作为查询依据,更新也一样。

主从同步原理

  1. master提交完事务后,写入binlog
  2. slave连接到master,获取binlog
  3. master创建dump线程,推送binlog到slave
  4. slave启动一个IO线程读取同步过来的master的binlog,记录到relay log中继日志中
  5. slave再开启一个sql线程读取relay log事件并在slave执行,完成同步
  6. slave记录自己的binglog

由于mysql默认的复制方式是异步的,主库把日志发送给从库后不关心从库是否已经处理,这样会产生一个问题就是假设主库挂了,从库处理失败了,这时候从库升为主库后,日志就丢失了。由此产生两个概念。

全同步复制

主库写入binlog后强制同步日志到从库,所有的从库都执行完成后才返回给客户端,但是很显然这个方式的话性能会受到严重影响。

半同步复制

和全同步不同的是,半同步复制的逻辑是这样,从库写入日志成功后返回ACK确认给主库,主库收到至少一个从库的确认就认为写操作完成。

缓存

缓存作为高性能的代表,在某些特殊业务可能承担90%以上的热点流量。对于一些活动比如秒杀这种并发QPS可能几十万的场景,引入缓存事先预热可以大幅降低对数据库的压力,10万的QPS对于单机的数据库来说可能就挂了,但是对于如redis这样的缓存来说就完全不是问题。

以秒杀系统举例,活动预热商品信息可以提前缓存提供查询服务,活动库存数据可以提前缓存,下单流程可以完全走缓存扣减,秒杀结束后再异步写入数据库,数据库承担的压力就小的太多了。当然,引入缓存之后就还要考虑缓存击穿、雪崩、热点一系列的问题了。

热key问题

所谓热key问题就是,突然有几十万的请求去访问redis上的某个特定key,那么这样会造成流量过于集中,达到物理网卡上限,从而导致这台redis的服务器宕机引发雪崩。

针对热key的解决方案:

  1. 提前把热key打散到不同的服务器,降低压力
  2. 加入二级缓存,提前加载热key数据到内存中,如果redis宕机,走内存查询
缓存击穿

缓存击穿的概念就是单个key并发访问过高,过期时导致所有请求直接打到db上,这个和热key的问题比较类似,只是不同的点在于过期导致请求全部打到DB上而已。

解决方案:

  1. 加锁更新,比如请求查询A,发现缓存中没有,对A这个key加锁,同时去数据库查询数据,写入缓存,再返回给用户,这样后面的请求就可以从缓存中拿到数据了。
  2. 将过期时间组合写在value中,通过异步的方式不断的刷新过期时间,防止此类现象。
缓存穿透

缓存穿透是指查询不存在缓存中的数据,每次请求都会打到DB,就像缓存不存在一样。

针对这个问题,加一层布隆过滤器。布隆过滤器的原理是在你存入数据的时候,会通过散列函数将它映射为一个位数组中的K个点,同时把他们置为1。

这样当用户再次来查询A,而A在布隆过滤器值为0,直接返回,就不会产生击穿请求打到DB了。

显然,使用布隆过滤器之后会有一个问题就是误判,因为它本身是一个数组,可能会有多个值落到同一个位置,那么理论上来说只要我们的数组长度够长,误判的概率就会越低,这种问题就根据实际情况来就好了。

缓存雪崩

当某一时刻发生大规模的缓存失效的情况,比如你的缓存服务宕机了, 或者大量的key过期,会有大量的请求进来直接打到DB上,这样可能导致整个系统的崩溃,称为雪崩。雪崩和击穿、热key的流量过于集中问题不太一样的是,雪崩是指大规模的缓存都过期失效了。

针对雪崩几个解决方案:

  1. 针对不同key设置不同的过期时间,避免同时过期
  2. 限流,如果redis宕机,可以限流,避免同时刻大量请求打崩DB
  3. 二级缓存,同热key的方案。

稳定性

熔断

比如营销服务挂了或者接口大量超时的异常情况,不能影响下单的主链路,涉及到积分的扣减一些操作可以在事后做补救。

限流

对突发如大促秒杀类的高并发,如果一些接口不做限流处理,可能直接就把服务打挂了,针对每个接口的压测性能的评估做出合适的限流尤为重要。

降级

熔断之后实际上可以说就是降级的一种,以熔断的举例来说营销接口熔断之后降级方案就是短时间内不再调用营销的服务,等到营销恢复之后再调用。

预案

一般来说,就算是有统一配置中心,在业务的高峰期也是不允许做出任何的变更的,但是通过配置合理的预案可以在紧急的时候做一些修改。

核对

针对各种分布式系统产生的分布式事务一致性或者受到攻击导致的数据异常,非常需要核对平台来做最后的兜底的数据验证。比如下游支付系统和订单系统的金额做核对是否正确,如果收到中间人攻击落库的数据是否保证正确性。

总结

其实可以看到,怎么设计高并发系统这个问题本身他是不难的,无非是基于你知道的知识点,从物理硬件层面到软件的架构、代码层面的优化,使用什么中间件来不断提高系统的抗压能力。

但是这个问题本身会带来更多的问题,微服务本身的拆分带来了分布式事务的问题,http、RPC框架的使用带来了通信效率、路由、容错的问题,MQ的引入带来了消息丢失、积压、事务消息、顺序消息的问题,缓存的引入又会带来一致性、雪崩、击穿的问题,数据库的读写分离、分库分表又会带来主从同步延迟、分布式ID、事务一致性的问题,而为了解决这些问题我们又要不断的加入各种措施熔断、限流、降级、离线核对、预案处理等等来防止和追溯这些问题。


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM