过期是一个非常有用的特性,比如我希望登录信息放到redis中,30min之后失效;或者单日的累计信息放在redis中,在每天的凌晨自动清空。

缓存过期

在ICache接口中定义两个方法

  • 多久之后过期
  • 什么时候过期

接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 缓存过期时间
* @param key key
* @param timeInMills 超时时间,单位毫秒
* @return this
*/
ICache<K, V> expire(final K key, final long timeInMills);
/**
* 指定时间过期
* @param key key
* @param timeInMills 时间戳
* @return this
*/
ICache<K, V> expireAt(final K key, final long timeInMills);

实现

将多久之后过期通过计算转化成什么时候过期的问题。

1
2
3
4
5
6
7
8
9
10
@Override
public ICache<K, V> expire(K key, long timeInMills) {
long expireTime = System.currentTimeMillis() + timeInMills;
return this.expireAt(key, expireTime);
}
@Override
public ICache<K, V> expireAt(K key, long timeInMills) {
this.expire.expire(key, timeInMills);
return this;
}

过期策略

接口

在方法中调用expire就可以执行过期策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface ICacheExpire<K, V> {

/**
* 过期信息
*/
void expire(final K key, final long expireAt);

/**
* 需要处理的key集合
*/
void refreshExpire(final Collection<K> keyList);

/**
* 待过期的key过期时间
* 不存在,则返回 null
*/
Long expireTime(final K key);
}

实现

简单实现

最简单的过期思路就是开启一个定时任务,然后对缓存进行轮询,将过期的信息清空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
public class SingleCacheExpire<K, V> implements ICacheExpire<K, V> {

/**
* 维护所有key
*/
private final Map<K, Long> expireMap = new HashMap<>();

/**
* 清理key限制
*/
public static final int LIMIT = 100;

private final ICache<K, V> cache;

/**
* 单线程轮询
*/
private static final ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();

public SingleCacheExpire(ICache<K, V> cache) {
this.cache = cache;
this.init();
}

private void init() {
service.scheduleAtFixedRate(new ExpireThread(), 100, 100, TimeUnit.MILLISECONDS);
}

@Override
public void expire(K key, long expireAt) {
expireMap.put(key, expireAt);
}

@Override
public void refreshExpire(Collection<K> keyList) {
if(keyList.isEmpty()) {
return;
}

// 判断大小,小的作为外循环。一般都是过期的 keys 比较小。
if(keyList.size() <= expireMap.size()) {
for(K key : keyList) {
Long expireAt = expireMap.get(key);
expireKey(key, expireAt);
}
} else {
for(Map.Entry<K, Long> entry : expireMap.entrySet()) {
this.expireKey(entry.getKey(), entry.getValue());
}
}
}

@Override
public Long expireTime(K key) {
return expireMap.get(key);
}

/**
* 清理线程
*/
private class ExpireThread implements Runnable {

@Override
public void run() {
//为空则跳过
if(expireMap.isEmpty()) return;

//获取 key 进行处理
int count = 0;
for(Map.Entry<K, Long> entry : expireMap.entrySet()) {
// 清理指定数量
if(count >= LIMIT) {
return;
}
expireKey(entry);
count++;
}
}

/**
* 清理Key
*/
private void expireKey(Map.Entry<K, Long> entry) {
final K key = entry.getKey();
final Long expireAt = entry.getValue();
// 删除的逻辑处理
long currentTime = System.currentTimeMillis();
if(currentTime >= expireAt) {
expireMap.remove(key);
// 再移除缓存,后续可以通过惰性删除做补偿
cache.remove(key);
}
}
}
}

使用有序Map优化

第一种做法通过轮询缓存会有很多弊端,如果过期的应用场景不多,那么经常轮训的意义实际不大。

比如我们的任务 99% 都是在凌晨清空数据,白天无论怎么轮询,纯粹是浪费资源。

那有没有什么方法,可以快速的判断有没有需要处理的过期元素呢?

答案是有的,那就是排序的MAP。

我们换一种思路,让过期的时间做 key,相同时间的需要过期的信息放在一个列表中,作为 value。

然后对过期时间排序,轮询的时候就可以快速判断出是否有过期的信息了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
public class SortedCacheExpire<K, V> implements ICacheExpire<K, V> {
/**
* 单次清理限制
*/
private static final int LIMIT = 100;

/**
* 排序缓存
*/
private final Map<Long, List<K>> sortMap = new TreeMap<>((o1, o2) -> (int) (o1 - o2));

/**
* 过期map
*/
private final Map<K, Long> expireMap = new HashMap<>();

private final ICache<K, V> cache;

private static final ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();

public SortedCacheExpire(ICache<K, V> cache) {
this.cache = cache;
this.init();
}

private void init() {
service.scheduleAtFixedRate(new ExpireThread(), 1, 1, TimeUnit.SECONDS);
}

@Override
public void expire(K key, long expireAt) {
List<K> keys = sortMap.get(expireAt);
if(keys == null) {
keys = new ArrayList<>();
}
keys.add(key);

// 设置对应的信息
sortMap.put(expireAt, keys);
expireMap.put(key, expireAt);
}

@Override
public void refreshExpire(Collection<K> keyList) {

}

@Override
public Long expireTime(K key) {
return null;
}

private class ExpireThread implements Runnable {
@Override
public void run() {
if (sortMap.isEmpty()) return;

int count = 0;
for(Map.Entry<Long, List<K>> entry : sortMap.entrySet()) {
final Long expireAt = entry.getKey();
List<K> expireKeys = entry.getValue();

// 判断队列是否为空
if(expireKeys.isEmpty()) {
sortMap.remove(expireAt);
continue;
}
if(count >= LIMIT) {
return;
}

// 删除的逻辑处理
long currentTime = System.currentTimeMillis();
if(currentTime >= expireAt) {
Iterator<K> iterator = expireKeys.iterator();
while (iterator.hasNext()) {
K key = iterator.next();
// 先移除本身
iterator.remove();
expireMap.remove(key);

// 再移除缓存,后续可以通过惰性删除做补偿
cache.remove(key);
count++;
}
} else {
// 直接跳过,没有过期的信息
return;
}
}
}
}
}

惰性删除

原因

类似于redis,我们采用定时删除的方案,就有一个问题:可能数据清理的不及时。

那当我们查询时,可能获取到到是脏数据。

于是可以这样想,当我们关心某些数据时,才对数据执行对应的过期策略,这样压力会小很多。

1
2
3
4
public V get(Object key) {
this.cacheExpire.refreshExpire(Collections.singletonList((K) key));
return map.get(key);
}

刷新实现

实现原理也比较简单,就是一个循环,然后作删除即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public void refreshExpire(Collection<K> keyList) {
if(keyList.isEmpty()) {
return;
}
// 判断大小,小的作为外循环。一般都是过期的 keys 比较小。
if(keyList.size() <= expireMap.size()) {
for(K key : keyList) {
Long expireAt = expireMap.get(key);
expireKey(key, expireAt);
}
} else {
for(Map.Entry<K, Long> entry : expireMap.entrySet()) {
this.expireKey(entry.getKey(), entry.getValue());
}
}
}