對於緩存大家都不會陌生,但如何正確和合理的使用緩存還是需要一定的思考,本文將基於Java技術棧對緩存做一個相對詳細的介紹,內容分為基本概念、本地緩存、遠程緩存和分布式緩存集群幾個部分,重點在於理解緩存的相關概念,願合理的使用Cache如下圖的妹子一樣美好。
基本概念
緩存是計算機系統中必不可少的一種解決性能問題的方法,常見的應用包括CPU緩存、操作系統緩存、本地緩存、分布式緩存、HTTP緩存、數據庫緩存等。其核心就是用空間換時間,通過分配一塊高速存儲區域(一般來說是內存)來提高數據的讀寫效率,實現的難點就在於清空策略的實現,比較合理的思路就是定時回收與即時判斷數據是否過期相結合。
緩存相關概念
- 命中率:命中率指請求次數與正確返回結果次數的比例,其影響因素包括數據實時性,如果股票類實時性要求很高的數據,緩存的命中率會很低;緩存粒度問題, 如果KEY值包含的條件太多,會出現緩存命中率特別低的情況。通常來說,提高緩存命中率的方法包括增大緩存空間的大小的;對熱點數據進行實時更新;調整緩存KEY的算法,保證緩存KEY的細粒度,如key-value;根據業務需要合理調整緩存的過期策略。
- 最大元素:緩存中可以存放的元素的最大數量。
- 清空策略包括FIFO,最先進入緩存的數據在空間不夠時會被優先清理;LFU一直以來最少被使用的元素會被清理,可以給緩存元素設置一個計數器實現;LRU最近最少使用的緩存元素會被清理,可以通過一個時間戳來講最近未使用數據清除。
- 預熱策略包括全量預熱,一開始就加載全部數據,適用於不怎么變化的數據(地區數據);增量預熱,查詢不到時從數據源取出放入緩存。
Tip:
緩存在高並發場景下的常見問題
緩存相關問題
- 緩存穿透:一般的緩存系統,都是按照key去緩存查詢,如果不存在對應的value,就應該去后端系統查找(比如DB)。如果key對應的value是一定不存在的,並且對該key並發請求量很大,就會對后端系統造成很大的壓力。這就叫做緩存穿透。解決方法包括將查詢結果為空的情況也進行緩存,緩存時間設置短一點,並在該key對應的數據insert之后清理緩存;對一定不存在的key進行過濾。
- 緩存雪崩
當緩存服務器重啟或者大量緩存集中在某一個時間段失效,這時會給后端系統(比如DB)帶來很大壓力。解決方案包括在緩存失效后,通過加鎖或者隊列來控制讀數據庫寫緩存的線程數量,比如對某個key只允許一個線程查詢數據和寫緩存,其他線程等待;不同的key設置不同的過期時間,讓緩存失效的時間點盡量均勻;做二級緩存,A1為原始緩存,A2為拷貝緩存,A1失效時,可以訪問A2,A1緩存失效時間設置為短期,A2設置為長期。
分布式緩存系統的需要注意緩存一致性、緩存穿透和雪崩、緩存數據的清理等問題。可以通過鎖解決一致性問題;為了提高緩存命中率,可以對緩存分層,分為全局緩存,二級緩存,他們是存在繼承關系的,全局緩存可以有二級緩存來組成。為了保證系統的HA,緩存系統可以組合使用兩套存儲系統(memcache,redis)。緩存淘汰的策略包括定時去清理過期的緩存、判斷過期時間來決定是否重新獲取數據。
在java應用中通常由兩類緩存,一類是進程內緩存,就是使用java應用虛擬機內存的緩存;另一個是進程外緩存,現在我們常用的各種分布式緩存。前者比較簡單,而且在一個JVM中,快速且可用性高,但會存在多態負載均衡主機數據不一致的問題,因此適合最常用且不易變的數據。后者擴展性強,而且相關的方案多,比如Redis Cluster等。通常來說,從數據庫讀取一條數據需要10ms,從分布式緩存讀取則只需要0.5ms左右,而本地緩存則只需要10μs,因此需要根據具體場景選出合適的方案。
Local緩存
Java的本地緩存很早就有了相關標准javax.cache
,要求的特性包括原子操作、緩存讀寫、緩存事件監聽器、數據統計等內容。實際工作中本地緩存主要用於特別頻繁的穩定數據,不然的話帶來的數據不一致會得不償失。實踐中,常使用Guava Cache
,以及與Spring結合良好的EhCache
.
Guava Cache
是一個全內存的本地緩存實現,它提供了線程安全的實現機制,簡單易用,性能好。其創建方式包括cacheLoader
和callable callback
兩種,前者針對整個cache
,而后者比較靈活可以在get
時指定。
CacheBuilder.newBuilder()
方法創建cache
時重要的幾個方法如下所示,之后是一個簡單的使用示例。
maximumSize(long)
:設置容量大小,超過就開始回收。
expireAfterAccess(long, TimeUnit)
:在這個時間段內沒有被讀/寫訪問,就會被回收。
expireAfterWrite(long, TimeUnit)
:在這個時間段內沒有被寫訪問,就會被回收 。
removalListener(RemovalListener)
:監聽事件,在元素被刪除時,進行監聽。
@Service
public class ConfigCenterServiceImpl implements ConfigCenterService {
private final static long maximumSize = 20;
/**
* 最大20個,過期時間為1天
*/
private Cache<String, Map<String, ConfigAppSettingDto>> cache = CacheBuilder.newBuilder().maximumSize(maximumSize)
.expireAfterWrite(1, TimeUnit.DAYS).build();
@Autowired
private ConfigAppSettingDAO configAppSettingDAO;
@Override
public ConfigAppSettingDto getByTypeNameAndKey(String configType, String appID, String key) {
Map<String, ConfigAppSettingDto> map = getByType(configType, appID);
return map.get(key);
}
/************************** 輔助方法 ******************************/
private Map<String, ConfigAppSettingDto> getByType(String configType, String appID) {
try {
return cache.get(configType, new Callable<Map<String, ConfigAppSettingDto>>() {
@Override
public Map<String, ConfigAppSettingDto> call() throws Exception {
Map<String, ConfigAppSettingDto> result = Maps.newConcurrentMap();
List<ConfigAppSetting> list = configAppSettingDAO.getByTypeName(configType, appID);
if (null != list && !list.isEmpty()) {
for (ConfigAppSetting item : list) {
result.put(item.getAppkey(), new ConfigAppSettingDto(item.getAppkey(), item.getAppvalue(),
item.getDescription()));
}
}
return result;
}
});
} catch (ExecutionException ex) {
throw new BizException(300, "獲取ConfigAppSetting配置信息失敗");
}
}
}
EHCache
EHCache也是一個全內存的本地緩存實現,符合javax.cache JSR-107
規范,被應用在Hibernate中,過去存在過期失效的緩存元素無法被GC掉,造成內存泄露的問題,其主要類型及使用示例如下所示。
Element:緩存的元素,它維護着一個鍵值對。
Cache:它是Ehcache的核心類,它有多個Element,並被CacheManager管理,實現了對緩存的邏輯操作行為。
CacheManager:Cache的容器對象,並管理着Cache的生命周期。
Spring Boot整合Ehcache示例
//maven配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
</dependency>
//開啟Cache
@SpringBootApplication
@EnableCaching
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
//方式1
@CacheConfig(cacheNames = "users")
public interface UserRepository extends JpaRepository<User, Long> {
@Cacheable
User findByName(String name);
}
//方式2
@Service
public class CacheUserServiceImpl implements CacheUserService {
@Autowired
private UserMapper userMapper;
@Override
public List<User> getUsers() {
return userMapper.findAll();
}
// Cacheable表示獲取緩存,內容會存儲在people中,包含兩個Key-Value
@Override
@Cacheable(value = "people", key = "#name")
public User getUser(String name) {
return userMapper.findUserByName(name);
}
//put是存儲
@CachePut(value = "people", key = "#user.userid")
public User save(User user) {
User finalUser = userMapper.insert(user);
return finalUser;
}
//Evict是刪除
@CacheEvict(value = "people")
public void remove(Long id) {
userMapper.delete(id);
}
}
//在application.properties指定spring.cache.type=ehcache即可
//在src/main/resources中創建ehcache.xml
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="ehcache.xsd">
<cache name="users"
maxEntriesLocalHeap="100"
timeToLiveSeconds="1200">
</cache>
</ehcache>
Remote緩存
常見的分布式緩存組件包括memcached,redis等。前者性能高效,使用方便,但功能相對單一,只支持字符串類型的數據,需要結合序列化協議,只能用作緩存。后者是目前最流行的緩存服務器,具有高效的存取速度,高並發的吞吐量,並且有豐富的數據類型,支持持久化。因此,應用場景非常多,包括數據緩存、分布式隊列、分布式鎖、消息中間件等。
Redis
Redis支持更豐富的數據結構, 例如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 與范圍查詢, bitmaps, hyperloglogs 和 地理空間(geospatial) 索引半徑查詢。 此外,Redis 內置了 復制(replication),LUA腳本(Lua scripting), LRU驅動事件(LRU eviction),事務(transactions) 和不同級別的 磁盤持久化(persistence), 並通過 Redis哨兵(Sentinel)和自動 分區(Cluster)提供高可用性(high availability)。可以說Redis兼具了緩存系統和數據庫的一些特性,因此有着豐富的應用場景。本文介紹Redis在Spring Boot中兩個典型的應用場景。
場景1:數據緩存
//maven配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
//application.properties配置
# Redis數據庫索引(默認為0)
spring.redis.database=0
# Redis服務器地址
spring.redis.host=localhost
# Redis服務器連接端口
spring.redis.port=6379
# Redis服務器連接密碼(默認為空)
spring.redis.password=
# 連接池最大連接數
spring.redis.pool.max-active=8
# 連接池最大阻塞等待時間(使用負值表示沒有限制)
spring.redis.pool.max-wait=-1
# 連接池中的最大空閑連接
spring.redis.pool.max-idle=8
# 連接池中的最小空閑連接
spring.redis.pool.min-idle=0
//方法1
@Configuration
@EnableCaching
public class CacheConfig {
@Autowired
private JedisConnectionFactory jedisConnectionFactory;
@Bean
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager(redisTemplate());
return redisCacheManager;
}
@Bean
public RedisTemplate<Object, Object> redisTemplate() {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<Object, Object>();
redisTemplate.setConnectionFactory(jedisConnectionFactory);
// 開啟事務支持
redisTemplate.setEnableTransactionSupport(true);
// 使用String格式序列化緩存鍵
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
return redisTemplate;
}
}
//方法2,和之前Ehcache方式一致
場景2:共享Session
//maven配置
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
//Session配置
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400*)//
public class SessionConfig {
}
//示例
@RequestMapping("/session")
@RestController
public class SessionController {
@Autowired
private UserRepository userRepository;
@RequestMapping("/user/{id}")
public User getUser(@PathVariable Long id, HttpSession session) {
User user = (User) session.getAttribute("user" + id);
if (null == user) {
user = userRepository.findOne(id);
session.setAttribute("user" + id, user);
}
return user;
}
}
tip:
Jedis的使用請見《大型分布式網站架構》學習筆記--02基礎設施
Jedis的Github地址
Spring Redis默認使用JDK進行序列化和反序列化,因此被緩存對象需要實現java.io.Serializable接口,否則緩存出錯。
過去寫過一篇Redis快速入門,現在來看理解上還差的比較多,有些麻瓜。
Redis集群
實現包括如下3種方式,相對於傳統的大客戶端分片和代理模式,路由查詢的方式比較新穎,具體解決方案推薦redis-cluster。
客戶端分片:包括jedis在內的一些客戶端,都實現了客戶端分片機制。
基於代理的分片:Twemproxy、codis,客戶端發送請求到一個代理,代理解析客戶端的數據,將請求轉發至正確的節點,然后將結果回復給客戶端。
路由查詢:Redis-cluster,將請求發送到任意節點,接收到請求的節點會將查詢請求發送到正確的節點上執行,這個思路比較有意思。
Docker部署Redis主從
//1.獲取redis
$docker pull redis:3.2
//2.主從啟動
$docker run -p 6379:6379 --name redis01 -v $PWD/data01:/data -v $PWD/config01:/usr/local/redis -d redis:3.2 redis-server --appendonly yes
//$docker start redis01 /usr/local/redis/redis.conf
//先建立容器,再做好配置,之后根據配置啟動(這部分細節上掌握的有些問題,比如需要先run,stop再start,如何合理的使用create?)
//配置到官網下載,docker中只需要修改appendonly yes, port xxxx
//修改從庫02,03的配置redis.conf,添加slaveof xxx.xxx.xxx.xxx 6379, docker exec -ti redis01 /bin/bash
//docker run -p 6380:6380 -v $PWD/config02:/usr/local/redis -v $PWD/data02:/data --name redis02 -d redis:3.2 redis-server /usr/local/redis/redis.conf
docker run -p 6380:6379 -v $PWD/config02:/usr/local/redis -v $PWD/data02:/data --name redis02 -d redis:3.2 redis-server --slaveof xxx.xxx.xxx.xxx 6379 --appendonly yes
docker run -p 6381:6379 -v $PWD/config03:/usr/local/redis -v $PWD/data03:/data --name redis03 -d redis:3.2 redis-server --slaveof xxx.xxx.xxx.xxx 6379 --appendonly yes
//3.在每個庫都啟動哨兵進程,用於協調選舉,需要添加sentinel.conf配置文件,地址為端口都為主庫的信息,其中最后一個參數2為哨兵最低通過票數(設置為哨兵總數的超過半數即可)
sentinel monitor redis01 xxx.xxx.xxx.xxx 6379 2
//4.啟動哨兵進程,/usr/local/bin/redis-sentinel /usr/local/redis/sentinel.conf --sentinel
//5.客戶端連接,可以通過info replication查看情況
public static void main(String[] args) {
JedisPoolConfig poolconfig = new JedisPoolConfig();
poolconfig.setMaxIdle(30);
poolconfig.setMaxTotal(1000);
Set<String> sentinels = new HashSet<String>();
sentinels.add("xxxxxx:xxx");
JedisSentinelPool jedisSentinelPool = new JedisSentinelPool("master",
sentinels, poolconfig, 3000, "redispass");
HostAndPort currentHostMaster = jedisSentinelPool.getCurrentHostMaster();
System.out.println("currentHostMaster : " + currentHostMaster.getHost() + " port: " + currentHostMaster.getPort());
Jedis resource = jedisSentinelPool.getResource();
resource.setDataSource(jedisSentinelPool);
String value = resource.get("aa");
System.out.println(value);
resource.close();
}
tip:
生產環境哨兵也需要做集群,避免單點故障,按照上面的方式啟動多個哨兵進程即可,或者每個節點啟動一個哨兵進程。
之后有空一定要把docker的compose模式學習好,進一步簡化部署工作。
Redis Sentinel機制與用法
dockerhub-redis
Docker化高可用redis集群
Redis Cluster
Redis Cluster至少需要3個主庫和3個從庫,目前還需要Ruby編寫相關腳本,接下來簡單的了解下唯品會Redis cluster大規模生產實踐,感謝唯品會的分享。
使用場景
唯品會經歷了從客戶端分片->代理分片->路由查詢
的三個階段。redis cluster用作內存存儲服務,幫助大數據實時推薦/ETL、風控、營銷三大業務,其單個cluster集群在幾十個GB到上TB級別內存存儲量(很可怕)。作為后端應用的存儲,數據來源包含Kafka --> Redis Cluster,Storm/Spark實時;Hive --> Redis Cluster, MapReduce程序;MySQL --> Redis Cluster,Java/C++程序。說實話,居然不是用於Cache功能,確實有些出人意料。
優點:無中心 架構;數據按照slot存儲分布在多個redis實例上;增加slave做standby數據副本,用於failover,使集群快速恢復;實現故障auto failover;節點之間通過gossip協議交換狀態信息;投票機制完成slave到master角色的提升。
缺點:client實現復雜,驅動要求實現smart client,緩存slots mapping信息並及時更新;目前僅JedisCluster相對成熟,異常處理部分還不完善,比如常見的“max redirect exception”;客戶端的不成熟,影響應用的穩定性,提高開發難度。節點會因為某些原因發生阻塞(阻塞時間大於clutser-node-timeout),被判斷下線。這種failover是沒有必要,sentinel也存在這種切換場景。
Tip:
推薦一個國內的Redis中文網,內容有很高的借鑒價值。
參考資料
Spring整合Ehcache管理緩存
LocalCache本地緩存分享
以Spring整合EhCache為例從根本上了解Spring緩存這件事
Guava學習筆記:Guava cache
Spring Boot中的緩存支持(一)注解配置與EhCache使用
Spring Boot中Redis的使用
Redis的兩個典型應用場景
Redis集群方案總結
Redis cluster官方文檔