首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

Spring Boot + Ehcache 實現文章瀏覽量緩存與定時持久化

     編輯:Java 相關知識

業務痛點分析

文章瀏覽量統計若每次請求直接 UPDATE article SET views = views + 1,高併發下將導致:

  • 數據庫行鎖競爭嚴重,吞吐量急劇下降
  • 大量寫入產生 Binlog/Redo Log 壓力
  • 熱點行成為系統瓶頸

解決方案:本地緩存 + 異步落庫

核心思路:

  1. 瀏覽量增量寫入 Ehcache(記憶體級效能)
  2. 定時任務批量將緩存數據同步至數據庫
  3. 應用啟動時預熱緩存,關閉時強制持久化

環境準備

Maven 依賴

<dependency>
    <groupId>net.sf.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>2.10.9</version>
</dependency>
<dependency>
    <groupId>javax.cache</groupId>
    <artifactId>cache-api</artifactId>
    <version>1.1.1</version>
</dependency>

ehcache.xml 配置

<ehcache xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="ehcache.xsd"
         updateCheck="false" monitoring="autodetect">
    
    <diskStore path="java.io.tmpdir/ehcache"/>
    
    <defaultCache
        maxEntriesLocalHeap="1000"
        eternal="false"
        timeToIdleSeconds="300"
        timeToLiveSeconds="600"
        overflowToDisk="true"
        diskPersistent="false"
        diskExpiryThreadIntervalSeconds="120"
        memoryStoreEvictionPolicy="LRU"/>
    
    <!-- 文章瀏覽量專用緩存 -->
    <cache name="articleViews"
           maxEntriesLocalHeap="10000"
           eternal="true"
           overflowToDisk="true"
           diskPersistent="true"
           diskExpiryThreadIntervalSeconds="120"
           memoryStoreEvictionPolicy="LRU">
    </cache>
</ehcache>

Spring Boot 整合配置

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        return new EhCacheCacheManager(ehCacheManager());
    }

    @Bean
    public net.sf.ehcache.CacheManager ehCacheManager() {
        return net.sf.ehcache.CacheManager.create(
            getClass().getResource("/ehcache.xml")
        );
    }
}

application.yml

spring:
  cache:
    jcache:
      config: classpath:ehcache.xml

核心業務代碼實現

服務層:緩存讀寫邏輯

@Service
@RequiredArgsConstructor
public class ArticleViewService {
    private final CacheManager cacheManager;
    private final ArticleRepository articleRepository;
    private static final String CACHE_NAME = "articleViews";
    private static final String KEY_PREFIX = "article:views:";
    
    /** 記錄瀏覽:緩存計數 +1 */
    public long recordView(Long articleId) {
        Cache cache = cacheManager.getCache(CACHE_NAME);
        String key = KEY_PREFIX + articleId;
        
        // 原子增量:Ehcache 2.x 無原子操作,需鎖或 CAS 重試
        // 生產建議用 Caffeine/Redis 原子操作,此處演示基本邏輯
        long newCount;
        synchronized (this) {
            Cache.ValueWrapper wrapper = cache.get(key);
            long current = (wrapper != null) ? (Long) wrapper.get() : 0L;
            newCount = current + 1;
            cache.put(key, newCount);
        }
        return newCount;
    }

    /** 獲取當前瀏覽量(緩存優先) */
    public long getViewCount(Long articleId) {
        Cache cache = cacheManager.getCache(CACHE_NAME);
        String key = KEY_PREFIX + articleId;
        Cache.ValueWrapper wrapper = cache.get(key);
        if (wrapper != null) return (Long) wrapper.get();
        
        // 緩存未命中,回源數據庫
        return articleRepository.findById(articleId)
            .map(Article::getViewCount).orElse(0L);
    }

    /** 批量持久化緩存數據到數據庫 */
    @Transactional
    public void flushToDatabase() {
        Cache cache = cacheManager.getCache(CACHE_NAME);
        // Ehcache 2.x 無原生遍歷 Keys API,需維護 Key 集合或用 JMX
        // 此處演示邏輯,生產建議用 Caffeine/Redis 支援 keys()
        Set<String> keys = getTrackedKeys(); // 需自行維護
        
        for (String key : keys) {
            Long articleId = Long.valueOf(key.replace(KEY_PREFIX, ""));
            Cache.ValueWrapper wrapper = cache.get(key);
            if (wrapper != null) {
                long cachedCount = (Long) wrapper.get();
                Article article = articleRepository.findById(articleId).orElse(null);
                if (article != null) {
                    long dbCount = article.getViewCount();
                    if (cachedCount > dbCount) {
                        article.setViewCount(cachedCount);
                        articleRepository.save(article);
                    }
                }
            }
        }
    }
    
