feat: update docs and components, fix DLQ demo bug
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
<!--
|
||||
CacheArchitectureDemo.vue
|
||||
缓存架构概览 - 展示缓存在系统中的位置和作用
|
||||
-->
|
||||
<template>
|
||||
<div class="cache-architecture-demo">
|
||||
<div class="header">
|
||||
<div class="title">缓存架构概览</div>
|
||||
<div class="subtitle">数据访问的"高速公路系统"</div>
|
||||
</div>
|
||||
|
||||
<div class="architecture-flow">
|
||||
<div class="flow-layer user">
|
||||
<div class="layer-label">用户请求</div>
|
||||
<div class="request-icon">👤</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow">↓</div>
|
||||
|
||||
<div
|
||||
class="flow-layer cache"
|
||||
:class="{ active: currentLayer === 'cache' }"
|
||||
>
|
||||
<div class="layer-label">缓存层 (Cache)</div>
|
||||
<div class="cache-box">
|
||||
<div class="cache-icon">⚡</div>
|
||||
<div class="cache-stats">
|
||||
<div>命中率: {{ hitRate }}%</div>
|
||||
<div>响应时间: ~1ms</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow">
|
||||
↓ <span v-if="showMiss" class="miss-text">未命中</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flow-layer database"
|
||||
:class="{ active: currentLayer === 'database' }"
|
||||
>
|
||||
<div class="layer-label">数据库层 (Database)</div>
|
||||
<div class="database-box">
|
||||
<div class="database-icon">🗄️</div>
|
||||
<div class="database-stats">
|
||||
<div>响应时间: ~50ms</div>
|
||||
<div>持久化存储</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison">
|
||||
<div class="comparison-title">访问速度对比</div>
|
||||
<div class="speed-bars">
|
||||
<div class="speed-item">
|
||||
<div class="label">缓存命中</div>
|
||||
<div class="bar-container">
|
||||
<div class="bar fast" :style="{ width: '5%' }"></div>
|
||||
</div>
|
||||
<div class="time">~1ms</div>
|
||||
</div>
|
||||
<div class="speed-item">
|
||||
<div class="label">数据库查询</div>
|
||||
<div class="bar-container">
|
||||
<div class="bar slow" :style="{ width: '100%' }"></div>
|
||||
</div>
|
||||
<div class="time">~50ms</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conclusion">
|
||||
缓存命中时,响应速度提升 <strong>{{ speedup }}x</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="interactive-demo">
|
||||
<button class="demo-btn" @click="simulateRequest">模拟请求</button>
|
||||
<div class="demo-result" v-if="lastResult">
|
||||
<span :class="{ hit: lastResult.hit, miss: !lastResult.hit }">
|
||||
{{ lastResult.hit ? '✅ 缓存命中' : '❌ 缓存未命中,访问数据库' }}
|
||||
</span>
|
||||
<span class="response-time">{{ lastResult.time }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const currentLayer = ref('cache')
|
||||
const showMiss = ref(false)
|
||||
const lastResult = ref(null)
|
||||
const hitRate = ref(75)
|
||||
|
||||
const speedup = computed(() => Math.round(50 / 1))
|
||||
|
||||
const simulateRequest = () => {
|
||||
const hit = Math.random() * 100 < hitRate.value
|
||||
lastResult.value = {
|
||||
hit,
|
||||
time: hit
|
||||
? Math.floor(Math.random() * 3) + 1
|
||||
: Math.floor(Math.random() * 20) + 40
|
||||
}
|
||||
|
||||
currentLayer.value = 'cache'
|
||||
showMiss.value = false
|
||||
|
||||
if (!hit) {
|
||||
setTimeout(() => {
|
||||
showMiss.value = true
|
||||
currentLayer.value = 'database'
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cache-architecture-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.architecture-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.flow-layer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.flow-layer.active {
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.layer-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.cache-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #f59e0b;
|
||||
}
|
||||
|
||||
.cache-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.cache-stats {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.database-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
.database-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.database-stats {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.miss-text {
|
||||
position: absolute;
|
||||
right: -80px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.75rem;
|
||||
color: #ef4444;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.comparison-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.speed-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.speed-item {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 60px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.bar-container {
|
||||
height: 20px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
|
||||
.bar.fast {
|
||||
background: linear-gradient(90deg, #22c55e, #16a34a);
|
||||
}
|
||||
|
||||
.bar.slow {
|
||||
background: linear-gradient(90deg, #f59e0b, #ef4444);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.conclusion {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.interactive-demo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.demo-btn {
|
||||
padding: 0.75rem 2rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.demo-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.demo-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hit {
|
||||
color: #22c55e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.miss {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.response-time {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,621 @@
|
||||
<!--
|
||||
CacheLifecycleDemo.vue
|
||||
缓存生命周期演示 - 展示缓存条目的写入、命中、过期、淘汰过程
|
||||
-->
|
||||
<template>
|
||||
<div class="cache-lifecycle-demo">
|
||||
<div class="header">
|
||||
<div class="title">缓存生命周期演示</div>
|
||||
<div class="subtitle">观察缓存条目从创建到淘汰的完整过程</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-container">
|
||||
<div class="cache-header">
|
||||
<div class="cache-title">
|
||||
缓存存储 (容量: {{ cacheSize }}/{{ maxCacheSize }})
|
||||
</div>
|
||||
<div class="cache-stats">
|
||||
<span>命中率: {{ hitRate }}%</span>
|
||||
<span>淘汰: {{ evictionCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-entries">
|
||||
<div
|
||||
v-for="entry in cacheEntries"
|
||||
:key="entry.id"
|
||||
class="cache-entry"
|
||||
:class="{
|
||||
hit: entry.status === 'hit',
|
||||
expiring: entry.status === 'expiring',
|
||||
evicting: entry.status === 'evicting',
|
||||
new: entry.status === 'new'
|
||||
}"
|
||||
>
|
||||
<div class="entry-header">
|
||||
<div class="entry-id">{{ entry.key }}</div>
|
||||
<div class="entry-status">
|
||||
<span v-if="entry.status === 'new'" class="status-badge new"
|
||||
>NEW</span
|
||||
>
|
||||
<span v-if="entry.status === 'hit'" class="status-badge hit"
|
||||
>HIT</span
|
||||
>
|
||||
<span
|
||||
v-if="entry.status === 'expiring'"
|
||||
class="status-badge expiring"
|
||||
>EXPIRING</span
|
||||
>
|
||||
<span
|
||||
v-if="entry.status === 'evicting'"
|
||||
class="status-badge evicting"
|
||||
>EVICTING</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-ttl">
|
||||
<div class="ttl-bar">
|
||||
<div
|
||||
class="ttl-fill"
|
||||
:style="{ width: entry.ttlPercent + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="ttl-text">TTL: {{ entry.ttl }}s</div>
|
||||
</div>
|
||||
<div class="entry-meta">
|
||||
<span>命中: {{ entry.hits }}</span>
|
||||
<span>访问: {{ entry.lastAccess }}s前</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label>操作</label>
|
||||
<button class="action-btn read" @click="readData">读取数据</button>
|
||||
<button class="action-btn write" @click="writeData">写入新数据</button>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>自动模拟</label>
|
||||
<button
|
||||
class="action-btn auto"
|
||||
:class="{ active: autoMode }"
|
||||
@click="toggleAuto"
|
||||
>
|
||||
{{ autoMode ? '停止' : '开始' }}自动模拟
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<div class="timeline-title">事件时间线</div>
|
||||
<div class="timeline-events">
|
||||
<div
|
||||
v-for="(event, index) in events"
|
||||
:key="index"
|
||||
class="event"
|
||||
:class="event.type"
|
||||
>
|
||||
<div class="event-time">{{ event.time }}</div>
|
||||
<div class="event-content">
|
||||
<span class="event-icon">{{ event.icon }}</span>
|
||||
<span class="event-text">{{ event.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-color new"></span>
|
||||
<span>新写入</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color hit"></span>
|
||||
<span>缓存命中</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color expiring"></span>
|
||||
<span>即将过期</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color evicting"></span>
|
||||
<span>淘汰中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
const maxCacheSize = 6
|
||||
const cacheEntries = ref([])
|
||||
const events = ref([])
|
||||
const autoMode = ref(false)
|
||||
let autoInterval = null
|
||||
let eventCounter = 0
|
||||
|
||||
const cacheSize = computed(() => cacheEntries.value.length)
|
||||
const hitRate = computed(() => {
|
||||
const hitEvents = events.value.filter((e) => e.type === 'hit').length
|
||||
const totalEvents = events.value.filter(
|
||||
(e) => e.type === 'hit' || e.type === 'miss'
|
||||
).length
|
||||
return totalEvents > 0 ? Math.round((hitEvents / totalEvents) * 100) : 0
|
||||
})
|
||||
const evictionCount = computed(
|
||||
() => events.value.filter((e) => e.type === 'eviction').length
|
||||
)
|
||||
|
||||
const addEvent = (type, icon, text) => {
|
||||
const now = new Date()
|
||||
events.value.unshift({
|
||||
time: `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`,
|
||||
type,
|
||||
icon,
|
||||
text
|
||||
})
|
||||
|
||||
if (events.value.length > 10) {
|
||||
events.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
const writeData = () => {
|
||||
if (cacheEntries.value.length >= maxCacheSize) {
|
||||
// LRU: Remove least recently used
|
||||
const lruIndex = cacheEntries.value.reduce(
|
||||
(minIdx, entry, idx, arr) =>
|
||||
entry.lastAccess > arr[minIdx].lastAccess ? minIdx : idx,
|
||||
0
|
||||
)
|
||||
|
||||
const evicting = cacheEntries.value[lruIndex]
|
||||
evicting.status = 'evicting'
|
||||
addEvent('eviction', '🗑️', `淘汰 ${evicting.key} (LRU)`)
|
||||
|
||||
setTimeout(() => {
|
||||
cacheEntries.value.splice(lruIndex, 1)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const newId = `key_${++eventCounter}`
|
||||
const newEntry = {
|
||||
key: newId,
|
||||
status: 'new',
|
||||
ttl: 30,
|
||||
ttlPercent: 100,
|
||||
hits: 0,
|
||||
lastAccess: 0
|
||||
}
|
||||
|
||||
cacheEntries.value.push(newEntry)
|
||||
addEvent('write', '✨', `写入 ${newId}`)
|
||||
|
||||
setTimeout(() => {
|
||||
newEntry.status = null
|
||||
}, 500)
|
||||
|
||||
startTTLDecay(newEntry)
|
||||
}
|
||||
|
||||
const readData = () => {
|
||||
if (cacheEntries.value.length === 0) {
|
||||
addEvent('miss', '❌', '缓存为空,未命中')
|
||||
return
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * cacheEntries.value.length)
|
||||
const entry = cacheEntries.value[randomIndex]
|
||||
|
||||
entry.status = 'hit'
|
||||
entry.hits++
|
||||
entry.lastAccess = 0
|
||||
entry.ttl = Math.min(entry.ttl + 5, 30) // Refresh TTL on hit
|
||||
entry.ttlPercent = (entry.ttl / 30) * 100
|
||||
|
||||
addEvent('hit', '✅', `命中 ${entry.key} (第${entry.hits}次)`)
|
||||
|
||||
setTimeout(() => {
|
||||
entry.status = null
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const startTTLDecay = (entry) => {
|
||||
const interval = setInterval(() => {
|
||||
if (!cacheEntries.value.includes(entry)) {
|
||||
clearInterval(interval)
|
||||
return
|
||||
}
|
||||
|
||||
entry.lastAccess++
|
||||
entry.ttl--
|
||||
entry.ttlPercent = (entry.ttl / 30) * 100
|
||||
|
||||
if (entry.ttl <= 10) {
|
||||
entry.status = 'expiring'
|
||||
}
|
||||
|
||||
if (entry.ttl <= 0) {
|
||||
addEvent('expiration', '⏰', `${entry.key} 过期`)
|
||||
const idx = cacheEntries.value.indexOf(entry)
|
||||
if (idx !== -1) {
|
||||
cacheEntries.value.splice(idx, 1)
|
||||
}
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const toggleAuto = () => {
|
||||
autoMode.value = !autoMode.value
|
||||
|
||||
if (autoMode.value) {
|
||||
autoInterval = setInterval(() => {
|
||||
const action = Math.random()
|
||||
if (action < 0.4 || cacheEntries.value.length === 0) {
|
||||
writeData()
|
||||
} else {
|
||||
readData()
|
||||
}
|
||||
}, 1500)
|
||||
} else {
|
||||
if (autoInterval) {
|
||||
clearInterval(autoInterval)
|
||||
autoInterval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoInterval) {
|
||||
clearInterval(autoInterval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cache-lifecycle-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cache-container {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.cache-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cache-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.cache-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.cache-entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.cache-entry {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.cache-entry.new {
|
||||
border-color: #22c55e;
|
||||
background: #f0fdf4;
|
||||
animation: slideIn 0.3s;
|
||||
}
|
||||
|
||||
.cache-entry.hit {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.cache-entry.expiring {
|
||||
border-color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.cache-entry.evicting {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
animation: shake 0.5s;
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.entry-id {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.new {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.hit {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.expiring {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.evicting {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.entry-ttl {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ttl-bar {
|
||||
height: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ttl-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #22c55e, #f59e0b, #ef4444);
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.ttl-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.entry-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn.read {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.write {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.auto {
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.action-btn.auto.active {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.timeline-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.event.hit {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.event.miss {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.event.write {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.event.eviction {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.event-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
.legend-color.new {
|
||||
border-color: #22c55e;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.legend-color.hit {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.legend-color.expiring {
|
||||
border-color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.legend-color.evicting {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,808 @@
|
||||
<!--
|
||||
CachePatternsDemo.vue
|
||||
缓存模式演示 - Cache-Aside, Read-Through, Write-Behind
|
||||
-->
|
||||
<template>
|
||||
<div class="cache-patterns-demo">
|
||||
<div class="header">
|
||||
<div class="title">缓存模式 (Caching Patterns)</div>
|
||||
<div class="subtitle">理解不同缓存读写模式的工作原理</div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-selector">
|
||||
<button
|
||||
v-for="pattern in patterns"
|
||||
:key="pattern.id"
|
||||
class="pattern-btn"
|
||||
:class="{ active: activePattern === pattern.id }"
|
||||
@click="activePattern = pattern.id"
|
||||
>
|
||||
{{ pattern.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pattern-content">
|
||||
<!-- Cache-Aside -->
|
||||
<div v-if="activePattern === 'cache-aside'" class="pattern-detail">
|
||||
<div class="description">
|
||||
<div class="pattern-title">Cache-Aside (旁路缓存)</div>
|
||||
<div class="pattern-subtitle">最常用的模式,由应用代码控制缓存</div>
|
||||
<div class="pattern-points">
|
||||
<div class="point">
|
||||
<span class="icon">📖</span>
|
||||
<div>
|
||||
<strong>读取</strong>:先查缓存,没命中再查数据库,然后写入缓存
|
||||
</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
<span class="icon">✏️</span>
|
||||
<div>
|
||||
<strong>更新</strong
|
||||
>:先更新数据库,然后<strong>删除</strong>缓存(不是更新!)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diagram">
|
||||
<div class="diagram-title">读取流程</div>
|
||||
<div class="flow-chart">
|
||||
<div class="flow-step" :class="{ active: flowStep >= 1 }">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-text">查询缓存</div>
|
||||
</div>
|
||||
<div class="flow-arrow">↓</div>
|
||||
<div class="flow-decision">
|
||||
<div class="decision-label">命中?</div>
|
||||
<div class="decision-branches">
|
||||
<div
|
||||
class="branch yes"
|
||||
:class="{ active: flowStep >= 2 && cacheHit }"
|
||||
>
|
||||
<div class="branch-label">是</div>
|
||||
<div class="branch-result">✅ 返回数据</div>
|
||||
</div>
|
||||
<div
|
||||
class="branch no"
|
||||
:class="{ active: flowStep >= 2 && !cacheHit }"
|
||||
>
|
||||
<div class="branch-label">否</div>
|
||||
<div class="branch-steps">
|
||||
<div class="flow-step" :class="{ active: flowStep >= 3 }">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-text">查询数据库</div>
|
||||
</div>
|
||||
<div class="flow-arrow">↓</div>
|
||||
<div class="flow-step" :class="{ active: flowStep >= 4 }">
|
||||
<div class="step-number">3</div>
|
||||
<div class="step-text">写入缓存</div>
|
||||
</div>
|
||||
<div class="flow-arrow">↓</div>
|
||||
<div class="flow-step" :class="{ active: flowStep >= 5 }">
|
||||
<div class="step-number">4</div>
|
||||
<div class="step-text">返回数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-controls">
|
||||
<button
|
||||
class="demo-btn"
|
||||
@click="simulateCacheAside"
|
||||
:disabled="simulating"
|
||||
>
|
||||
{{ simulating ? '模拟中...' : '模拟读取' }}
|
||||
</button>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" v-model="cacheHit" />
|
||||
缓存命中
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-example">
|
||||
<div class="code-title">代码示例</div>
|
||||
<pre class="code-block"><code>// Cache-Aside 模式
|
||||
def get_user(user_id):
|
||||
# 1. 查缓存
|
||||
user = cache.get(f'user:{user_id}')
|
||||
if user:
|
||||
return user # 命中,直接返回
|
||||
|
||||
# 2. 查数据库
|
||||
user = db.query(f'SELECT * FROM users WHERE id = {user_id}')
|
||||
|
||||
# 3. 写入缓存
|
||||
cache.set(f'user:{user_id}', user, ttl=600)
|
||||
|
||||
return user
|
||||
|
||||
def update_user(user_id, data):
|
||||
# 1. 更新数据库
|
||||
db.update('users', data)
|
||||
|
||||
# 2. 删除缓存(不是更新!)
|
||||
cache.delete(f'user:{user_id}')</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Read-Through -->
|
||||
<div v-if="activePattern === 'read-through'" class="pattern-detail">
|
||||
<div class="description">
|
||||
<div class="pattern-title">Read-Through / Write-Through</div>
|
||||
<div class="pattern-subtitle">
|
||||
由缓存库负责与数据库交互,应用只和缓存打交道
|
||||
</div>
|
||||
<div class="pattern-points">
|
||||
<div class="point">
|
||||
<span class="icon">📖</span>
|
||||
<div>
|
||||
<strong>Read-Through</strong>:缓存库自动从数据库加载数据
|
||||
</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
<span class="icon">✏️</span>
|
||||
<div>
|
||||
<strong>Write-Through</strong>:写入缓存时同步写入数据库
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diagram">
|
||||
<div class="diagram-title">架构对比</div>
|
||||
<div class="architecture-comparison">
|
||||
<div class="arch-block">
|
||||
<div class="arch-title">Cache-Aside</div>
|
||||
<div class="arch-flow">
|
||||
<div class="flow-box app">应用</div>
|
||||
<div class="flow-arrows">
|
||||
<div>↔️ 缓存</div>
|
||||
<div>↔️ 数据库</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="arch-block">
|
||||
<div class="arch-title">Read-Through</div>
|
||||
<div class="arch-flow">
|
||||
<div class="flow-box app">应用</div>
|
||||
<div class="flow-arrows">
|
||||
<div>↔️ 缓存库</div>
|
||||
</div>
|
||||
<div class="flow-box cache">缓存库 ↔️ 数据库</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-example">
|
||||
<div class="code-title">代码示例</div>
|
||||
<pre class="code-block"><code>// Read-Through 模式(代码更简洁)
|
||||
def get_user(user_id):
|
||||
# 缓存库自动处理数据库查询
|
||||
user = cache.get_or_load(user_id, lambda: db.get_user(user_id))
|
||||
return user
|
||||
|
||||
// Write-Through 模式
|
||||
def update_user(user_id, data):
|
||||
# 缓存库自动同步到数据库
|
||||
cache.set(user_id, data) # 自动写入数据库</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Write-Behind -->
|
||||
<div v-if="activePattern === 'write-behind'" class="pattern-detail">
|
||||
<div class="description">
|
||||
<div class="pattern-title">Write-Behind (异步写回)</div>
|
||||
<div class="pattern-subtitle">写入时只写缓存,异步批量写数据库</div>
|
||||
<div class="pattern-points">
|
||||
<div class="point">
|
||||
<span class="icon">⚡</span>
|
||||
<div><strong>优点</strong>:写入极快,适合写多的场景</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
<span class="icon">⚠️</span>
|
||||
<div>
|
||||
<strong>缺点</strong>:数据可能丢失(缓存崩了,数据就没了)
|
||||
</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
<span class="icon">🎯</span>
|
||||
<div>
|
||||
<strong>适用</strong
|
||||
>:秒杀系统、点赞数、浏览量(可接受少量丢失)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diagram">
|
||||
<div class="diagram-title">写入流程</div>
|
||||
<div class="flow-chart">
|
||||
<div class="flow-step">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-text">写入缓存</div>
|
||||
<div class="step-time">⚡ ~1ms</div>
|
||||
</div>
|
||||
<div class="flow-arrow">↓</div>
|
||||
<div class="flow-step">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-text">立即返回</div>
|
||||
</div>
|
||||
<div class="flow-arrow">↓</div>
|
||||
<div class="flow-step pending">
|
||||
<div class="step-number">3</div>
|
||||
<div class="step-text">异步批量写数据库</div>
|
||||
<div class="step-time">🕐 后台执行</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-controls">
|
||||
<button class="demo-btn" @click="simulateWriteBehind">
|
||||
模拟批量写入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="write-queue" v-if="writeQueue.length > 0">
|
||||
<div class="queue-title">待写入队列</div>
|
||||
<div class="queue-items">
|
||||
<div
|
||||
v-for="(item, index) in writeQueue"
|
||||
:key="index"
|
||||
class="queue-item"
|
||||
:class="{ writing: item.writing, written: item.written }"
|
||||
>
|
||||
<span class="item-key">{{ item.key }}</span>
|
||||
<span class="item-status">{{ item.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-example">
|
||||
<div class="code-title">代码示例</div>
|
||||
<pre class="code-block"><code>// Write-Behind 模式
|
||||
def update_counter(post_id):
|
||||
# 1. 立即更新缓存(极快)
|
||||
cache.incr(f'views:{post_id}')
|
||||
# 立即返回,不等待数据库
|
||||
|
||||
# 2. 后台异步批量写入数据库
|
||||
async def flush_to_db():
|
||||
while True:
|
||||
await asyncio.sleep(5) # 每5秒批量写入
|
||||
batch = cache.get_many('views:*')
|
||||
db.batch_update(batch)
|
||||
|
||||
asyncio.create_task(flush_to_db())</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-comparison">
|
||||
<div class="comparison-title">模式对比</div>
|
||||
<table class="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>模式</th>
|
||||
<th>复杂度</th>
|
||||
<th>性能</th>
|
||||
<th>一致性</th>
|
||||
<th>适用场景</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :class="{ highlight: activePattern === 'cache-aside' }">
|
||||
<td>Cache-Aside</td>
|
||||
<td>中</td>
|
||||
<td>高</td>
|
||||
<td>中</td>
|
||||
<td>大多数场景</td>
|
||||
</tr>
|
||||
<tr :class="{ highlight: activePattern === 'read-through' }">
|
||||
<td>Read-Through</td>
|
||||
<td>低</td>
|
||||
<td>中</td>
|
||||
<td>高</td>
|
||||
<td>简单场景</td>
|
||||
</tr>
|
||||
<tr :class="{ highlight: activePattern === 'write-behind' }">
|
||||
<td>Write-Behind</td>
|
||||
<td>高</td>
|
||||
<td>极高</td>
|
||||
<td>低</td>
|
||||
<td>写多、可丢失</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activePattern = ref('cache-aside')
|
||||
const flowStep = ref(0)
|
||||
const cacheHit = ref(false)
|
||||
const simulating = ref(false)
|
||||
const writeQueue = ref([])
|
||||
|
||||
const patterns = [
|
||||
{ id: 'cache-aside', name: 'Cache-Aside' },
|
||||
{ id: 'read-through', name: 'Read-Through' },
|
||||
{ id: 'write-behind', name: 'Write-Behind' }
|
||||
]
|
||||
|
||||
const simulateCacheAside = async () => {
|
||||
simulating.value = true
|
||||
flowStep.value = 0
|
||||
|
||||
const steps = cacheHit.value ? [1, 2] : [1, 2, 3, 4, 5]
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
flowStep.value = steps[i]
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
flowStep.value = 0
|
||||
simulating.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const simulateWriteBehind = async () => {
|
||||
writeQueue.value = [
|
||||
{
|
||||
key: 'views:post:1',
|
||||
value: 100,
|
||||
status: '待写入',
|
||||
writing: false,
|
||||
written: false
|
||||
},
|
||||
{
|
||||
key: 'views:post:2',
|
||||
value: 200,
|
||||
status: '待写入',
|
||||
writing: false,
|
||||
written: false
|
||||
},
|
||||
{
|
||||
key: 'views:post:3',
|
||||
value: 150,
|
||||
status: '待写入',
|
||||
writing: false,
|
||||
written: false
|
||||
}
|
||||
]
|
||||
|
||||
for (let i = 0; i < writeQueue.value.length; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
writeQueue.value[i].writing = true
|
||||
writeQueue.value[i].status = '写入中...'
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 700))
|
||||
writeQueue.value[i].writing = false
|
||||
writeQueue.value[i].written = true
|
||||
writeQueue.value[i].status = '✅ 已写入'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cache-patterns-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pattern-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pattern-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pattern-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.pattern-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.pattern-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.pattern-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pattern-subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pattern-points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.point {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.diagram {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.diagram-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.flow-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.flow-step.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.flow-step.pending {
|
||||
border-color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.step-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.flow-arrow {
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.flow-decision {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.decision-label {
|
||||
font-weight: 600;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f59e0b;
|
||||
}
|
||||
|
||||
.decision-branches {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.branch {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.branch.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.branch-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.branch-result {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
background: #f0fdf4;
|
||||
border-radius: 6px;
|
||||
color: #166534;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.branch-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.demo-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.demo-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.demo-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.architecture-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.architecture-comparison {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.arch-block {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.arch-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.arch-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.flow-box {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flow-box.cache {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.flow-arrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.write-queue {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.queue-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.queue-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.queue-item.writing {
|
||||
border-color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.queue-item.written {
|
||||
border-color: #22c55e;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.item-key {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.item-status {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.code-example {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pattern-comparison {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.comparison-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
background: var(--vp-c-bg-soft);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.comparison-table td {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.comparison-table tr.highlight {
|
||||
background: #eff6ff;
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,999 @@
|
||||
<!--
|
||||
CacheProblemsDemo.vue
|
||||
缓存三大问题演示 - 缓存穿透、缓存击穿、缓存雪崩
|
||||
-->
|
||||
<template>
|
||||
<div class="cache-problems-demo">
|
||||
<div class="header">
|
||||
<div class="title">缓存的三大问题</div>
|
||||
<div class="subtitle">穿透、击穿、雪崩的场景与解决方案</div>
|
||||
</div>
|
||||
|
||||
<div class="problem-selector">
|
||||
<button
|
||||
v-for="problem in problems"
|
||||
:key="problem.id"
|
||||
class="problem-btn"
|
||||
:class="{ active: activeProblem === problem.id }"
|
||||
@click="activeProblem = problem.id"
|
||||
>
|
||||
<span class="problem-icon">{{ problem.icon }}</span>
|
||||
<span class="problem-name">{{ problem.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="problem-content">
|
||||
<!-- 缓存穿透 -->
|
||||
<div v-if="activeProblem === 'penetration'" class="problem-detail">
|
||||
<div class="problem-intro">
|
||||
<div class="intro-title">什么是缓存穿透?</div>
|
||||
<div class="intro-text">
|
||||
查询一个<strong>不存在的数据</strong>(如恶意请求
|
||||
id=-1),缓存没有,数据库也没有。 导致每次请求都直接打到数据库。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="problem-scenario">
|
||||
<div class="scenario-title">场景模拟</div>
|
||||
<div class="scenario-diagram">
|
||||
<div class="flow-item request">
|
||||
<div class="flow-icon">🔥</div>
|
||||
<div class="flow-text">请求 id=-999</div>
|
||||
</div>
|
||||
<div class="flow-arrow">↓</div>
|
||||
<div class="flow-item cache" :class="{ miss: true }">
|
||||
<div class="flow-icon">❌</div>
|
||||
<div class="flow-text">缓存未命中</div>
|
||||
</div>
|
||||
<div class="flow-arrow">↓</div>
|
||||
<div
|
||||
class="flow-item database"
|
||||
:class="{ overloaded: dbPressure >= 80 }"
|
||||
>
|
||||
<div class="flow-icon">🗄️</div>
|
||||
<div class="flow-text">数据库查询(不存在)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class="attack-btn"
|
||||
@click="simulatePenetration"
|
||||
:disabled="simulating"
|
||||
>
|
||||
{{ simulating ? '攻击中...' : '模拟恶意攻击' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pressure-meter">
|
||||
<div class="meter-label">数据库压力</div>
|
||||
<div class="meter-bar">
|
||||
<div
|
||||
class="meter-fill"
|
||||
:style="{ width: dbPressure + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="meter-value">{{ dbPressure }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="solutions">
|
||||
<div class="solutions-title">解决方案</div>
|
||||
<div class="solution-list">
|
||||
<div class="solution-item">
|
||||
<div class="solution-header">
|
||||
<span class="solution-number">1</span>
|
||||
<span class="solution-name">布隆过滤器 (Bloom Filter)</span>
|
||||
</div>
|
||||
<div class="solution-desc">
|
||||
在缓存前加一层过滤器,快速判断"这个 id 肯定不存在"。
|
||||
<br />
|
||||
<span class="note">100% 判断不存在,但可能有误判</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="solution-item">
|
||||
<div class="solution-header">
|
||||
<span class="solution-number">2</span>
|
||||
<span class="solution-name">缓存空对象</span>
|
||||
</div>
|
||||
<div class="solution-desc">
|
||||
查询不存在时,缓存一个 NULL 值(TTL 设置短一点,如 5 分钟)。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缓存击穿 -->
|
||||
<div v-if="activeProblem === 'breakdown'" class="problem-detail">
|
||||
<div class="problem-intro">
|
||||
<div class="intro-title">什么是缓存击穿?</div>
|
||||
<div class="intro-text">
|
||||
某个<strong>热点数据</strong>过期(如微博热搜),瞬间几百万请求同时打到数据库。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="problem-scenario">
|
||||
<div class="scenario-title">场景模拟</div>
|
||||
<div class="hotkey-scenario">
|
||||
<div class="hotkey-badge">
|
||||
🔥 热点数据
|
||||
<br />
|
||||
<span class="key">user:12345</span>
|
||||
</div>
|
||||
|
||||
<div class="concurrent-requests">
|
||||
<div class="requests-title">并发请求</div>
|
||||
<div class="requests-container">
|
||||
<div
|
||||
v-for="(req, index) in concurrentRequests"
|
||||
:key="index"
|
||||
class="request-item"
|
||||
:class="req.status"
|
||||
>
|
||||
<div class="request-id">请求 {{ req.id }}</div>
|
||||
<div class="request-status">{{ req.statusText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mutex-visual" v-if="showMutex">
|
||||
<div class="mutex-badge">🔒 互斥锁</div>
|
||||
<div class="mutex-text">只有一个线程能查数据库</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class="attack-btn"
|
||||
@click="simulateBreakdown"
|
||||
:disabled="simulating"
|
||||
>
|
||||
{{ simulating ? '模拟中...' : '模拟热点过期' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="solutions">
|
||||
<div class="solutions-title">解决方案</div>
|
||||
<div class="solution-list">
|
||||
<div class="solution-item">
|
||||
<div class="solution-header">
|
||||
<span class="solution-number">1</span>
|
||||
<span class="solution-name">互斥锁 (Mutex Lock)</span>
|
||||
</div>
|
||||
<div class="solution-desc">
|
||||
只允许一个线程查数据库,其他线程等待。
|
||||
<br />
|
||||
<span class="note">优点:简单;缺点:阻塞其他请求</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="solution-item">
|
||||
<div class="solution-header">
|
||||
<span class="solution-number">2</span>
|
||||
<span class="solution-name">逻辑过期 (Logical Expiration)</span>
|
||||
</div>
|
||||
<div class="solution-desc">
|
||||
不设置 TTL,而是在 value 里存一个过期时间字段。
|
||||
<br />
|
||||
<span class="note"
|
||||
>查询时发现"逻辑过期",异步更新缓存,同时返回旧数据</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缓存雪崩 -->
|
||||
<div v-if="activeProblem === 'avalanche'" class="problem-detail">
|
||||
<div class="problem-intro">
|
||||
<div class="intro-title">什么是缓存雪崩?</div>
|
||||
<div class="intro-text">
|
||||
大量缓存<strong>同时过期</strong>(如系统重启后,所有缓存都在
|
||||
00:00:00 过期), 数据库瞬间被打爆。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="problem-scenario">
|
||||
<div class="scenario-title">场景模拟</div>
|
||||
<div class="avalanche-visual">
|
||||
<div class="cache-items">
|
||||
<div
|
||||
v-for="(item, index) in cacheItems"
|
||||
:key="index"
|
||||
class="cache-item"
|
||||
:class="{ expired: item.expired }"
|
||||
>
|
||||
<div class="item-key">{{ item.key }}</div>
|
||||
<div class="item-ttl">TTL: {{ item.ttl }}s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mass-explosion" v-if="massExplosion">
|
||||
<div class="explosion-icon">💥</div>
|
||||
<div class="explosion-text">同时过期!</div>
|
||||
</div>
|
||||
|
||||
<div class="db-overload" :class="{ critical: dbPressure >= 90 }">
|
||||
<div class="db-icon">🗄️</div>
|
||||
<div class="db-status">数据库负载: {{ dbPressure }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class="attack-btn"
|
||||
@click="simulateAvalanche"
|
||||
:disabled="simulating"
|
||||
>
|
||||
{{ simulating ? '模拟中...' : '模拟缓存雪崩' }}
|
||||
</button>
|
||||
<button class="solution-btn" @click="applyRandomTTL">
|
||||
应用解决方案(随机 TTL)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="solutions">
|
||||
<div class="solutions-title">解决方案</div>
|
||||
<div class="solution-list">
|
||||
<div class="solution-item">
|
||||
<div class="solution-header">
|
||||
<span class="solution-number">1</span>
|
||||
<span class="solution-name">随机 TTL</span>
|
||||
</div>
|
||||
<div class="solution-desc">
|
||||
避免同时过期,TTL 加上随机值。
|
||||
<br />
|
||||
<span class="code"
|
||||
>ttl = 600 + random.randint(-60, 60) # 600 ± 60 秒</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="solution-item">
|
||||
<div class="solution-header">
|
||||
<span class="solution-number">2</span>
|
||||
<span class="solution-name">缓存预热</span>
|
||||
</div>
|
||||
<div class="solution-desc">
|
||||
系统启动时,主动加载热点数据到缓存。
|
||||
<br />
|
||||
<span class="note"
|
||||
>使用定时任务,提前刷新即将过期的热点数据</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="solution-item">
|
||||
<div class="solution-header">
|
||||
<span class="solution-number">3</span>
|
||||
<span class="solution-name">熔断降级</span>
|
||||
</div>
|
||||
<div class="solution-desc">
|
||||
当数据库压力过大时,暂时停止更新缓存,直接返回降级数据。
|
||||
<br />
|
||||
<span class="note">如"系统繁忙,请稍后再试"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-table">
|
||||
<div class="table-title">三大问题对比</div>
|
||||
<table class="problems-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>问题</th>
|
||||
<th>原因</th>
|
||||
<th>影响</th>
|
||||
<th>主要解决方案</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :class="{ active: activeProblem === 'penetration' }">
|
||||
<td>缓存穿透</td>
|
||||
<td>查询不存在的数据</td>
|
||||
<td>数据库压力增加</td>
|
||||
<td>布隆过滤器、缓存空对象</td>
|
||||
</tr>
|
||||
<tr :class="{ active: activeProblem === 'breakdown' }">
|
||||
<td>缓存击穿</td>
|
||||
<td>热点数据过期</td>
|
||||
<td>数据库瞬间压力</td>
|
||||
<td>互斥锁、逻辑过期</td>
|
||||
</tr>
|
||||
<tr :class="{ active: activeProblem === 'avalanche' }">
|
||||
<td>缓存雪崩</td>
|
||||
<td>大量缓存同时过期</td>
|
||||
<td>数据库被打爆</td>
|
||||
<td>随机 TTL、缓存预热</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeProblem = ref('penetration')
|
||||
const simulating = ref(false)
|
||||
const dbPressure = ref(0)
|
||||
const concurrentRequests = ref([])
|
||||
const showMutex = ref(false)
|
||||
const cacheItems = ref([])
|
||||
const massExplosion = ref(false)
|
||||
|
||||
const problems = [
|
||||
{ id: 'penetration', name: '缓存穿透', icon: '🕳️' },
|
||||
{ id: 'breakdown', name: '缓存击穿', icon: '🔥' },
|
||||
{ id: 'avalanche', name: '缓存雪崩', icon: '❄️' }
|
||||
]
|
||||
|
||||
const initializeCacheItems = () => {
|
||||
cacheItems.value = Array.from({ length: 8 }, (_, i) => ({
|
||||
key: `key:${i + 1}`,
|
||||
ttl: 10,
|
||||
expired: false
|
||||
}))
|
||||
}
|
||||
|
||||
const simulatePenetration = async () => {
|
||||
simulating.value = true
|
||||
dbPressure.value = 0
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
dbPressure.value = Math.min(100, dbPressure.value + 5)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
simulating.value = false
|
||||
dbPressure.value = 0
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const simulateBreakdown = async () => {
|
||||
simulating.value = true
|
||||
concurrentRequests.value = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
status: 'waiting',
|
||||
statusText: '等待中'
|
||||
}))
|
||||
|
||||
showMutex.value = true
|
||||
|
||||
// First request gets the lock
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
concurrentRequests.value[0].status = 'processing'
|
||||
concurrentRequests.value[0].statusText = '查询数据库...'
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
concurrentRequests.value[0].status = 'done'
|
||||
concurrentRequests.value[0].statusText = '✅ 完成'
|
||||
|
||||
// Other requests wait and get from cache
|
||||
for (let i = 1; i < concurrentRequests.value.length; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
concurrentRequests.value[i].status = 'done'
|
||||
concurrentRequests.value[i].statusText = '✅ 从缓存获取'
|
||||
}
|
||||
|
||||
showMutex.value = false
|
||||
|
||||
setTimeout(() => {
|
||||
simulating.value = false
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const simulateAvalanche = async () => {
|
||||
simulating.value = true
|
||||
dbPressure.value = 0
|
||||
massExplosion.value = false
|
||||
|
||||
initializeCacheItems()
|
||||
|
||||
// Countdown to expiration
|
||||
for (let i = 10; i > 0; i--) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
cacheItems.value.forEach((item) => {
|
||||
item.ttl = i
|
||||
})
|
||||
}
|
||||
|
||||
// Mass expiration
|
||||
massExplosion.value = true
|
||||
cacheItems.value.forEach((item) => {
|
||||
item.expired = true
|
||||
})
|
||||
|
||||
// Database pressure spike
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
dbPressure.value = Math.min(100, dbPressure.value + 5)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
massExplosion.value = false
|
||||
simulating.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const applyRandomTTL = async () => {
|
||||
simulating.value = true
|
||||
dbPressure.value = 0
|
||||
massExplosion.value = false
|
||||
|
||||
initializeCacheItems()
|
||||
|
||||
// Apply random TTL
|
||||
cacheItems.value.forEach((item) => {
|
||||
item.ttl = 10 + Math.floor(Math.random() * 10) - 5
|
||||
})
|
||||
|
||||
// Gradual expiration
|
||||
const maxTTL = Math.max(...cacheItems.value.map((item) => item.ttl))
|
||||
|
||||
for (let t = maxTTL; t > 0; t--) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
cacheItems.value.forEach((item) => {
|
||||
if (item.ttl > 0) {
|
||||
item.ttl--
|
||||
if (item.ttl === 0) {
|
||||
item.expired = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const expiredCount = cacheItems.value.filter((item) => item.expired).length
|
||||
dbPressure.value = Math.min(50, expiredCount * 8)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
simulating.value = false
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
initializeCacheItems()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cache-problems-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.problem-selector {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.problem-btn {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.problem-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.problem-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.problem-name {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.problem-content {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.problem-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.problem-intro {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.intro-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.problem-scenario {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.scenario-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.scenario-diagram {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.flow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
min-width: 250px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flow-item.cache.miss {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.flow-item.database.overloaded {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.flow-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.flow-text {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flow-arrow {
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin: 1.5rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.attack-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.attack-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.solution-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pressure-meter {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.meter-label {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meter-bar {
|
||||
height: 20px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.meter-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #22c55e, #f59e0b, #ef4444);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.meter-value {
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hotkey-scenario {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hotkey-badge {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #f59e0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.concurrent-requests {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.requests-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.requests-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.request-item {
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.request-item.waiting {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.request-item.processing {
|
||||
border-color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.request-item.done {
|
||||
border-color: #22c55e;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.request-id {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.request-status {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.mutex-visual {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: #eff6ff;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
.mutex-badge {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mutex-text {
|
||||
font-size: 0.9rem;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.avalanche-visual {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.cache-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cache-item {
|
||||
padding: 0.5rem;
|
||||
background: #f0fdf4;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #22c55e;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.cache-item.expired {
|
||||
background: #fef2f2;
|
||||
border-color: #ef4444;
|
||||
animation: shake 0.5s;
|
||||
}
|
||||
|
||||
.item-key {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.item-ttl {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.mass-explosion {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: #fef2f2;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #ef4444;
|
||||
}
|
||||
|
||||
.explosion-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.explosion-text {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.db-overload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.db-overload.critical {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.db-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.db-status {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.solutions {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.solutions-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.solution-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.solution-item {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.solution-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.solution-number {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.solution-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.solution-desc {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-1);
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.code {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.problems-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.problems-table th,
|
||||
.problems-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.problems-table th {
|
||||
background: var(--vp-c-bg-soft);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.problems-table td {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.problems-table tr.active {
|
||||
background: #eff6ff;
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.problems-table tr.active td {
|
||||
border-top-color: var(--vp-c-brand);
|
||||
border-bottom-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
<!--
|
||||
LocalVsDistributedCacheDemo.vue
|
||||
本地缓存 vs 分布式缓存对比演示
|
||||
-->
|
||||
<template>
|
||||
<div class="cache-comparison-demo">
|
||||
<div class="header">
|
||||
<div class="title">本地缓存 vs 分布式缓存</div>
|
||||
<div class="subtitle">对比两种缓存架构的性能和特点</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-view">
|
||||
<!-- Local Cache -->
|
||||
<div class="cache-side local">
|
||||
<div class="side-header">
|
||||
<div class="title">本地缓存 (Local Cache)</div>
|
||||
<div class="tag">进程内</div>
|
||||
</div>
|
||||
|
||||
<div class="architecture">
|
||||
<div class="app-instance">
|
||||
<div class="instance-label">应用实例 1</div>
|
||||
<div class="cache-box">
|
||||
<div class="cache-label">缓存</div>
|
||||
<div class="cache-data">
|
||||
<div v-for="item in localCache1" :key="item" class="data-item">
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-instance">
|
||||
<div class="instance-label">应用实例 2</div>
|
||||
<div class="cache-box">
|
||||
<div class="cache-label">缓存</div>
|
||||
<div class="cache-data">
|
||||
<div v-for="item in localCache2" :key="item" class="data-item">
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label">响应时间</div>
|
||||
<div class="metric-value fast">~1 ms</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">容量</div>
|
||||
<div class="metric-value">~1 GB</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">一致性</div>
|
||||
<div class="metric-value warning">低</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<div class="list-title">✅ 优点</div>
|
||||
<div class="list-item">极快(无网络开销)</div>
|
||||
<div class="list-item">简单(内存 Map)</div>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<div class="list-title">❌ 缺点</div>
|
||||
<div class="list-item">容量受限</div>
|
||||
<div class="list-item">实例间不一致</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Distributed Cache -->
|
||||
<div class="cache-side distributed">
|
||||
<div class="side-header">
|
||||
<div class="title">分布式缓存 (Distributed Cache)</div>
|
||||
<div class="tag">独立服务</div>
|
||||
</div>
|
||||
|
||||
<div class="architecture">
|
||||
<div class="instances-row">
|
||||
<div class="app-instance-small">
|
||||
<div class="instance-label-small">实例 1</div>
|
||||
</div>
|
||||
<div class="app-instance-small">
|
||||
<div class="instance-label-small">实例 2</div>
|
||||
</div>
|
||||
<div class="app-instance-small">
|
||||
<div class="instance-label-small">实例 3</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="network-layer">
|
||||
<div class="network-label">网络</div>
|
||||
<div class="network-arrows">⬇️ ⬇️ ⬇️</div>
|
||||
</div>
|
||||
|
||||
<div class="redis-cluster">
|
||||
<div class="cluster-label">Redis 集群</div>
|
||||
<div class="redis-nodes">
|
||||
<div class="redis-node">
|
||||
<div class="node-label">Node 1</div>
|
||||
<div class="node-data">
|
||||
<div
|
||||
v-for="item in redisData1"
|
||||
:key="item"
|
||||
class="data-item small"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="redis-node">
|
||||
<div class="node-label">Node 2</div>
|
||||
<div class="node-data">
|
||||
<div
|
||||
v-for="item in redisData2"
|
||||
:key="item"
|
||||
class="data-item small"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="redis-node">
|
||||
<div class="node-label">Node 3</div>
|
||||
<div class="node-data">
|
||||
<div
|
||||
v-for="item in redisData3"
|
||||
:key="item"
|
||||
class="data-item small"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label">响应时间</div>
|
||||
<div class="metric-value medium">~5 ms</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">容量</div>
|
||||
<div class="metric-value">~100 GB</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">一致性</div>
|
||||
<div class="metric-value good">高</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<div class="list-title">✅ 优点</div>
|
||||
<div class="list-item">容量可扩展</div>
|
||||
<div class="list-item">全局共享</div>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<div class="list-title">❌ 缺点</div>
|
||||
<div class="list-item">网络延迟</div>
|
||||
<div class="list-item">需要维护</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="interactive-demo">
|
||||
<div class="demo-title">交互演示:写入和读取数据</div>
|
||||
<div class="demo-controls">
|
||||
<button class="demo-btn" @click="simulateWrite">写入数据</button>
|
||||
<button class="demo-btn secondary" @click="simulateRead">
|
||||
读取数据
|
||||
</button>
|
||||
<button class="demo-btn reset" @click="reset">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-result" v-if="lastOperation">
|
||||
<div class="result-icon">{{ lastOperation.icon }}</div>
|
||||
<div class="result-text">{{ lastOperation.text }}</div>
|
||||
<div class="result-detail">{{ lastOperation.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const localCache1 = ref(['user:1', 'user:2', 'config:A'])
|
||||
const localCache2 = ref(['user:3', 'config:B'])
|
||||
const redisData1 = ref(['user:1', 'user:2', 'user:3'])
|
||||
const redisData2 = ref(['product:A', 'product:B', 'product:C'])
|
||||
const redisData3 = ref(['config:A', 'config:B'])
|
||||
const lastOperation = ref(null)
|
||||
|
||||
let dataCounter = 4
|
||||
|
||||
const simulateWrite = () => {
|
||||
const key = `user:${dataCounter++}`
|
||||
|
||||
// Local cache: Write to instance 1 only
|
||||
localCache1.value.push(key)
|
||||
if (localCache1.value.length > 5) localCache1.value.shift()
|
||||
|
||||
// Distributed cache: Hash to a node
|
||||
const nodeIndex = dataCounter % 3
|
||||
if (nodeIndex === 0) redisData1.value.push(key)
|
||||
else if (nodeIndex === 1) redisData2.value.push(key)
|
||||
else redisData3.value.push(key)
|
||||
|
||||
lastOperation.value = {
|
||||
icon: '✍️',
|
||||
text: `写入 ${key}`,
|
||||
detail: '本地缓存: 仅实例1有数据 | 分布式缓存: 所有实例共享'
|
||||
}
|
||||
}
|
||||
|
||||
const simulateRead = () => {
|
||||
const key = 'user:1'
|
||||
|
||||
const inLocal1 = localCache1.value.includes(key)
|
||||
const inLocal2 = localCache2.value.includes(key)
|
||||
const inRedis =
|
||||
redisData1.value.includes(key) ||
|
||||
redisData2.value.includes(key) ||
|
||||
redisData3.value.includes(key)
|
||||
|
||||
lastOperation.value = {
|
||||
icon: '🔍',
|
||||
text: `读取 ${key}`,
|
||||
detail: `本地缓存: 实例1${inLocal1 ? '✅' : '❌'} 实例2${inLocal2 ? '✅' : '❌'} | 分布式缓存: ${inRedis ? '✅' : '❌'}`
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
localCache1.value = ['user:1', 'user:2', 'config:A']
|
||||
localCache2.value = ['user:3', 'config:B']
|
||||
redisData1.value = ['user:1', 'user:2', 'user:3']
|
||||
redisData2.value = ['product:A', 'product:B', 'product:C']
|
||||
redisData3.value = ['config:A', 'config:B']
|
||||
dataCounter = 4
|
||||
lastOperation.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cache-comparison-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.comparison-view {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.comparison-view {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cache-side {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.cache-side.local {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.cache-side.distributed {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.side-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.side-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.architecture {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.app-instance {
|
||||
background: #eff6ff;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.instance-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.cache-box {
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #93c5fd;
|
||||
}
|
||||
|
||||
.cache-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.cache-data {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.data-item {
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: #dbeafe;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.data-item.small {
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.instances-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.app-instance-small {
|
||||
flex: 1;
|
||||
background: #fef2f2;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #fecaca;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.instance-label-small {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.network-layer {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.network-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.network-arrows {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.redis-cluster {
|
||||
background: #fef2f2;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.cluster-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #991b1b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.redis-nodes {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.redis-node {
|
||||
flex: 1;
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #fca5a5;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--vp-c-text-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-data {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-value.fast {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.metric-value.medium {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.metric-value.good {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.metric-value.warning {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.pros-cons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pros,
|
||||
.cons {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pros {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.cons {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.interactive-demo {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.demo-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.demo-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.demo-btn.secondary {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.demo-btn.reset {
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.demo-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.result-detail {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,538 @@
|
||||
<!--
|
||||
LocalityPrincipleDemo.vue
|
||||
局部性原理演示 - 展示时间局部性和空间局部性
|
||||
-->
|
||||
<template>
|
||||
<div class="locality-demo">
|
||||
<div class="header">
|
||||
<div class="title">局部性原理演示</div>
|
||||
<div class="subtitle">理解缓存为什么有效</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'temporal' }"
|
||||
@click="activeTab = 'temporal'"
|
||||
>
|
||||
时间局部性
|
||||
</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'spatial' }"
|
||||
@click="activeTab = 'spatial'"
|
||||
>
|
||||
空间局部性
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- Temporal Locality -->
|
||||
<div v-if="activeTab === 'temporal'" class="temporal-demo">
|
||||
<div class="description">
|
||||
<strong>时间局部性</strong
|
||||
>:如果你访问了某个数据,未来很可能再次访问它。
|
||||
<br />
|
||||
<span class="example"
|
||||
>例子:用户登录后,每次请求都需要查询用户信息</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<div class="timeline-title">访问时间线</div>
|
||||
<div class="timeline-events">
|
||||
<div
|
||||
v-for="(event, index) in temporalEvents"
|
||||
:key="index"
|
||||
class="event"
|
||||
:class="{ hit: event.hit, miss: !event.hit }"
|
||||
>
|
||||
<div class="event-time">{{ event.time }}</div>
|
||||
<div class="event-action">
|
||||
<span class="user-icon">👤</span>
|
||||
<span>查询 user_{{ event.userId }}</span>
|
||||
</div>
|
||||
<div class="event-result">
|
||||
{{ event.hit ? '✅ 缓存命中' : '❌ 缓存未命中' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-state">
|
||||
<div class="cache-title">当前缓存状态</div>
|
||||
<div class="cache-items">
|
||||
<div
|
||||
v-for="item in cacheItems"
|
||||
:key="item.id"
|
||||
class="cache-item"
|
||||
:class="{ active: item.active }"
|
||||
>
|
||||
<div class="item-id">{{ item.id }}</div>
|
||||
<div class="item-hits">命中 {{ item.hits }} 次</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spatial Locality -->
|
||||
<div v-if="activeTab === 'spatial'" class="spatial-demo">
|
||||
<div class="description">
|
||||
<strong>空间局部性</strong
|
||||
>:如果你访问了某个数据,很可能访问它附近的数据。
|
||||
<br />
|
||||
<span class="example">例子:浏览商品列表时,通常会翻到下一页</span>
|
||||
</div>
|
||||
|
||||
<div class="product-grid">
|
||||
<div class="grid-title">商品浏览序列</div>
|
||||
<div class="products">
|
||||
<div
|
||||
v-for="product in products"
|
||||
:key="product.id"
|
||||
class="product"
|
||||
:class="{
|
||||
viewed: product.viewed,
|
||||
cached: product.cached,
|
||||
current: product.current
|
||||
}"
|
||||
>
|
||||
<div class="product-id">{{ product.id }}</div>
|
||||
<div class="product-status">
|
||||
<span v-if="product.current">👁️ 当前</span>
|
||||
<span v-else-if="product.cached">⚡ 已缓存</span>
|
||||
<span v-else-if="product.viewed">✓ 已浏览</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spatial-explanation">
|
||||
<div class="explanation-item">
|
||||
<div class="icon">📊</div>
|
||||
<div class="text">
|
||||
<strong>预取策略</strong>:当你浏览第 5 个商品时,系统自动将 6-8
|
||||
预加载到缓存
|
||||
</div>
|
||||
</div>
|
||||
<div class="explanation-item">
|
||||
<div class="icon">🎯</div>
|
||||
<div class="text">
|
||||
<strong>命中率提升</strong>:空间局部性让缓存命中率达到 70-90%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="interactive-controls">
|
||||
<button class="control-btn" @click="addEvent">添加访问事件</button>
|
||||
<button class="control-btn secondary" @click="reset">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">总访问次数</div>
|
||||
<div class="stat-value">{{ totalAccess }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">缓存命中</div>
|
||||
<div class="stat-value hit">{{ hitCount }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">命中率</div>
|
||||
<div class="stat-value">{{ hitRate }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeTab = ref('temporal')
|
||||
const temporalEvents = ref([])
|
||||
const cacheItems = ref([])
|
||||
const products = ref([])
|
||||
let eventCounter = 0
|
||||
|
||||
const totalAccess = computed(() => temporalEvents.value.length)
|
||||
const hitCount = computed(
|
||||
() => temporalEvents.value.filter((e) => e.hit).length
|
||||
)
|
||||
const hitRate = computed(() => {
|
||||
if (totalAccess.value === 0) return 0
|
||||
return Math.round((hitCount.value / totalAccess.value) * 100)
|
||||
})
|
||||
|
||||
const initializeProducts = () => {
|
||||
products.value = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `P${i + 1}`,
|
||||
viewed: false,
|
||||
cached: false,
|
||||
current: false
|
||||
}))
|
||||
}
|
||||
|
||||
const addEvent = () => {
|
||||
const currentTime = new Date().toLocaleTimeString()
|
||||
const userId = Math.floor(Math.random() * 3) + 1
|
||||
|
||||
const existingItem = cacheItems.value.find(
|
||||
(item) => item.id === `user_${userId}`
|
||||
)
|
||||
const hit = existingItem !== undefined
|
||||
|
||||
if (existingItem) {
|
||||
existingItem.hits++
|
||||
existingItem.active = true
|
||||
setTimeout(() => {
|
||||
existingItem.active = false
|
||||
}, 1000)
|
||||
} else {
|
||||
if (cacheItems.value.length >= 5) {
|
||||
cacheItems.value.shift()
|
||||
}
|
||||
cacheItems.value.push({
|
||||
id: `user_${userId}`,
|
||||
hits: 1,
|
||||
active: true
|
||||
})
|
||||
}
|
||||
|
||||
temporalEvents.value.push({
|
||||
time: currentTime,
|
||||
userId,
|
||||
hit
|
||||
})
|
||||
|
||||
if (temporalEvents.value.length > 8) {
|
||||
temporalEvents.value.shift()
|
||||
}
|
||||
|
||||
if (activeTab.value === 'spatial') {
|
||||
const currentIndex = products.value.findIndex((p) => p.current)
|
||||
if (currentIndex !== -1) {
|
||||
products.value[currentIndex].current = false
|
||||
products.value[currentIndex].viewed = true
|
||||
}
|
||||
|
||||
const nextIndex = currentIndex + 1
|
||||
if (nextIndex < products.value.length) {
|
||||
products.value[nextIndex].current = true
|
||||
products.value[nextIndex].viewed = true
|
||||
|
||||
// Prefetch next items
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const prefetchIndex = nextIndex + i
|
||||
if (prefetchIndex < products.value.length) {
|
||||
products.value[prefetchIndex].cached = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
temporalEvents.value = []
|
||||
cacheItems.value = []
|
||||
initializeProducts()
|
||||
}
|
||||
|
||||
initializeProducts()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.locality-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.example {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.timeline-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.event {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr 120px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #94a3b8;
|
||||
}
|
||||
|
||||
.event.hit {
|
||||
border-left-color: #22c55e;
|
||||
}
|
||||
|
||||
.event.miss {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.event-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.user-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.event-result {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cache-state {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cache-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.cache-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cache-item {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.cache-item.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: #eff6ff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.item-id {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-hits {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.grid-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.products {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.product {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.product.viewed {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.product.cached {
|
||||
border-color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.product.current {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: #dbeafe;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.product-id {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.product-status {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.spatial-explanation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.explanation-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.interactive-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.control-btn.secondary {
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.stat-value.hit {
|
||||
color: #22c55e;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,620 @@
|
||||
<!--
|
||||
MultiLevelCacheDemo.vue
|
||||
多级缓存架构演示 - 展示浏览器缓存、CDN、本地缓存、Redis、数据库的多级架构
|
||||
-->
|
||||
<template>
|
||||
<div class="multi-level-cache-demo">
|
||||
<div class="header">
|
||||
<div class="title">多级缓存架构</div>
|
||||
<div class="subtitle">每一层都是上一层的"保护伞"</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-levels">
|
||||
<div
|
||||
v-for="(level, index) in cacheLevels"
|
||||
:key="level.name"
|
||||
class="cache-level"
|
||||
:class="{
|
||||
active: activeLevel === index,
|
||||
hit: level.status === 'hit',
|
||||
miss: level.status === 'miss'
|
||||
}"
|
||||
>
|
||||
<div class="level-number">L{{ level.layer }}</div>
|
||||
<div class="level-content">
|
||||
<div class="level-header">
|
||||
<div class="level-name">{{ level.name }}</div>
|
||||
<div class="level-meta">
|
||||
<span class="latency">{{ level.latency }}</span>
|
||||
<span class="capacity">{{ level.capacity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-description">{{ level.description }}</div>
|
||||
<div class="level-status" v-if="level.status">
|
||||
<span v-if="level.status === 'hit'" class="status-badge hit"
|
||||
>✅ 命中</span
|
||||
>
|
||||
<span v-if="level.status === 'miss'" class="status-badge miss"
|
||||
>❌ 未命中</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-arrow" v-if="index < cacheLevels.length - 1">↓</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label>请求数据</label>
|
||||
<button class="request-btn" @click="makeRequest" :disabled="processing">
|
||||
{{ processing ? '处理中...' : '发起请求' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>模拟场景</label>
|
||||
<select
|
||||
v-model="scenario"
|
||||
@change="onScenarioChange"
|
||||
class="scenario-select"
|
||||
>
|
||||
<option value="normal">正常访问 (70% 命中率)</option>
|
||||
<option value="cold">冷启动 (0% 命中率)</option>
|
||||
<option value="hot">热点数据 (95% 命中率)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="request-flow" v-if="requestHistory.length > 0">
|
||||
<div class="flow-title">请求流程</div>
|
||||
<div class="flow-timeline">
|
||||
<div
|
||||
v-for="(event, index) in requestHistory"
|
||||
:key="index"
|
||||
class="flow-event"
|
||||
:class="event.type"
|
||||
>
|
||||
<div class="event-level">{{ event.level }}</div>
|
||||
<div class="event-action">
|
||||
<span class="event-icon">{{ event.icon }}</span>
|
||||
<span class="event-text">{{ event.action }}</span>
|
||||
</div>
|
||||
<div class="event-time">{{ event.time }}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="statistics">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">总请求数</div>
|
||||
<div class="stat-value">{{ stats.totalRequests }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">缓存命中</div>
|
||||
<div class="stat-value hit">{{ stats.cacheHits }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">命中率</div>
|
||||
<div class="stat-value">{{ stats.hitRate }}%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">平均响应时间</div>
|
||||
<div class="stat-value">{{ stats.avgLatency }}ms</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">数据库访问</div>
|
||||
<div class="stat-value db">{{ stats.dbAccess }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explanation">
|
||||
<div class="explanation-title">多级缓存的优势</div>
|
||||
<div class="explanation-grid">
|
||||
<div class="explanation-item">
|
||||
<div class="item-icon">🛡️</div>
|
||||
<div class="item-text">
|
||||
<strong>逐级过滤</strong>
|
||||
<br />
|
||||
<span class="item-detail"
|
||||
>每层过滤掉大部分请求,最终到达数据库的可能只有 1%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="explanation-item">
|
||||
<div class="item-icon">⚡</div>
|
||||
<div class="item-text">
|
||||
<strong>极速响应</strong>
|
||||
<br />
|
||||
<span class="item-detail"
|
||||
>上层缓存命中时,响应时间从 50ms 降至 0-10ms</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="explanation-item">
|
||||
<div class="item-icon">💰</div>
|
||||
<div class="item-text">
|
||||
<strong>降低成本</strong>
|
||||
<br />
|
||||
<span class="item-detail"
|
||||
>减少昂贵的数据库查询,节省服务器资源</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeLevel = ref(-1)
|
||||
const processing = ref(false)
|
||||
const scenario = ref('normal')
|
||||
const requestHistory = ref([])
|
||||
|
||||
const cacheLevels = ref([
|
||||
{
|
||||
layer: 1,
|
||||
name: '浏览器缓存',
|
||||
latency: '~0 ms',
|
||||
capacity: '~100 MB',
|
||||
description: '静态资源(图片、CSS、JS)',
|
||||
status: null
|
||||
},
|
||||
{
|
||||
layer: 2,
|
||||
name: 'CDN 缓存',
|
||||
latency: '~10 ms',
|
||||
capacity: 'TB 级',
|
||||
description: '边缘节点静态文件',
|
||||
status: null
|
||||
},
|
||||
{
|
||||
layer: 3,
|
||||
name: '本地缓存',
|
||||
latency: '~1 ms',
|
||||
capacity: '~1 GB',
|
||||
description: '进程内极热点数据',
|
||||
status: null
|
||||
},
|
||||
{
|
||||
layer: 4,
|
||||
name: 'Redis 缓存',
|
||||
latency: '~5 ms',
|
||||
capacity: '~100 GB',
|
||||
description: '分布式热点数据',
|
||||
status: null
|
||||
},
|
||||
{
|
||||
layer: 5,
|
||||
name: '数据库',
|
||||
latency: '~50 ms',
|
||||
capacity: 'TB ~ PB',
|
||||
description: '持久化存储',
|
||||
status: null
|
||||
}
|
||||
])
|
||||
|
||||
const stats = ref({
|
||||
totalRequests: 0,
|
||||
cacheHits: 0,
|
||||
hitRate: 0,
|
||||
avgLatency: 0,
|
||||
dbAccess: 0
|
||||
})
|
||||
|
||||
const scenarioConfigs = {
|
||||
normal: { hitRate: 0.7 },
|
||||
cold: { hitRate: 0 },
|
||||
hot: { hitRate: 0.95 }
|
||||
}
|
||||
|
||||
const onScenarioChange = () => {
|
||||
requestHistory.value = []
|
||||
stats.value = {
|
||||
totalRequests: 0,
|
||||
cacheHits: 0,
|
||||
hitRate: 0,
|
||||
avgLatency: 0,
|
||||
dbAccess: 0
|
||||
}
|
||||
cacheLevels.value.forEach((level) => {
|
||||
level.status = null
|
||||
})
|
||||
}
|
||||
|
||||
const makeRequest = async () => {
|
||||
if (processing.value) return
|
||||
|
||||
processing.value = true
|
||||
requestHistory.value = []
|
||||
|
||||
// Reset statuses
|
||||
cacheLevels.value.forEach((level) => {
|
||||
level.status = null
|
||||
})
|
||||
|
||||
const config = scenarioConfigs[scenario.value]
|
||||
let hit = Math.random() < config.hitRate
|
||||
let totalLatency = 0
|
||||
|
||||
const delays = [100, 100, 100, 100, 100]
|
||||
|
||||
for (let i = 0; i < cacheLevels.value.length; i++) {
|
||||
activeLevel.value = i
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delays[i]))
|
||||
|
||||
const level = cacheLevels.value[i]
|
||||
let eventTime = 0
|
||||
|
||||
if (hit && i < cacheLevels.value.length - 1) {
|
||||
level.status = 'hit'
|
||||
eventTime = parseInt(level.latency.match(/\d+/)[0])
|
||||
totalLatency += eventTime
|
||||
|
||||
requestHistory.value.push({
|
||||
level: level.name,
|
||||
icon: '✅',
|
||||
action: '缓存命中',
|
||||
time: eventTime,
|
||||
type: 'hit'
|
||||
})
|
||||
|
||||
stats.value.cacheHits++
|
||||
break
|
||||
} else if (i === cacheLevels.value.length - 1) {
|
||||
level.status = 'miss'
|
||||
eventTime = parseInt(level.latency.match(/\d+/)[0])
|
||||
totalLatency += eventTime
|
||||
|
||||
requestHistory.value.push({
|
||||
level: level.name,
|
||||
icon: '🗄️',
|
||||
action: '查询数据库',
|
||||
time: eventTime,
|
||||
type: 'miss'
|
||||
})
|
||||
|
||||
stats.value.dbAccess++
|
||||
} else {
|
||||
level.status = 'miss'
|
||||
eventTime = parseInt(level.latency.match(/\d+/)[0])
|
||||
totalLatency += eventTime
|
||||
|
||||
requestHistory.value.push({
|
||||
level: level.name,
|
||||
icon: '❌',
|
||||
action: '未命中,继续',
|
||||
time: eventTime,
|
||||
type: 'miss'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
stats.value.totalRequests++
|
||||
stats.value.hitRate = Math.round(
|
||||
(stats.value.cacheHits / stats.value.totalRequests) * 100
|
||||
)
|
||||
stats.value.avgLatency = Math.round(totalLatency)
|
||||
|
||||
processing.value = false
|
||||
activeLevel.value = -1
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.multi-level-cache-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cache-levels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.cache-level {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.cache-level.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.cache-level.hit {
|
||||
border-color: #22c55e;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.cache-level.miss {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.level-number {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.level-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.level-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.level-name {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.level-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.level-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.level-status {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.hit {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.miss {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.level-arrow {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.request-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.request-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.request-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.scenario-select {
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.request-flow {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.flow-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.flow-event {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 80px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.flow-event.hit {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.flow-event.miss {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.event-level {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.event-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.statistics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.stat-value.hit {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.stat-value.db {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.explanation-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.explanation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.explanation-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,914 @@
|
||||
<!--
|
||||
ProductCacheDemo.vue
|
||||
商品详情页缓存实战演示 - 完整的三级缓存系统
|
||||
-->
|
||||
<template>
|
||||
<div class="product-cache-demo">
|
||||
<div class="header">
|
||||
<div class="title">商品详情页缓存系统实战</div>
|
||||
<div class="subtitle">完整的三级缓存架构 + 监控面板</div>
|
||||
</div>
|
||||
|
||||
<div class="architecture-overview">
|
||||
<div class="overview-title">系统架构</div>
|
||||
<div class="architecture-diagram">
|
||||
<div class="layer client">
|
||||
<div class="layer-label">客户端</div>
|
||||
<div class="layer-icon">📱</div>
|
||||
</div>
|
||||
<div class="arrow">↓</div>
|
||||
<div class="layer local-cache" :class="{ hit: currentLevel === 1 }">
|
||||
<div class="layer-label">L1: 本地缓存 (Caffeine)</div>
|
||||
<div class="layer-stats">
|
||||
<div>容量: 1000</div>
|
||||
<div>TTL: 30s</div>
|
||||
<div>命中: {{ localHits }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="arrow">↓</div>
|
||||
<div class="layer redis-cache" :class="{ hit: currentLevel === 2 }">
|
||||
<div class="layer-label">L2: Redis 集群</div>
|
||||
<div class="layer-stats">
|
||||
<div>容量: 100万</div>
|
||||
<div>TTL: 5min</div>
|
||||
<div>命中: {{ redisHits }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="arrow">↓</div>
|
||||
<div class="layer database" :class="{ hit: currentLevel === 3 }">
|
||||
<div class="layer-label">L3: MySQL 数据库</div>
|
||||
<div class="layer-stats">
|
||||
<div>持久化存储</div>
|
||||
<div>查询: {{ dbQueries }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-sections">
|
||||
<div class="section query-demo">
|
||||
<div class="section-title">查询商品</div>
|
||||
<div class="query-controls">
|
||||
<input
|
||||
v-model="productId"
|
||||
type="text"
|
||||
placeholder="输入商品ID (如: P001)"
|
||||
class="product-input"
|
||||
/>
|
||||
<button class="query-btn" @click="queryProduct" :disabled="querying">
|
||||
{{ querying ? '查询中...' : '查询' }}
|
||||
</button>
|
||||
<button class="reset-btn" @click="resetDemo">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="query-result" v-if="queryResult">
|
||||
<div class="result-header">
|
||||
<span class="result-icon">{{ queryResult.icon }}</span>
|
||||
<span class="result-title">{{ queryResult.title }}</span>
|
||||
</div>
|
||||
<div class="result-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">商品ID:</span>
|
||||
<span class="detail-value">{{ queryResult.id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">名称:</span>
|
||||
<span class="detail-value">{{ queryResult.name }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">价格:</span>
|
||||
<span class="detail-value">¥{{ queryResult.price }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">来源:</span>
|
||||
<span
|
||||
class="detail-value source"
|
||||
:class="queryResult.sourceLevel"
|
||||
>
|
||||
{{ queryResult.source }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">响应时间:</span>
|
||||
<span class="detail-value">{{ queryResult.responseTime }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="query-flow" v-if="queryFlow.length > 0">
|
||||
<div class="flow-title">查询流程</div>
|
||||
<div class="flow-steps">
|
||||
<div
|
||||
v-for="(step, index) in queryFlow"
|
||||
:key="index"
|
||||
class="flow-step"
|
||||
:class="step.type"
|
||||
>
|
||||
<div class="step-level">{{ step.level }}</div>
|
||||
<div class="step-result">{{ step.result }}</div>
|
||||
<div class="step-time">{{ step.time }}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section cache-monitor">
|
||||
<div class="section-title">缓存监控</div>
|
||||
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">总请求数</div>
|
||||
<div class="metric-value">{{ metrics.totalRequests }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">本地缓存命中</div>
|
||||
<div class="metric-value local">{{ localHits }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Redis命中</div>
|
||||
<div class="metric-value redis">{{ redisHits }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">数据库查询</div>
|
||||
<div class="metric-value db">{{ dbQueries }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hit-rate-display">
|
||||
<div class="rate-label">整体命中率</div>
|
||||
<div class="rate-value">{{ overallHitRate }}%</div>
|
||||
<div class="rate-bar">
|
||||
<div
|
||||
class="rate-fill"
|
||||
:style="{ width: overallHitRate + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="rate-target">目标: > 90%</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-stats-detail">
|
||||
<div class="stats-title">详细统计</div>
|
||||
<div class="stats-list">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">本地缓存命中率:</span>
|
||||
<span class="stat-value">{{ localHitRate }}%</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Redis缓存命中率:</span>
|
||||
<span class="stat-value">{{ redisHitRate }}%</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">平均响应时间:</span>
|
||||
<span class="stat-value">{{ avgResponseTime }}ms</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">数据库压力:</span>
|
||||
<span class="stat-value">{{ dbPressure }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-title">核心特性</div>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<div class="feature-name">多级缓存</div>
|
||||
<div class="feature-desc">
|
||||
本地缓存 + Redis 双层防护,减少 99% 数据库查询
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🔒</div>
|
||||
<div class="feature-name">防击穿</div>
|
||||
<div class="feature-desc">互斥锁保护热点数据,避免并发查询数据库</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🎯</div>
|
||||
<div class="feature-name">防穿透</div>
|
||||
<div class="feature-desc">缓存空对象,防止查询不存在的商品</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">⏰</div>
|
||||
<div class="feature-name">随机 TTL</div>
|
||||
<div class="feature-desc">避免缓存雪崩,过期时间加随机值</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-preview">
|
||||
<div class="code-title">核心代码片段</div>
|
||||
<pre class="code-block"><code>// 三级缓存查询
|
||||
public Product getProduct(String productId) {
|
||||
// L1: 本地缓存
|
||||
Product product = localCache.getIfPresent(productId);
|
||||
if (product != null) {
|
||||
metrics.localHits++;
|
||||
return product;
|
||||
}
|
||||
|
||||
// L2: Redis 缓存
|
||||
product = redisTemplate.get("product:" + productId);
|
||||
if (product != null) {
|
||||
localCache.put(productId, product); // 回填
|
||||
metrics.redisHits++;
|
||||
return product;
|
||||
}
|
||||
|
||||
// L3: 数据库(加锁防击穿)
|
||||
synchronized(this) {
|
||||
// 双重检查
|
||||
product = redisTemplate.get("product:" + productId);
|
||||
if (product != null) return product;
|
||||
|
||||
// 查数据库
|
||||
product = productMapper.selectById(productId);
|
||||
if (product == null) {
|
||||
// 缓存空对象(防穿透)
|
||||
redisTemplate.set("product:" + productId,
|
||||
NULL_PRODUCT, 5, TimeUnit.MINUTES);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 写入缓存(随机 TTL 防雪崩)
|
||||
int ttl = 300 + ThreadLocalRandom.current().nextInt(-30, 30);
|
||||
redisTemplate.set("product:" + productId, product,
|
||||
ttl, TimeUnit.SECONDS);
|
||||
localCache.put(productId, product);
|
||||
|
||||
metrics.dbQueries++;
|
||||
return product;
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const productId = ref('P001')
|
||||
const querying = ref(false)
|
||||
const queryResult = ref(null)
|
||||
const queryFlow = ref([])
|
||||
const currentLevel = ref(0)
|
||||
|
||||
const localHits = ref(0)
|
||||
const redisHits = ref(0)
|
||||
const dbQueries = ref(0)
|
||||
|
||||
const metrics = ref({
|
||||
totalRequests: 0
|
||||
})
|
||||
|
||||
const products = {
|
||||
P001: { id: 'P001', name: 'iPhone 15 Pro', price: 7999 },
|
||||
P002: { id: 'P002', name: 'MacBook Pro 14"', price: 14999 },
|
||||
P003: { id: 'P003', name: 'AirPods Pro', price: 1999 },
|
||||
P004: { id: 'P004', name: 'iPad Air', price: 4799 }
|
||||
}
|
||||
|
||||
const overallHitRate = computed(() => {
|
||||
const total = metrics.value.totalRequests
|
||||
if (total === 0) return 0
|
||||
const hits = localHits.value + redisHits.value
|
||||
return Math.round((hits / total) * 100)
|
||||
})
|
||||
|
||||
const localHitRate = computed(() => {
|
||||
const total = metrics.value.totalRequests
|
||||
if (total === 0) return 0
|
||||
return Math.round((localHits.value / total) * 100)
|
||||
})
|
||||
|
||||
const redisHitRate = computed(() => {
|
||||
const total = metrics.value.totalRequests
|
||||
if (total === 0) return 0
|
||||
return Math.round((redisHits.value / total) * 100)
|
||||
})
|
||||
|
||||
const avgResponseTime = computed(() => {
|
||||
const total = metrics.value.totalRequests
|
||||
if (total === 0) return 0
|
||||
const totalTime =
|
||||
localHits.value * 1 + redisHits.value * 5 + dbQueries.value * 50
|
||||
return Math.round(totalTime / total)
|
||||
})
|
||||
|
||||
const dbPressure = computed(() => {
|
||||
const total = metrics.value.totalRequests
|
||||
if (total === 0) return 0
|
||||
return Math.round((dbQueries.value / total) * 100)
|
||||
})
|
||||
|
||||
const queryProduct = async () => {
|
||||
if (!productId.value || querying.value) return
|
||||
|
||||
querying.value = true
|
||||
queryFlow.value = []
|
||||
queryResult.value = null
|
||||
currentLevel.value = 0
|
||||
|
||||
const id = productId.value.toUpperCase()
|
||||
const exists = products[id]
|
||||
|
||||
// Simulate cache levels
|
||||
const flow = []
|
||||
|
||||
// Level 1: Local Cache (30% hit rate for demo)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
currentLevel.value = 1
|
||||
const localHit = Math.random() < 0.3
|
||||
flow.push({
|
||||
level: 'L1: 本地缓存',
|
||||
result: localHit ? '✅ 命中' : '❌ 未命中',
|
||||
time: 1,
|
||||
type: localHit ? 'hit' : 'miss'
|
||||
})
|
||||
|
||||
if (localHit && exists) {
|
||||
localHits.value++
|
||||
metrics.value.totalRequests++
|
||||
queryFlow.value = flow
|
||||
queryResult.value = {
|
||||
icon: '⚡',
|
||||
title: '本地缓存命中',
|
||||
id: products[id].id,
|
||||
name: products[id].name,
|
||||
price: products[id].price,
|
||||
source: '本地缓存',
|
||||
sourceLevel: 'local',
|
||||
responseTime: 1
|
||||
}
|
||||
querying.value = false
|
||||
currentLevel.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Level 2: Redis (50% hit rate for demo)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
currentLevel.value = 2
|
||||
const redisHit = !localHit && Math.random() < 0.5 && exists
|
||||
flow.push({
|
||||
level: 'L2: Redis',
|
||||
result: redisHit ? '✅ 命中' : '❌ 未命中',
|
||||
time: 5,
|
||||
type: redisHit ? 'hit' : 'miss'
|
||||
})
|
||||
|
||||
if (redisHit && exists) {
|
||||
redisHits.value++
|
||||
metrics.value.totalRequests++
|
||||
queryFlow.value = flow
|
||||
queryResult.value = {
|
||||
icon: '🚀',
|
||||
title: 'Redis缓存命中',
|
||||
id: products[id].id,
|
||||
name: products[id].name,
|
||||
price: products[id].price,
|
||||
source: 'Redis缓存',
|
||||
sourceLevel: 'redis',
|
||||
responseTime: 6
|
||||
}
|
||||
querying.value = false
|
||||
currentLevel.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Level 3: Database
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
currentLevel.value = 3
|
||||
flow.push({
|
||||
level: 'L3: 数据库',
|
||||
result: exists ? '✅ 查询成功' : '❌ 商品不存在',
|
||||
time: 50,
|
||||
type: exists ? 'hit' : 'miss'
|
||||
})
|
||||
|
||||
dbQueries.value++
|
||||
metrics.value.totalRequests++
|
||||
queryFlow.value = flow
|
||||
|
||||
if (exists) {
|
||||
queryResult.value = {
|
||||
icon: '🗄️',
|
||||
title: '数据库查询',
|
||||
id: products[id].id,
|
||||
name: products[id].name,
|
||||
price: products[id].price,
|
||||
source: 'MySQL 数据库',
|
||||
sourceLevel: 'database',
|
||||
responseTime: 56
|
||||
}
|
||||
} else {
|
||||
queryResult.value = {
|
||||
icon: '❌',
|
||||
title: '商品不存在',
|
||||
id: id,
|
||||
name: '-',
|
||||
price: '-',
|
||||
source: '缓存空对象',
|
||||
sourceLevel: 'notfound',
|
||||
responseTime: 56
|
||||
}
|
||||
}
|
||||
|
||||
querying.value = false
|
||||
currentLevel.value = 0
|
||||
}
|
||||
|
||||
const resetDemo = () => {
|
||||
productId.value = 'P001'
|
||||
queryResult.value = null
|
||||
queryFlow.value = []
|
||||
localHits.value = 0
|
||||
redisHits.value = 0
|
||||
dbQueries.value = 0
|
||||
metrics.value.totalRequests = 0
|
||||
currentLevel.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-cache-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.architecture-overview {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.overview-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.architecture-diagram {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.layer {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.layer.hit {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.layer.client {
|
||||
background: #f3e8ff;
|
||||
border-color: #a855f7;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.layer.local-cache {
|
||||
background: #dbeafe;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.layer.local-cache.hit {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.layer.redis-cache {
|
||||
background: #fef3c7;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.layer.redis-cache.hit {
|
||||
background: #fef9c3;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.layer.database {
|
||||
background: #fee2e2;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.layer.database.hit {
|
||||
background: #fef2f2;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.layer-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.layer-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.demo-sections {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.demo-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.query-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.product-input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.query-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.query-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.query-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.query-result {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.result-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-value.source.local {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.detail-value.source.redis {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.detail-value.source.database {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.detail-value.source.notfound {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.query-flow {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 60px;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.flow-step.hit {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.flow-step.miss {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.step-level {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-result {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step-time {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-value.local {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.metric-value.redis {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.metric-value.db {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.hit-rate-display {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.rate-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.rate-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.rate-bar {
|
||||
height: 12px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.rate-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #22c55e, #16a34a);
|
||||
transition: width 0.5s;
|
||||
}
|
||||
|
||||
.rate-target {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.cache-stats-detail {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stats-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.features {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.feature-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.code-preview {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user