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

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

  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 分布式锁」让多个实例/操作 串行化起来。避免脏数据覆盖新数据