文章目录
一、缓存方案
后台问题描述:访问数据库查询商品信息,读多写少。
/**
* 获取商品详情信息
*
* @param id 产品ID
*/
public PmsProductParam getProductInfo(Long id) {
PmsProductParam productInfo = null;
productInfo = portalProductDao.getProductInfo(id);
if (null == productInfo) {
return productInfo;
}
FlashPromotionParam promotion = flashPromotionProductDao.getFlashPromotion(id);
if (!ObjectUtils.isEmpty(promotion)) {
productInfo.setFlashPromotionCount(promotion.getRelation().get(0).getFlashPromotionCount());
productInfo.setFlashPromotionLimit(promotion.getRelation().get(0).getFlashPromotionLimit());
productInfo.setFlashPromotionPrice(promotion.getRelation().get(0).getFlashPromotionPrice());
productInfo.setFlashPromotionRelationId(promotion.getRelation().get(0).getId());
productInfo.setFlashPromotionEndDate(promotion.getEndDate());
productInfo.setFlashPromotionStartDate(promotion.getStartDate());
productInfo.setFlashPromotionStatus(promotion.getStatus());
}
return productInfo;
}
前端静态化页面:
我们发现上节课通过我们对数据进行静态化,也是有很多问题的,比如我们商品如果过多,freemark模板一定修改之后,我们所有的商品都需要重新再次生产静态化,这个工作量实在是太大了。
1.1 引入缓存
传统的直接查询mysql要进行网络IO和磁盘IO。而我们查询缓存的话,数据是在内存中的,只有网络IO,而且也可以大大减少数据库的压力。因为缓存的抗并发能力是要大大优于数据库的。
二、实战-获取商品详情信息(zk分布式锁+本地缓存+Redis)
1、错误的代码
/**
* 获取商品详情信息
*
* @param id 产品ID
*/
public PmsProductParam getProductInfo(Long id) {
PmsProductParam productInfo = null;
productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class);
if (productInfo != null) {
return productInfo;
}
productInfo = portalProductDao.getProductInfo(id);
if (null == productInfo) {
return null;
}
FlashPromotionParam promotion = flashPromotionProductDao.getFlashPromotion(id);
if (!ObjectUtils.isEmpty(promotion)) {
productInfo.setFlashPromotionCount(promotion.getRelation().get(0).getFlashPromotionCount());
productInfo.setFlashPromotionLimit(promotion.getRelation().get(0).getFlashPromotionLimit());
productInfo.setFlashPromotionPrice(promotion.getRelation().get(0).getFlashPromotionPrice());
productInfo.setFlashPromotionRelationId(promotion.getRelation().get(0).getId());
productInfo.setFlashPromotionEndDate(promotion.getEndDate());
productInfo.setFlashPromotionStartDate(promotion.getStartDate());
productInfo.setFlashPromotionStatus(promotion.getStatus());
}
redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 3600, TimeUnit.SECONDS);
return productInfo;
}
2.1 现在有什么问题了?
问题一:数据压缩
我们知道Redis虽然可以解决磁盘IO问题,但是网络IO问题依然是存在的。而影响网络IO的两个因素是网络带宽和传输文件大小。我们这里还需要对文件大小,也就是缓存数据的大小进行优化:压缩文件!!!
问题二:并发带来的多次访问数据库问题
跟我们预期只set一次redis 是有出入,为何会这样子了?并发问题导致set了多次redis缓存。
这里要使用分布式锁来解决:redis(redisson)或者zookeeper
2.2 加入分布式锁:
2.2.1 Redis实现分布式锁
<!--加入redisson-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
或者使用setnx。
单机加锁:
分布式加锁:
同一时间同一个数据请求过来,比如set 100 value:(1 2 3)
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】:https://blog.csdn.net/qq_43631716/article/details/118510581
优化后代码
1、直接加锁版本
@Autowired
RedissonClient redission;
/**
* 获取商品详情信息 加入redis 加入锁
*
* @param id 产品ID
*/
public PmsProductParam getProductInfo3(Long id) {
PmsProductParam productInfo = null;
//从缓存Redis里找
productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class);
if (null != productInfo) {
return productInfo;
}
RLock lock = redission.getLock(lockPath + id);
try {
// 加锁
if (lock.Lock()) {
// DCL
productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class);
if (null == productInfo) {
productInfo = portalProductDao.getProductInfo(id);
if (null == productInfo) {
log.warn("没有查询到商品信息,id:" + id);
return null;
}
checkFlash(id, productInfo);
redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 3600, TimeUnit.SECONDS);
}
}
} finally {
lock.unlock();
}
return productInfo;
}
这样直接加锁会导致所有请求都要穿行执行,并发能力会大大下降。可以考虑使用其他方式进行优化,比如使用tryLock,尽量减少请求进入锁的逻辑。
2、tryLock尝试加锁版本
@Autowired
RedissonClient redission;
/**
* 获取商品详情信息 加入redis 加入锁
*
* @param id 产品ID
*/
public PmsProductParam getProductInfo3(Long id) {
PmsProductParam productInfo = null;
//从缓存Redis里找
productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class);
if (null != productInfo) {
return productInfo;
}
RLock lock = redission.getLock(lockPath + id);
try {
// 尝试获取锁
if (lock.tryLock()) {
productInfo = portalProductDao.getProductInfo(id);
if (null == productInfo) {
log.warn("没有查询到商品信息,id:" + id);
return null;
}
checkFlash(id, productInfo);
redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 3600, TimeUnit.SECONDS);
} else {
// 尝试获取锁失败后,再尝试查询一次redis,但是不一定能查询到数据,可能返回null,
// 这里优化空间就是sleep一会后重新执行该方法进行查询
productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class);
if (null == productInfo) {
Thread.sleep(500);
getProductInfo3(id); // 重试该方法
}
}
} finally {
// 判断锁是否依然存在,因为尝试获取锁失败的线程会先执行到这里,所以一定要让真正持有锁的线程去释放锁
if (lock.isLocked()) {
// 判断锁是否是被当前线程持有,这样才能释放锁。其他线程是无法释放锁的,会报错
if (lock.isHeldByCurrentThread())
lock.unlock();
}
}
return productInfo;
}
2.2.2 ZK分布式锁实现
我们知道Redis是属于AP架构的,如果我们使用分布式锁的时候刚好将值写在master节点上的时候,突然挂掉了,此时从节点还没来得及同步锁数据,这样很可能会造成锁丢失或者其他一系列的问题。
而ZK是基于CP架构的,性能虽然弱与Redis,但是它写入数据后,必须有大于半数的节点返回ack才表示一个节点写入成功,这样就能保证当leader节点挂掉之后,那个被选举为新leader的从节点上是存在最新的数据的!
1、实现类
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.cache.PathChildrenCache;
import org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.ZooDefs;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;
/**
* zk分布式锁
**/
@Slf4j
public class ZKLockImpl implements ZKLock, InitializingBean {
private final static String LOCK_ROOT_PATH = "/ZkLock";
private Map<String, CountDownLatch> concurrentMap = new ConcurrentHashMap<>();
private ReentrantLock lock = new ReentrantLock();
@Autowired
private CuratorFramework curatorFramework;
@Override
public boolean lock(String lockpath) {
boolean result = false;
String keyPath = LOCK_ROOT_PATH + lockpath;
try {
curatorFramework
.create()
.creatingParentsIfNeeded()
.withMode(CreateMode.EPHEMERAL)
.withACL(ZooDefs.Ids.OPEN_ACL_UNSAFE)
.forPath(keyPath); // 创建失败会抛出异常Exception
result = true;
log.info("success to acquire mutex lock for path:{}", keyPath);
} catch (Exception e) { // 获取锁失败的线程需要使用CountDownLatch进行阻塞等待,知道其他线程释放锁
log.info("Thread:{};failed to acquire mutex lock for path:{}", Thread.currentThread().getName(), keyPath);
if (!concurrentMap.containsKey(lockpath)) {
try {
/*
* 这里考虑到高并发场景,必须保证对同一个节点加锁的线程失败后是落在同一个countdown对象上
* ,否则有的线程永远没有办法唤醒了
*/
lock.lock();
//双重校验,考虑高并发问题
if (!concurrentMap.containsKey(lockpath)) {
concurrentMap.put(lockpath, new CountDownLatch(1));
}
} finally {
lock.unlock();
}
}
try {
CountDownLatch countDownLatch = concurrentMap.get(lockpath);
//这里为什么要判断呢?大家可以思考一下,高并发场景
if (countDownLatch != null) {
countDownLatch.await();
}
} catch (InterruptedException e1) {
log.info("InterruptedException message:{}", e1.getMessage());
}
}
return result;
}
@Override
public boolean unlock(String lockpath) {
String keyPath = LOCK_ROOT_PATH + lockpath;
try {
if (curatorFramework.checkExists().forPath(keyPath) != null) {
curatorFramework.delete().forPath(keyPath);
}
} catch (Exception e) {
log.error("failed to release mutex lock");
return false;
}
return true;
}
/**
* 监听节点事件
*
* @param lockPath 加锁的路径
*/
private void addWatcher(String lockPath) throws Exception {
String keyPath;
if (LOCK_ROOT_PATH.equals(lockPath)) {
keyPath = lockPath;
} else {
keyPath = LOCK_ROOT_PATH + lockPath;
}
final PathChildrenCache cache = new PathChildrenCache(curatorFramework, keyPath, false);
cache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT);
/*
* 添加监听器
*/
cache.getListenable().addListener((client, event) -> {
if (event.getType().equals(PathChildrenCacheEvent.Type.CHILD_REMOVED)) {
String oldPath = event.getData().getPath();
log.info("oldPath delete:{},redis缓存已经更新!", oldPath);
if (oldPath.contains(lockPath)) {
//TODO 释放计数器,释放锁
CountDownLatch countDownLatch = concurrentMap.remove(oldPath);
if (countDownLatch != null) {//有可能没有竞争,countdown不存在
countDownLatch.countDown();
}
}
}
});
}
@Override
public void afterPropertiesSet() {
curatorFramework = curatorFramework.usingNamespace("zklock-namespace");
//zk锁的根路径 不存在则创建
try {
if (curatorFramework.checkExists().forPath(LOCK_ROOT_PATH) == null) {
curatorFramework
.create()
.creatingParentsIfNeeded()
.withMode(CreateMode.PERSISTENT)
.withACL(ZooDefs.Ids.OPEN_ACL_UNSAFE)
.forPath(LOCK_ROOT_PATH);
}
//启动监听器
addWatcher(LOCK_ROOT_PATH);
} catch (Exception e) {
log.error("connect zookeeper failed:{}", e.getMessage(), e);
}
}
}
2、寄啊锁逻辑
引入本地缓存LocalCache (解决Redis网络IO问题)
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.tuling.tulingmall.domain.PmsProductParam;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;
/**
* @description: 缓存管理工具,采用LRU淘汰策略
**/
@Slf4j
@Component
public class LocalCache {
private Cache<String,PmsProductParam> localCache = null;
@PostConstruct
private void init(){
localCache = CacheBuilder.newBuilder()
//设置本地缓存容器的初始容量
.initialCapacity(10)
//设置本地缓存的最大容量
.maximumSize(500)
//设置写缓存后多少秒过期
.expireAfterWrite(60, TimeUnit.SECONDS).build();
}
public void setLocalCache(String key,PmsProductParam object){
localCache.put(key,object);
}
public PmsProductParam get(String key){
return localCache.getIfPresent(key);
}
}
考虑使用concurrentHashMap,什么时候清楚数据,如何清除都比较麻烦,所以考虑使用guava的localCache。
优化后的代码
@Autowired
private LocalCache cache;
/*
* zk分布式锁
*/
@Autowired
private ZKLock zkLock;
private String lockPath = "/load_db";
/**
* 获取商品详情信息 分布式锁、 本地缓存、redis缓存
*
* @param id 产品ID
*/
public PmsProductParam getProductInfo3(Long id) {
PmsProductParam productInfo = null;
// 1、从本地缓存中查询商品信息
productInfo = cache.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id);
if (null != productInfo) {
return productInfo;
}
// 2、从Redis中查询商品信息
productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class);
if (productInfo != null) {
log.info("get redis productId:" + productInfo);
// 设置到本地缓存中
cache.setLocalCache(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo);
return productInfo;
}
// 3、本地缓存和Redis中都不存在商品信息,加锁查询数据库
try {
// 获取锁成功的话
if (zkLock.lock(lockPath + "_" + id)) {
productInfo = portalProductDao.getProductInfo(id);
if (null == productInfo) {
return null;
}
// 数据库中获取数据
checkFlash(id, productInfo);
log.info("set db productId:" + productInfo);
// 数据缓存到redis
redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 3600, TimeUnit.SECONDS);
// 数据缓存到local cache
cache.setLocalCache(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo);
} else { // 获取锁失败的话
log.info("get redis2 productId:" + productInfo);
// 尝试从Redis获取
productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class);
if (productInfo != null) {
// 添加到local cache
cache.setLocalCache(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo);
}
}
} finally {
log.info("unlock :" + productInfo);
zkLock.unlock(lockPath + "_" + id);
}
return productInfo;
}
private void checkFlash(Long id, PmsProductParam productInfo) {
FlashPromotionParam promotion = flashPromotionProductDao.getFlashPromotion(id);
if (!ObjectUtils.isEmpty(promotion)) {
productInfo.setFlashPromotionCount(promotion.getRelation().get(0).getFlashPromotionCount());
productInfo.setFlashPromotionLimit(promotion.getRelation().get(0).getFlashPromotionLimit());
productInfo.setFlashPromotionPrice(promotion.getRelation().get(0).getFlashPromotionPrice());
productInfo.setFlashPromotionRelationId(promotion.getRelation().get(0).getId());
productInfo.setFlashPromotionEndDate(promotion.getEndDate());
productInfo.setFlashPromotionStartDate(promotion.getStartDate());
productInfo.setFlashPromotionStatus(promotion.getStatus());
}
}
我们发现在高并发下增加了分布式锁可以解决刚才那个问题,但是也降低了qps,所以这儿还是要根据需求而定。
2.3 ZK分布式锁原理
zk图:
Zookeeper典型使用场景实战:分布式锁、注册中心:https://blog.csdn.net/qq_43631716/article/details/118786533
2.4 如何保证数据一致性
1、最终一致性方案: 设置超时时间来解决
redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE+id, productInfo, 360, TimeUnit.SECONDS);
2、实时一致性方案: 使用阿里的canal组件, 读取binlog并修改redis中的数据
2.5 缓存应用场景
1、访问量大、QPS高、更新频率不是很高的业务
2、数据一致性要求不高
二、缓存问题
参考文章:Redis缓存设计(key、value设计)与性能优化(缓存击穿、缓存穿透、缓存雪崩)
缓存击穿问题(热点数据单个key)
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。
解决方案:
1、加锁:在未命中缓存时,通过加锁避免大量请求访问数据库
2、不允许过期: 物理不过期,也就是不设置过期时间。而是逻辑上定时在后台异步的更新数据。
3、采用二级缓存: L1缓存失效时间短,L2缓存失效时间长。请求优先从L1缓存获取数据,如果未命中,则加锁,保证只有一个线程去数据库中读取数据然后再更新到L1和L2中。然后其他线程依然在L2缓存获取数据。
缓存穿透问题(恶意攻击、访问不存在数据)
缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
解决方案: 有很多种方法可以有效地解决缓存穿透问题
1、最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
2、另外也有一个更为简单粗暴的方法(我们采用的就是这种),如果一个查询返回的数据为空(不管是数 据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
public List<String> getData03() {
List<String> result = new ArrayList<String>();
// 从缓存读取数据
result = getDataFromCache();
if (result.isEmpty()) {
synchronized (lock) {
//双重判断,第二个以及之后的请求不必去找数据库,直接命中缓存
// 查询缓存
result = getDataFromCache();
if (result.isEmpty()) {
// 从数据库查询数据
result = getDataFromDB();
// 将查询到的数据写入缓存
setDataToCache(result);
}
}
}
return result;
}
static Lock reenLock = new ReentrantLock();
public List<String> getData04() throws InterruptedException {
List<String> result = new ArrayList<String>();
// 从缓存读取数据
result = getDataFromCache();
if (result.isEmpty()) {
if (reenLock.tryLock()) { // 如果是分布式,要用redisson分布式锁
try {
System.out.println("我拿到锁了,从DB获取数据库后写入缓存");
// 从数据库查询数据
result = getDataFromDB();
// 将查询到的数据写入缓存
setDataToCache(result);
} finally {
reenLock.unlock();// 释放锁
}
} else {
result = getDataFromCache();// 先查一下缓存
if (result.isEmpty()) {
System.out.println("我没拿到锁,缓存也没数据,先小憩一下");
// 这里可以自旋或者sleep
Thread.sleep(100);// 小憩一会儿
return getData04();// 重试
}
}
}
return result;
}
缓存雪崩(同一时间失效,并发量大)
存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
解决方案:
1、缓存失效时的雪崩效应对底层系统的冲击非常可怕。大多数系统设计者考虑用加锁或者队列的方式保证缓存的单线 程(进程)写,从而避免失效时大量的并发请求落到底层存储系统上。 这里分享一个简单方案就时讲缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
2、事前:这种方案就是在发生雪崩前对缓存集群实现高可用,如果是使用 Redis,可以使用 主从+哨兵 ,Redis Cluster 来避免 Redis 全盘崩溃的情况。
3、事中:使用 Hystrix或者Sentinel进行限流 & 降级,比如一秒来了5000个请求,我们可以设置假设只能有一秒 2000个请求能通过这个组件,那么其他剩余的 3000 请求就会走限流逻辑。然后去调用我们自己开发的降级组件(降级),比如设置的一些默认值呀之类的。以此来保护最后的 MySQL 不会被大量的请求给打死。
4、事后:开启Redis持久化机制,尽快恢复缓存集群
缓存和数据库双写一致性问题
一致性问题是分布式常见问题,还可以再分为最终一致性和强一致性。数据库和缓存双写,就必然会存在不一致的问题。
答这个问题,先明白一个前提。就是如果对数据有强一致性要求,不能放缓存。
我们所做的一切,只能保证最终一致性。另外,我们所做的方案其实从根本上来说,只能说降低不一致发生的概率,无法完全避免。因此,有强一致性要求的数据,不能放缓存。
推荐做法:
1、对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
2、就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
3、【推荐】如果不能容忍缓存数据不一致,可以通过加读写锁保证并发读写或写写的时候按顺序排好队,读读的时候相当于无锁。
/**
* ==================== 读锁:查询库存 =======================
*/
@RequestMapping("/get_stock")
public String getStock(@RequestParam("clientId") Long clientId) {
String lockKey = "product_stock_101";
RReadWriteLock readWriteLock = redisson.getReadWriteLock(lockKey);
RLock rLock = readWriteLock.readLock();
System.out.println("获取读锁成功. ClientId: " + clientId);
// 获取到redisson锁对象
try {
rLock.lock();
// ======== 扣减库存业务员开始 ============
// 从redis获取库存数量
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
return String.valueOf(stock);
// ======== 扣减库存业务员结束 ============
} finally { // 防止异常导致锁无法释放!!!
// ============= 释放redisson锁 ==========
rLock.unlock();
System.out.println("释放读锁成功. ClientId: " + clientId);
}
}
加了写锁之后,相当于只有一个lockKey(“product_stock_101”),使用setnx设置之后,只能有一个线程持有这个lockKey完成修改操作,相当于串行执行修改的代码。
/**
* ==================== 写锁:修改库存 =======================
*/
@RequestMapping("/update_stock")
public String updateStock(@RequestParam("clientId") Long clientId) {
String lockKey = "product_stock_101";
RReadWriteLock readWriteLock = redisson.getReadWriteLock(lockKey);
WLock wLock = readWriteLock.writeLock();
System.out.println("获取写锁成功. ClientId: " + clientId);
// 获取到redisson锁对象
try {
wLock .lock();
// ======== 扣减库存业务员开始 ============
System.out.println("修改商品的库存为6....");
stringRedisTemplate.delete("stock");
// ======== 扣减库存业务员结束 ============
} finally { // 防止异常导致锁无法释放!!!
// ============= 释放redisson锁 ==========
wLock .unlock();
System.out.println("释放写锁成功. ClientId: " + clientId);
}
return "end";
}
4、也可以用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。
此时应用程序不用对缓存做任何操作。因为通过监听数据库的binlog,会自动的去更新缓存。