    // 需自行維護的 Key 追蹤集合(生產用 ConcurrentHashMap.newKeySet())
    private final Set<String> trackedKeys = ConcurrentHashMap.newKeySet();
    private Set<String> getTrackedKeys() { return trackedKeys; }
}

控制器層

@RestController
@RequestMapping("/articles")
@RequiredArgsConstructor
public class ArticleController {
    private final ArticleViewService viewService;

    @GetMapping("/{id}")
    public Result<ArticleVO> detail(@PathVariable Long id) {
        // 1. 記錄瀏覽(非阻塞)
        viewService.recordView(id);
        
        // 2. 獲取文章詳情(含瀏覽量)
        Article article = articleRepository.findById(id).orElseThrow();
        long views = viewService.getViewCount(id);
        
        return Result.success(ArticleVO.from(article, views));
    }
}

定時持久化任務

@Component
@EnableScheduling
@RequiredArgsConstructor
public class ViewCountSyncTask {
    private final ArticleViewService viewService;
    private static final Logger log = LoggerFactory.getLogger(ViewCountSyncTask.class);

    /** 每 5 分鐘同步一次 */
    @Scheduled(cron = "0 0/5 * * * ?")
    public void syncViewCounts() {
        log.info("開始同步文章瀏覽量至數據庫...");
        try {
            viewService.flushToDatabase();
            log.info("瀏覽量同步完成");
        } catch (Exception e) {
            log.error("瀏覽量同步失敗", e);
        }
    }
}

生命週期管理:啟動預熱與關閉持久化

@Component
@RequiredArgsConstructor
public class CacheLifecycleManager implements ApplicationListener<ContextRefreshedEvent>, 
                                                   DisposableBean {
    private final ArticleViewService viewService;
    private final ArticleRepository articleRepository;
    private static final Logger log = LoggerFactory.getLogger(CacheLifecycleManager.class);

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        // 應用啟動完成後預熱緩存
        log.info("預熱文章瀏覽量緩存...");
        List<Article> hotArticles = articleRepository.findTop100ByOrderByViewCountDesc();
        for (Article article : hotArticles) {
            String key = "article:views:" + article.getId();
            viewService.getCache().put(key, article.getViewCount());
            viewService.getTrackedKeys().add(key);
        }
        log.info("緩存預熱完成,共 {} 篇文章", hotArticles.size());
    }

    @Override
    public void destroy() {
        // 應用關閉前強制持久化
        log.info("應用關閉,強制同步瀏覽量...");
        viewService.flushToDatabase();
        log.info("同步完成");
    }
}

進階優化:從 Ehcache 遷移至 Caffeine/Redis

為什麼 Ehcache 2.x 不適合計數器場景

  • 無原子增量操作(put 非原子),高併發下計數丟失
  • 無原生 keys() 遍歷,需自行維護 Key 集合
  • 分佈式部署時各節點緩存不一致

Caffeine 方案(單體應用首選)

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>

@Bean
public CacheManager caffeineCacheManager() {
    CaffeineCacheManager manager = new CaffeineCacheManager("articleViews");
    manager.setCaffeine(Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(1, TimeUnit.HOURS)
        .recordStats());
    return manager;
}

// 業務代碼簡化為原子操作
public long recordView(Long articleId) {
    Cache cache = cacheManager.getCache("articleViews");
    String key = "article:views:" + articleId;
    return cache.get(key, k -> 0L, (k, v) -> v + 1); // 原子獲取並遞增
}

Redis 方案(分佈式/集群必選)

@Autowired private StringRedisTemplate redisTemplate;

public long recordView(Long articleId) {
    String key = "article:views:" + articleId;
    return redisTemplate.opsForValue().increment(key); // 原子 INCR
}

@Scheduled(cron = "0 0/5 * * * ?")
public void syncToDb() {
    Set<String> keys = redisTemplate.keys("article:views:*");
    for (String key : keys) {
        Long articleId = Long.valueOf(key.replace("article:views:", ""));
        Long cached = Long.valueOf(redisTemplate.opsForValue().get(key));
        // 同步邏輯同前
    }
}

總結

文章瀏覽量統計的高併發解決方案演進路徑:

  1. 直連 DB → 行鎖瓶頸,不可用
  2. 本地緩存 + 定時落庫(Ehcache/Caffeine) → 單體應用可用,計數近似
  3. Redis 原子計數 + 定時落庫 → 分佈式標準方案,計數精確
  4. 寫入合併:Kafka/消息隊列異步寫入 DB → 極高併發終極方案

核心原則:讀寫分離、緩存優先、異步持久化、原子操作、生命週期管理。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved