انتقل إلى المحتوى الرئيسي

الفصل 2: ذاكرة التخزين المؤقت مع Spring Cache وRedis

أهداف الفصل​

  • فهم مفاهيم التخزين المؤقت
  • إعداد Spring Cache مع Redis
  • استخدام التعليقات التوضيحية @Cacheable, @CacheEvict, @CachePut
  • تنفيذ استراتيجيات تخزين مؤقت فعالة
  • إدارة انتهاء الصلاحية والإبطال

1. مقدمة في التخزين المؤقت​

1.1 لماذا التخزين المؤقت​

بدون ذاكرة التخزين المؤقت:
العميل → API → الخدمة → قاعدة البيانات → الخدمة → API → العميل
↓ ↓
10ms 100ms = 110ms إجمالي

مع ذاكرة التخزين المؤقت:
العميل → API → ذاكرة التخزين المؤقت → API → العميل
↓ ↓
10ms 1ms = 11ms إجمالي (أسرع 10 مرات)

1.2 أنواع ذاكرة التخزين المؤقت​

النوعالوصفالمثال
محليذاكرة التطبيقConcurrentHashMap، Caffeine
موزعخادم خارجي مشتركRedis، Memcached
CDNمحتوى ثابتCloudFlare، AWS CloudFront

1.3 استراتيجيات ذاكرة التخزين المؤقت​

الاستراتيجيةالوصفالاستخدام
Cache-Asideالتطبيق يدير الذاكرةعام
Read-Throughالذاكرة تدير القراءاتقراءات متكررة
Write-Throughالكتابة في الذاكرة وقاعدة البياناتاتساق قوي
Write-Behindالكتابة في الذاكرة، قاعدة البيانات غير متزامنةالأداء

2. إعداد Spring Cache مع Redis​

2.1 تبعيات Maven​

<dependencies>
<!-- Spring Cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Pool de connexions -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>

2.2 إعداد application.yml​

spring:
data:
redis:
host: localhost
port: 6379
password: ""
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
max-wait: -1ms

cache:
type: redis
redis:
time-to-live: 3600000 # 1 ساعة بالمللي ثانية
cache-null-values: false
use-key-prefix: true
key-prefix: "myapp:"

2.3 إعداد Java​

package com.example.cache.config;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableCaching
public class CacheConfig {

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// الإعداد الافتراضي
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();

// إعدادات محددة لكل ذاكرة
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();

// ذاكرة المنتجات: TTL 30 دقيقة
cacheConfigs.put("products", defaultConfig.entryTtl(Duration.ofMinutes(30)));

// ذاكرة المستخدمين: TTL 1 ساعة
cacheConfigs.put("users", defaultConfig.entryTtl(Duration.ofHours(1)));

// ذاكرة البحث: TTL 5 دقائق
cacheConfigs.put("search", defaultConfig.entryTtl(Duration.ofMinutes(5)));

return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.transactionAware()
.build();
}
}

3. التعليقات التوضيحية للذاكرة​

3.1 @Cacheable​

تخزن نتيجة طريقة في الذاكرة.

@Service
public class ProductService {

private final ProductRepository productRepository;

public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}

// ذاكرة بسيطة مع ID كمفتاح
@Cacheable(value = "products", key = "#id")
public Product getProductById(Long id) {
log.info("Fetching product {} from database", id);
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}

// ذاكرة مع مفتاح مركب
@Cacheable(value = "products", key = "#category + '_' + #page")
public List<Product> getProductsByCategory(String category, int page) {
log.info("Fetching products for category {} page {}", category, page);
return productRepository.findByCategory(category, PageRequest.of(page, 20));
}

// ذاكرة شرطية
@Cacheable(value = "products", key = "#id", condition = "#id > 0")
public Product getProductConditional(Long id) {
return productRepository.findById(id).orElse(null);
}

// لا تخزن إذا كانت النتيجة null
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public Product getProductUnlessNull(Long id) {
return productRepository.findById(id).orElse(null);
}
}

3.2 @CacheEvict​

تحذف إدخالات من الذاكرة.

@Service
public class ProductService {

// تحذف إدخالًا محددًا
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}

// تحذف جميع الإدخالات من الذاكرة
@CacheEvict(value = "products", allEntries = true)
public void clearProductCache() {
log.info("Product cache cleared");
}

// تحذف قبل التنفيذ
@CacheEvict(value = "products", key = "#id", beforeInvocation = true)
public void deleteProductBeforeInvocation(Long id) {
productRepository.deleteById(id);
}

// تحذف بعد التحديث
@CacheEvict(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
}

3.3 @CachePut​

تحدث الذاكرة دون التحقق من وجودها.

@Service
public class ProductService {

// تحدث الذاكرة بالمنتج الجديد
@CachePut(value = "products", key = "#product.id")
public Product createProduct(Product product) {
return productRepository.save(product);
}

// تحدث الذاكرة بعد التعديل
@CachePut(value = "products", key = "#result.id")
public Product updateProduct(Long id, ProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
product.setName(request.getName());
product.setPrice(request.getPrice());
return productRepository.save(product);
}
}

3.4 @Caching (التجميع)​

@Service
public class ProductService {

@Caching(
put = { @CachePut(value = "products", key = "#result.id") },
evict = {
@CacheEvict(value = "productList", allEntries = true),
@CacheEvict(value = "search", allEntries = true)
}
)
public Product saveProduct(Product product) {
return productRepository.save(product);
}
}

3.5 @CacheConfig (إعداد الفئة)​

@Service
@CacheConfig(cacheNames = "products")
public class ProductService {

@Cacheable(key = "#id") // تستخدم الذاكرة "products"
public Product getById(Long id) { ... }

@CacheEvict(key = "#id") // تستخدم الذاكرة "products"
public void delete(Long id) { ... }
}

4. مفاتيح ذاكرة مخصصة​

4.1 SpEL للمفاتيح​

// معلمة بسيطة
@Cacheable(key = "#id")

// خاصية كائن
@Cacheable(key = "#product.id")

// دمج
@Cacheable(key = "#category + ':' + #page")

// طريقة على المعلمة
@Cacheable(key = "#name.toLowerCase()")

// نتيجة (لـ @CachePut)
@CachePut(key = "#result.id")

// متغيرات الجذر
@Cacheable(key = "#root.method.name + '_' + #id")
@Cacheable(key = "#root.targetClass.simpleName + '_' + #id")

4.2 مولد مفتاح مخصص​

@Configuration
public class CacheConfig {

@Bean
public KeyGenerator customKeyGenerator() {
return (target, method, params) -> {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName());
sb.append(".");
sb.append(method.getName());
for (Object param : params) {
sb.append("_");
sb.append(param != null ? param.toString() : "null");
}
return sb.toString();
};
}
}
@Cacheable(value = "products", keyGenerator = "customKeyGenerator")
public List<Product> searchProducts(String name, String category, Double maxPrice) {
// مفتاح مولد: ProductService.searchProducts_laptop_electronics_1000.0
return productRepository.search(name, category, maxPrice);
}

5. RedisTemplate للعمليات المتقدمة​

5.1 الإعداد​

@Configuration
public class RedisConfig {

@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);

// تسلسل المفاتيح إلى String
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());

// تسلسل القيم إلى JSON
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

template.afterPropertiesSet();
return template;
}
}

5.2 العمليات​

@Service
public class RedisCacheService {

private final RedisTemplate<String, Object> redisTemplate;

public RedisCacheService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}

// عمليات String
public void setValue(String key, Object value, Duration ttl) {
redisTemplate.opsForValue().set(key, value, ttl);
}

public Object getValue(String key) {
return redisTemplate.opsForValue().get(key);
}

public Boolean setIfAbsent(String key, Object value, Duration ttl) {
return redisTemplate.opsForValue().setIfAbsent(key, value, ttl);
}

// عمليات Hash
public void setHashField(String key, String field, Object value) {
redisTemplate.opsForHash().put(key, field, value);
}

public Object getHashField(String key, String field) {
return redisTemplate.opsForHash().get(key, field);
}

public Map<Object, Object> getHash(String key) {
return redisTemplate.opsForHash().entries(key);
}

// عمليات List
public void addToList(String key, Object value) {
redisTemplate.opsForList().rightPush(key, value);
}

public List<Object> getList(String key, long start, long end) {
return redisTemplate.opsForList().range(key, start, end);
}

// عمليات Set
public void addToSet(String key, Object... values) {
redisTemplate.opsForSet().add(key, values);
}

public Set<Object> getSet(String key) {
return redisTemplate.opsForSet().members(key);
}

// TTL والحذف
public Boolean expire(String key, Duration ttl) {
return redisTemplate.expire(key, ttl);
}

public Boolean delete(String key) {
return redisTemplate.delete(key);
}

public Long deleteByPattern(String pattern) {
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
return redisTemplate.delete(keys);
}
return 0L;
}

public Boolean hasKey(String key) {
return redisTemplate.hasKey(key);
}
}

6. أنماط الذاكرة​

6.1 Cache-Aside (التحميل الكسول)​

