緩存深入理解


對於緩存大家都不會陌生,但如何正確和合理的使用緩存還是需要一定的思考,本文將基於Java技術棧對緩存做一個相對詳細的介紹,內容分為基本概念、本地緩存、遠程緩存和分布式緩存集群幾個部分,重點在於理解緩存的相關概念,願合理的使用Cache如下圖的妹子一樣美好。

基本概念

緩存是計算機系統中必不可少的一種解決性能問題的方法,常見的應用包括CPU緩存、操作系統緩存、本地緩存、分布式緩存、HTTP緩存、數據庫緩存等。其核心就是用空間換時間,通過分配一塊高速存儲區域(一般來說是內存)來提高數據的讀寫效率,實現的難點就在於清空策略的實現,比較合理的思路就是定時回收與即時判斷數據是否過期相結合

緩存相關概念

  1. 命中率:命中率指請求次數與正確返回結果次數的比例,其影響因素包括數據實時性,如果股票類實時性要求很高的數據,緩存的命中率會很低;緩存粒度問題, 如果KEY值包含的條件太多,會出現緩存命中率特別低的情況。通常來說,提高緩存命中率的方法包括增大緩存空間的大小的;對熱點數據進行實時更新;調整緩存KEY的算法,保證緩存KEY的細粒度,如key-value;根據業務需要合理調整緩存的過期策略。
  2. 最大元素:緩存中可以存放的元素的最大數量。
  3. 清空策略包括FIFO,最先進入緩存的數據在空間不夠時會被優先清理;LFU一直以來最少被使用的元素會被清理,可以給緩存元素設置一個計數器實現;LRU最近最少使用的緩存元素會被清理,可以通過一個時間戳來講最近未使用數據清除。
  4. 預熱策略包括全量預熱,一開始就加載全部數據,適用於不怎么變化的數據(地區數據);增量預熱,查詢不到時從數據源取出放入緩存。

Tip:
緩存在高並發場景下的常見問題

緩存相關問題

  1. 緩存穿透:一般的緩存系統,都是按照key去緩存查詢,如果不存在對應的value,就應該去后端系統查找(比如DB)。如果key對應的value是一定不存在的,並且對該key並發請求量很大,就會對后端系統造成很大的壓力。這就叫做緩存穿透。解決方法包括將查詢結果為空的情況也進行緩存,緩存時間設置短一點,並在該key對應的數據insert之后清理緩存;對一定不存在的key進行過濾。
  2. 緩存雪崩
    緩存服務器重啟或者大量緩存集中在某一個時間段失效,這時會給后端系統(比如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

是一個全內存的本地緩存實現,它提供了線程安全的實現機制,簡單易用,性能好。其創建方式包括cacheLoadercallable 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>

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官方文檔


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM