什么是分布式缓存重建并发冲突问题?
很简单,多个缓存服务实例提供服务,发现缓存失效,那么就会去重建,这个时候回出现以下几种情况:
- 多个缓存实例都去数据库获取一份数据,然后放入缓存中
- 新数据被旧数据覆盖
缓存 a 和 b 都拿了一份数据,a 拿到 12:00:01 的数据,b 拿到 12:00:05 的数据
缓存 b 先写入 redis,缓存 a 后写入。
以上问题有多重解决方案,如:
- 利用 hash 分发
相同商品分发到同一个服务中,服务中再用队列去重建
但是这就变成了有状态的缓存服务,压力全部集中到同一个服务上 - 利用 kafka 队列
源头信息服务,在发送消息到 kafka topic 的时候,都需要按照 product id 去分区
和上面 hash 方案类似 - 基于 zookeeper 分布式锁的解决方案
分布式锁:多个机器在访问同一个共享资源,需要给这个资源加一把锁,让多个机器串行访问
对于分布式锁,有很多种实现方式,比如 redis 也可以实现。
这里讲解 zk 分布式锁,zk 做分布式协调比较流程,大数据应用里面 hadoop、storm 都是基于 zk 去做分布式协调
zk 分布式锁的解决并发冲突的方案
- 变更缓存重建以及空缓存请求重建,更新 redis 之前,都需要先获取对应商品 id 的分布式锁
- 拿到分布式锁之后,需要根据时间版本去比较一下,如果自己的版本新于 redis 中的版本,那么就更新,否则就不更新
- 如果拿不到分布式锁,那么就等待,不断轮询等待,直到自己获取到分布式的锁
基于 zk 进行分布式锁的代码封装;
zk 分布式锁原理简单介绍
- 创建一个 zk 临时 node,来模拟一个商品 id 加锁
- zk 会保证一个 node path 只会被创建一次,如果已经被创建,则抛出 NodeExistsException
- 这个时候可以去做业务操作
- 释放锁,则是删除这个临时 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;
}
```
缓存重建出现在两个地方:
- 当基础服务信息变更之后(被动)
- 当所有缓存失效之后(主动)
一个主动一个被动,他们的执行逻辑都相同,其实可以使用一个队列逻辑来处理缓存重建
缓存重建重要依赖「zk 分布式锁」让多个实例/操作 串行化起来。避免脏数据覆盖新数据