@Service
public class ProductCacheService {

private final ProductRepository repository;
private final RedisTemplate<String, Product> redisTemplate;
private static final Duration CACHE_TTL = Duration.ofMinutes(30);

public Product getProduct(Long id) {
String key = "product:" + id;

// 1. التحقق من الذاكرة
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}

// 2. فقدان الذاكرة: التحميل من قاعدة البيانات
Product product = repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));

// 3. التخزين في الذاكرة
redisTemplate.opsForValue().set(key, product, CACHE_TTL);

return product;
}

public void updateProduct(Product product) {
// 1. التحديث في قاعدة البيانات
repository.save(product);

// 2. إبطال الذاكرة
String key = "product:" + product.getId();
redisTemplate.delete(key);
}
}

6.2 Write-Through​

@Service
public class ProductWriteThroughService {

public Product saveProduct(Product product) {
// 1. الحفظ في قاعدة البيانات
Product saved = repository.save(product);

// 2. التحديث في الذاكرة فورًا
String key = "product:" + saved.getId();
redisTemplate.opsForValue().set(key, saved, CACHE_TTL);

return saved;
}
}

6.3 Cache Warming (التسخين المسبق)​

@Component
public class CacheWarmer implements ApplicationRunner {

private final ProductRepository productRepository;
private final RedisTemplate<String, Product> redisTemplate;

@Override
public void run(ApplicationArguments args) {
log.info("Warming up product cache...");

// تحميل المنتجات الشعبية عند بدء التشغيل
List<Product> popularProducts = productRepository.findTop100ByOrderBySalesDesc();

popularProducts.forEach(product -> {
String key = "product:" + product.getId();
redisTemplate.opsForValue().set(key, product, Duration.ofHours(24));
});

log.info("Cache warmed with {} products", popularProducts.size());
}
}

7. إدارة الأخطاء​

7.1 CacheErrorHandler​

@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

@Override
public CacheErrorHandler errorHandler() {
return new SimpleCacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException ex, Cache cache, Object key) {
log.warn("Cache get error for key {} in cache {}: {}",
key, cache.getName(), ex.getMessage());
// لا تقم بنشر الخطأ - استمر بدون ذاكرة
}

@Override
public void handleCachePutError(RuntimeException ex, Cache cache,
Object key, Object value) {
log.warn("Cache put error for key {} in cache {}: {}",
key, cache.getName(), ex.getMessage());
}

@Override
public void handleCacheEvictError(RuntimeException ex, Cache cache, Object key) {
log.warn("Cache evict error for key {} in cache {}: {}",
key, cache.getName(), ex.getMessage());
}

@Override
public void handleCacheClearError(RuntimeException ex, Cache cache) {
log.warn("Cache clear error for cache {}: {}",
cache.getName(), ex.getMessage());
}
};
}
}

8. المراقبة​

8.1 Actuator Endpoints​

management:
endpoints:
web:
exposure:
include: health,caches,metrics
endpoint:
caches:
enabled: true
# قائمة الذاكرات
curl http://localhost:8080/actuator/caches

# مقاييس الذاكرة
curl http://localhost:8080/actuator/metrics/cache.gets
curl http://localhost:8080/actuator/metrics/cache.puts
curl http://localhost:8080/actuator/metrics/cache.evictions

8.2 مقاييس مخصصة​

@Service
public class ProductService {

private final MeterRegistry meterRegistry;
private final Counter cacheHits;
private final Counter cacheMisses;

public ProductService(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.cacheHits = meterRegistry.counter("product.cache.hits");
this.cacheMisses = meterRegistry.counter("product.cache.misses");
}

public Product getProduct(Long id) {
Product cached = getFromCache(id);
if (cached != null) {
cacheHits.increment();
return cached;
}

cacheMisses.increment();
return loadFromDatabase(id);
}
}

9. ملخص​

التعليقات التوضيحية​

التعليق التوضيحيالإجراء
@Cacheableيقرأ من الذاكرة أو ينفذ ويخزن
@CachePutينفذ دائمًا ويحدث الذاكرة
@CacheEvictيحذف من الذاكرة
@Cachingيجمع بين عدة تعليقات توضيحية
@CacheConfigإعداد على مستوى الفئة

أفضل الممارسات​

  1. تحديد TTL مناسب لكل نوع من البيانات
  2. استخدام مفاتيح ذات معنى ومتسقة
  3. إبطال الذاكرة عند التحديثات
  4. إدارة أخطاء Redis برفق
  5. مراقبة نسب hit/miss
  6. عدم تخزين البيانات الحساسة
  7. التخطيط للتسخين المسبق للذاكرة للبيانات الحرجة

الفصل التالي: المراسلة مع RabbitMQ

العودة إلى الوحدة 14