一、使用缓存
1、缓存的使用适用于变化不频繁,需要大量被访问的数据,例如线路、仓库名称等,每次大促来临之时,业务上会禁止一些数据的变更,以便于数据预热到缓存时能与DB保持一致,大促之时,系统基本从缓存中读取数据,以此减少IO次数,同时也是为了保护高流量下DB不会击垮。
2、缓存分为本地缓存 & 分布式缓存,本地缓存是将热点数据存储到本地JVM中,系统访问数据时先从本地缓存中读取,没有再从分布式缓存中获取,先读取本地可以有效地降低网络通信的耗时(访问分布式缓存需要走网络通信)。本地缓存如今有许多开源框架供以选择,例如guava cache、Caffine等,当然最简单的还是使用JDK的ConcurrentHashMap,在高并发系统中,Caffine性能好于guava,一般系统中,使用guava即可。该模式理解为二级缓存,有效解决热点问题,本地缓存失效时间设置稍短约于分布式缓存。
3、使用缓存的场景是高并发 & 允许出现短时间的数据不一致。如果业务是实时性的,需要考虑采用主动推送的方式更新缓存,当数据库出现变化时,主动put最新值到缓存中,但update & put操作还是存在时间差的,这段时间里还是会出现缓存不一致的情况,但好于缓存失效之后再去DB中捞取数据。
二、使用缓存
1、考虑到最简单的场景:先从缓存中查询数据,没有再从DB中获取,有查询结果放入到缓存中,这种场景适用于一般流量小系统,一定不用用在高并发系统中,系统会完蛋的。上述流程存在缓存击穿的致命弱点:
- 缓存数据同时失效
- 恶意查询不存在数据,导致缓存命中率=0,大量请求访问DB(此时缓存基本就是摆设了)
针对缓存同时失效,可以在失效时间+随机数,使得缓存失效尽量错开,减少同时大量请求访问DB,随机数一定要使用ThreadLocalRandom,它的性能优于Random,原因在于Random获取随机数时使用了CAS+for循环,导致性能浪费。详细原理对比参考:
http://www.importnew.com/12460.html
public void putCache(){
UsaTairManager.prefixPut(this.usaMcTairManager, UsaConstants.USA_TAIR_NAMESPACE,
(UsaConstants.USA_PREFIX_KEY + pKey), sKey, (ArrayList<UsaServiceCpResourceLineDTO>)tairValue, 0,
getPlusExpireTime());
}
private int getPlusExpireTime(){
return ThreadLocalRandom.current().nextInt(100);
}
针对查询不存在的数据,当第一次查询缓存未命中走到DB端时,返回的是NULL,此时依然要放入一个空对象进入缓存(不是NULL),例如NullObject。
import java.io.Serializable;
import java.util.Objects;
public class NullObject implements Serializable {
/**
* 一定要个字段,不然equals无法实现
*/
private Boolean dummy = false;
@Override
public boolean equals(Object o) {
if (this == o) { return true; }
if (o == null || getClass() != o.getClass()) { return false; }
NullObject that = (NullObject)o;
return Objects.equals(dummy, that.dummy);
}
@Override
public int hashCode() {
return Objects.hash(dummy);
}
}
使用该NullObject方式如下:
import java.io.Serializable;
public class DefaultCacheClient implements CacheClient {
private static final NullObject NULL = new NullObject();
private Cache cache;
private boolean enabled = true;
public void setCache(Cache cache) {
this.cache = cache;
}
public Cache getCache() {
return this.cache;
}
@Override
public void put(String key, Serializable value) {
if (enabled) {
if (null != value) {
cache.put(key, value);
} else {
cache.put(key, NULL);
}
}
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(String key) {
if (!enabled) {
return null;
}
Serializable v = cache.get(key);
if (null != v && v instanceof NullObject) {
return null;
}
return (T)v;
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(String key, ValueGenerator generator) {
if (!enabled) {
return (T)generator.generate();
}
Serializable v = cache.get(key);
if (null == v) {
v = generator.generate();
if (v != null) {
cache.put(key, v);
} else {
cache.put(key, NULL);
}
} else if (v instanceof NullObject) {
v = null;
}
return (T)v;
}
}
除了上面提到的2点,还有一个解决方案是锁机制:
当多个线程同时携带同一个key查询时,未命中时,对请求的key竞争锁,让第一个查询线程拿到这个锁,访问DB并返回给Cache,其他线程则不断查询cache,如果未命中则再次尝试获取锁,一旦获取到,再查询一次cache,即双重判断,重复循环直至缓存命中或拿到锁。对key加锁,synchronized(key)是不行的,因为不同线程请求的key是equals的但不一定是同一个对象,所以借助guava的本地cache做锁比较合适。
private static Cache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(1000000)
.expireAfterWrite(expire, TimeUnit.SECONDS).build();
/**
* 锁缓存,支持add and get原子操作
*/
private static LoadingCache<String, AtomicInteger> lockCache = CacheBuilder.newBuilder()
.maximumSize(1000000)
.expireAfterWrite(lockExpire, TimeUnit.SECONDS)
.build(new CacheLoader<String, AtomicInteger>() {
@Override
public AtomicInteger load(String key) throws Exception {
return new AtomicInteger(0);
}
});
public static String getConfig(final String key) {
String result;
Preconditions.checkArgument(StringUtils.isNotBlank(key));
try {
boolean access = false;
long start = System.currentTimeMillis();
// 等待cache生效有超时时间
while (!access && (System.currentTimeMillis() - start) < timeout) {
// 如果缓存命中直接返回
result = cache.getIfPresent(key);
if (result != null) {
return result;
}
// 获取锁
AtomicInteger lockValue = lockCache.get(key);
// add and get = 1表示获取到锁
access = (lockValue.addAndGet(1) == 1);
if (access) {
try {
// 获取到锁后双重判断,有可能获取锁前未命中,获取到后其他线程已经put cache
result = cache.getIfPresent(key);
if (result != null) {
return result;
}
// 从diamond获取配置
result = getFromDB(key);
if (result != null) {
// 缓存配置
cache.put(key, result);
}
return result;
} catch (Exception e) {
throw new RuntimeException("error getting diamond config", e);
} finally {
// 获取到锁的保证释放锁
lockCache.invalidate(key);
}
} else {
try {
//等待
Thread.sleep(10);
} catch (Exception e) {
// ignore
}
}
}
throw new RuntimeException("wait for lock timeout");
} catch (Exception e) {
throw new RuntimeException("error getting lock", e);
}
}
2、针对于非击穿式缓存,请求从缓存中查询,无论有没有数据都会直接返回,避免了访问DB造成的缓存击穿问题,那么如果查询不到数据怎么办呢,使用异步(队列+单线程),把不存在value的key放入队列中,一个线程负责从队列中取出key——查询DB——放入缓存。由于是单线程,即使访问DB压力也不大。劣势在于会导致一定时间内的数据不一致。
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
public class DatabaseDefender {
private final ExecutorService threadPool = Executors.newFixedThreadExecutor(2);
private final BlockingQueue<Item> queue = new LinkedBlockingQueue<Item>(100);
private volatile boolean isInited = false;
public void defend(Long sellerId, String spCode) {
Item item = new Item(sellerId, spCode);
boolean isAdd = queue.offer(item);
if (!isAdd) {
//log
} else {
if (! isInited) {
synchronized (queue) {
if (! isInited) {
threadPool.execute(new Runnable() {
@Override
public void run() {
try {
for (; ; ) {
Item item = queue.take();
//get from db then put it into cache
}
} catch (Throwable e) {
}
}
});
}
}
isInited = true;
}
}
}
}
缓存更新的其他知识可以参考https://coolshell.cn/articles/17416.html