高并发高可用复杂系统中的缓存架构(二十) 分布式缓存重建并发冲突问题以及 zookeeper 分布式锁解决方案

什么是分布式缓存重建并发冲突问题?

很简单,多个缓存服务实例提供服务,发现缓存失效,那么就会去重建,这个时候回出现以下几种情况:

  1. 多个缓存实例都去数据库获取一份数据,然后放入缓存中

  2. 新数据被旧数据覆盖

    缓存 a 和 b 都拿了一份数据,a 拿到 12:00:01 的数据,b 拿到 12:00:05 的数据

    缓存 b 先写入 redis,缓存 a 后写入。

以上问题有多重解决方案,如:

  1. 利用 hash 分发

    相同商品分发到同一个服务中,服务中再用队列去重建

    但是这就变成了有状态的缓存服务,压力全部集中到同一个服务上

  2. 利用 kafka 队列

    源头信息服务,在发送消息到 kafka topic 的时候,都需要按照 product id 去分区

    和上面 hash 方案类似

  3. 基于 zookeeper 分布式锁的解决方案

分布式锁:多个机器在访问同一个共享资源,需要给这个资源加一把锁,让多个机器串行访问

对于分布式锁,有很多种实现方式,比如 redis 也可以实现。

这里讲解 zk 分布式锁,zk 做分布式协调比较流程,大数据应用里面 hadoop、storm 都是基于 zk 去做分布式协调

zk 分布式锁的解决并发冲突的方案

  1. 变更缓存重建以及空缓存请求重建,更新 redis 之前,都需要先获取对应商品 id 的分布式锁

  2. 拿到分布式锁之后,需要根据时间版本去比较一下,如果自己的版本新于 redis 中的版本,那么就更新,否则就不更新

  3. 如果拿不到分布式锁,那么就等待,不断轮询等待,直到自己获取到分布式的锁

基于 zk 进行分布式锁的代码封装;

zk 分布式锁原理简单介绍

  1. 创建一个 zk 临时 node,来模拟一个商品 id 加锁

  2. zk 会保证一个 node path 只会被创建一次,如果已经被创建,则抛出 NodeExistsException

  3. 这个时候可以去做业务操作

  4. 释放锁,则是删除这个临时 node。

当一个多个缓存服务去对同一个商品 id 加锁时,只有一个成功, 其他的则轮循等待锁被释放,获取到锁之后,对比一下商品的时间版本,较新则重建缓存,否则放弃重建

基于 zkClient 封装分布式锁工具

zk 分布式锁有很多种实现方式,这里演示一种最简单的,但是比较实用的分布式锁

添加依赖: compile 'org.apache.zookeeper:zookeeper:3.4.5'

zk client 初始化代码

/**
 * ${todo}

 */
