文章瀏覽量統計若每次請求直接 UPDATE article SET views = views + 1,高併發下將導致:
核心思路:
<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 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>
@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")
);
}
}
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("同步完成");
}
}
put 非原子),高併發下計數丟失keys() 遍歷,需自行維護 Key 集合<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); // 原子獲取並遞增
}
@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));
// 同步邏輯同前
}
}
文章瀏覽量統計的高併發解決方案演進路徑:
核心原則:讀寫分離、緩存優先、異步持久化、原子操作、生命週期管理。