public class ZooKeeperSession {
    private ZooKeeper zookeeper;
    private CountDownLatch connectedSemaphore = new CountDownLatch(1);
​
    private ZooKeeperSession() {
        String connectString = "192.168.99.170:2181,192.168.99.171:2181,192.168.99.172:2181";
        int sessionTimeout = 5000;
        try {
            // 异步连接,所以需要一个  org.apache.zookeeper.Watcher 来通知
            // 由于是异步,利用 CountDownLatch 来让构造函数等待
            zookeeper = new ZooKeeper(connectString, sessionTimeout, event -> {
                Watcher.Event.KeeperState state = event.getState();
                System.out.println("watch event:" + state);
                if (state == Watcher.Event.KeeperState.SyncConnected) {
                    System.out.println("zookeeper 已连接");
                    connectedSemaphore.countDown();
                }
            });
        } catch (IOException e) {
            e.printStackTrace();
        }
        try {
            connectedSemaphore.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("zookeeper 初始化成功");
    }
​
    private static ZooKeeperSession instance = new ZooKeeperSession();
​
    public static ZooKeeperSession getInstance() {
        return instance;
    }
​
    public static void main(String[] args) {
        ZooKeeperSession instance = ZooKeeperSession.getInstance();
    }
}

运行测试之后输出信息

watch event:SyncConnected
zookeeper 已连接
zookeeper 初始化成功

接下来编写加锁与释放锁的逻辑

public class ZooKeeperSession {
    private ZooKeeper zookeeper;
    private CountDownLatch connectedSemaphore = new CountDownLatch(1);
​
    private ZooKeeperSession() {
        String connectString = "192.168.99.170:2181,192.168.99.171:2181,192.168.99.172:2181";
        int sessionTimeout = 5000;
        try {
            // 异步连接,所以需要一个  org.apache.zookeeper.Watcher 来通知
            // 由于是异步,利用 CountDownLatch 来让构造函数等待
            zookeeper = new ZooKeeper(connectString, sessionTimeout, event -> {
                Watcher.Event.KeeperState state = event.getState();
                System.out.println("watch event:" + state);
                if (state == Watcher.Event.KeeperState.SyncConnected) {
                    System.out.println("zookeeper 已连接");
                    connectedSemaphore.countDown();
                }
            });
        } catch (IOException e) {
            e.printStackTrace();
        }
        try {
            connectedSemaphore.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("zookeeper 初始化成功");
    }
​
    /**
     * 获取分布式锁
     */
    public void acquireDistributedLock(Long productId) {
        String path = "/product-lock-" + productId;
        byte[] data = "".getBytes();
        try {
            // 创建一个临时节点,后面两个参数一个安全策略,一个临时节点类型
            // EPHEMERAL:客户端被断开时,该节点自动被删除
            zookeeper.create(path, data, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
            System.out.println("获取锁成功 product[id=" + productId + "]");
        } catch (Exception e) {
            e.printStackTrace();
            // 如果锁已经被创建,那么将异常
            // 循环等待锁的释放
            int count = 0;
            while (true) {
                try {
                    TimeUnit.MILLISECONDS.sleep(20);
                    // 休眠 20 毫秒后再次尝试创建
                    zookeeper.create(path, data, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
                } catch (Exception e1) {
                    e1.printStackTrace();
                    count++;
                    continue;
                }
                System.out.println("获取锁成功 product[id=" + productId + "] 尝试了 " + count + " 次.");
                break;
            }
        }
    }
​
    /**
     * 释放分布式锁
     */
    public void releaseDistributedLock(Long productId) {
        String path = "/product-lock-" + productId;
        try {
            zookeeper.delete(path, -1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        }
    }
​
    private static ZooKeeperSession instance = new ZooKeeperSession();
​
    public static ZooKeeperSession getInstance() {
        return instance;
    }
​
    public static void main(String[] args) throws InterruptedException {
        ZooKeeperSession instance = ZooKeeperSession.getInstance();
        CountDownLatch downLatch = new CountDownLatch(2);
        IntStream.of(1, 2).forEach(i -> new Thread(() -> {
            instance.acquireDistributedLock(1L);
            System.out.println(Thread.currentThread().getName() + " 得到锁并休眠 10 秒");
            try {
                TimeUnit.SECONDS.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            instance.releaseDistributedLock(1L);
            System.out.println(Thread.currentThread().getName() + " 释放锁");
            downLatch.countDown();
        }).start());
        downLatch.await();
    }
}

运行 main 测试两个线程获取锁的等待过程如下

watch event:SyncConnected
zookeeper 已连接
zookeeper 初始化成功
获取锁成功 product[id=1]
Thread-1 得到锁并休眠 10 秒
​
循环异常中...
org.apache.zookeeper.KeeperException$NodeExistsException: KeeperErrorCode = NodeExists for /product-lock-1
    at org.apache.zookeeper.KeeperException.create(KeeperException.java:119)
​
Thread-1 释放锁
获取锁成功 product[id=1] 尝试了 285 次.
Thread-0 得到锁并休眠 10 秒
Thread-0 释放锁

可以看到,日志输出,证明分布式锁已经编写成功

 

 

 

 主动更新

缓存生产服务接收基础信息更改事件的时候,有一个操作是更新本地缓存和 redis 中的缓存,
这个场景下也存可能存在并发冲突情况。所以这里也可以使用分布式锁来保证数据错乱问题

KafkaMessageProcessor#processProductInfoChangeMessage

回顾下现在的实现代码。以商品为例,来展示怎么使用分布式锁

```java
/**
 * 处理商品信息变更的消息
 */
private void processProductInfoChangeMessage(JSONObject messageJSONObject) {
    // 提取出商品id
    Long productId = messageJSONObject.getLong("productId");

    // 调用商品信息服务的接口
    // 直接用注释模拟:getProductInfo?productId=1,传递过去
    // 商品信息服务,一般来说就会去查询数据库,去获取productId=1的商品信息,然后返回回来

    String productInfoJSON = "{\"id\": 1, \"name\": \"iphone7手机\", \"price\": 5599, \"pictureList\":\"a.jpg,b.jpg\", \"specification\": \"iphone7的规格\", \"service\": \"iphone7的售后服务\", \"color\": \"红色,白色,黑色\", \"size\": \"5.5\", \"shopId\": 1}";
    ProductInfo productInfo = JSONObject.parseObject(productInfoJSON, ProductInfo.class);
    cacheService.saveProductInfo2LocalCache(productInfo);
    log.info("获取刚保存到本地缓存的商品信息:" + cacheService.getProductInfoFromLocalCache(productId));
    cacheService.saveProductInfo2ReidsCache(productInfo);
}
```

使用分布式锁之后

```java
private void processProductInfoChangeMessage(JSONObject messageJSONObject) {
    // 提取出商品id
    Long productId = messageJSONObject.getLong("productId");
    // 增加了一个 modifyTime 字段,来比较数据修改先后顺序
    String productInfoJSON = "{\"id\": 1, \"name\": \"iphone7手机\", \"price\": 5599, \"pictureList\":\"a.jpg,b.jpg\", \"specification\": \"iphone7的规格\", \"service\": \"iphone7的售后服务\", \"color\": \"红色,白色,黑色\", \"size\": \"5.5\", \"shopId\": 1," +
            "\"modifyTime\":\"2019-05-13 22:00:00\"}";
    ProductInfo productInfo = JSONObject.parseObject(productInfoJSON, ProductInfo.class);

    // 加锁
    ZooKeeperSession zks = ZooKeeperSession.getInstance();
    zks.acquireDistributedLock(productId);
    try {
        // 先获取一次 redis ,防止其他实例已经放入数据了
        ProductInfo existedProduct = cacheService.getProductInfoOfReidsCache(productId);
        if (existedProduct != null) {
            // 判定通过消息获取到的数据版本和 redis 中的谁最新
            Date existedModifyTime = existedProduct.getModifyTime();
            Date modifyTime = productInfo.getModifyTime();
            // 如果本次获取到的修改时间大于 redis 中的,那么说明此数据是最新的,可以放入 redis 中
            if (modifyTime.after(existedModifyTime)) {
                cacheService.saveProductInfo2LocalCache(productInfo);
                log.info("最新数据覆盖 redis 中的数据:" + cacheService.getProductInfoFromLocalCache(productId));
                cacheService.saveProductInfo2ReidsCache(productInfo);
            }
        } else {
            // redis 中没有数据,直接放入
            cacheService.saveProductInfo2LocalCache(productInfo);
            log.info("获取刚保存到本地缓存的商品信息:" + cacheService.getProductInfoFromLocalCache(productId));
            cacheService.saveProductInfo2ReidsCache(productInfo);
        }
    } finally {
        // 最后释放锁
        zks.releaseDistributedLock(productId);
    }
}
```

## 缓存重建

回顾下重建的地方

```java
/**
 * 这里的代码别看着奇怪,简单回顾下之前的流程: 1. nginx 获取 redis 缓存 2. 获取不到再获取服务的堆缓存(也就是这里的 ecache) 3.
 * 还获取不到就需要去数据库获取并重建缓存
 */
@RequestMapping("/getProductInfo")
@ResponseBody
public ProductInfo getProductInfo(Long productId) {
    ProductInfo productInfo = cacheService.getProductInfoOfReidsCache(productId);
    log.info("从 redis 中获取商品信息");
    if (productInfo == null) {
        productInfo = cacheService.getProductInfoFromLocalCache(productId);
        log.info("从 ehcache 中获取商品信息");
    }
    if (productInfo == null) {
        // 两级缓存中都获取不到数据,那么就需要从数据源重新拉取数据,重建缓存
        // 但是这里暂时不讲
        log.info("缓存重建 商品信息");
    }
    return productInfo;
}
```

如上代码,都获取不到数据的时候,就需要从数据库读取数据进行重建。

第一版思路:

1. 从数据库读取数据
2. 队列异步重建
3. 返回第一步的数据

下面来实现下这个代码(先不考虑该思路是否有问题)

RebuildCache

```java
/**
 * 缓存重建;一个队列对应一个消费线程
 *

 */
@Component
public class RebuildCache {
    private Logger log = LoggerFactory.getLogger(getClass());
    private ArrayBlockingQueue<ProductInfo> queue = new ArrayBlockingQueue<>(100);
    private CacheService cacheService;

    public RebuildCache(CacheService cacheService) {
        this.cacheService = cacheService;
        start();
    }

    public void put(ProductInfo productInfo) {
        try {
            queue.put(productInfo);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public ProductInfo take() {
        try {
            return queue.take();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return null;
    }

    // 启动一个线程来消费

    private void start() {
        new Thread(() -> {
            while (true) {
                try {
                    ProductInfo productInfo = queue.take();
                    Long productId = productInfo.getId();
                    ZooKeeperSession zks = ZooKeeperSession.getInstance();
                    zks.acquireDistributedLock(productId);
                    try {
                        // 先获取一次 redis ,防止其他实例已经放入数据了
                        ProductInfo existedProduct = cacheService.getProductInfoOfReidsCache(productId);
                        if (existedProduct != null) {
                            // 判定通过消息获取到的数据版本和 redis 中的谁最新
                            Date existedModifyTime = existedProduct.getModifyTime();
                            Date modifyTime = productInfo.getModifyTime();
                            // 如果本次获取到的修改时间大于 redis 中的,那么说明此数据是最新的,可以放入 redis 中
                            if (modifyTime.after(existedModifyTime)) {
                                cacheService.saveProductInfo2LocalCache(productInfo);
                                log.info("最新数据覆盖 redis 中的数据:" + cacheService.getProductInfoFromLocalCache(productId));
                                cacheService.saveProductInfo2ReidsCache(productInfo);
                            } else {
                                log.info("此次数据版本落后,放弃重建");
                            }
                        } else {
                            // redis 中没有数据,直接放入
                            cacheService.saveProductInfo2LocalCache(productInfo);
                            log.info("缓存重建成功" + cacheService.getProductInfoFromLocalCache(productId));
                            cacheService.saveProductInfo2ReidsCache(productInfo);
                        }
                    } finally {
                        // 最后释放锁
                        zks.releaseDistributedLock(productId);
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

```

controller 中使用该队列

```java
/**
 * 这里的代码别看着奇怪,简单回顾下之前的流程: 1. nginx 获取 redis 缓存 2. 获取不到再获取服务的堆缓存(也就是这里的 ecache) 3.
 * 还获取不到就需要去数据库获取并重建缓存
 */
@RequestMapping("/getProductInfo")
@ResponseBody
public ProductInfo getProductInfo(Long productId) {
    ProductInfo productInfo = cacheService.getProductInfoOfReidsCache(productId);
    log.info("从 redis 中获取商品信息");
    if (productInfo == null) {
        productInfo = cacheService.getProductInfoFromLocalCache(productId);
        log.info("从 ehcache 中获取商品信息");
    }
    if (productInfo == null) {
        // 两级缓存中都获取不到数据,那么就需要从数据源重新拉取数据,重建缓存
        // 假设这里从数据库中获取的数据
        String productInfoJSON = "{\"id\": 1, \"name\": \"iphone7手机\", \"price\": 5599, \"pictureList\":\"a.jpg,b.jpg\", \"specification\": \"iphone7的规格\", \"service\": \"iphone7的售后服务\", \"color\": \"红色,白色,黑色\", \"size\": \"5.5\", \"shopId\": 1," +
                "\"modifyTime\":\"2019-05-13 22:00:00\"}";
        productInfo = JSONObject.parseObject(productInfoJSON, ProductInfo.class);
        rebuildCache.put(productInfo);
    }
    return productInfo;
}
```

缓存重建出现在两个地方:

  1. 当基础服务信息变更之后(被动)

  2. 当所有缓存失效之后(主动)

一个主动一个被动,他们的执行逻辑都相同,其实可以使用一个队列逻辑来处理缓存重建

缓存重建重要依赖「zk 分布式锁」让多个实例/操作 串行化起来。避免脏数据覆盖新数据

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值