49-缓存一致性详解

缓存一致性详解

本章导读

缓存一致性是分布式系统中最棘手的问题之一。当数据同时存在于缓存和数据库时,如何保证两者的一致性?本章将深入分析 Cache Aside、延时双删、Canal 监听 binlog 等主流方案,帮助你做出正确的技术选型。

学习目标

  • 目标1:理解缓存一致性问题的根源,掌握不同一致性等级的适用场景
  • 目标2:掌握 Cache Aside 模式的读写流程,理解"删除优于更新"的设计思想
  • 目标3:能够实现延时双删和 Canal binlog 监听方案,保证最终一致性

前置知识:已完成《缓存设计详解》的学习,理解缓存穿透、雪崩、击穿问题

阅读时长:约 35 分钟

一、知识概述

缓存一致性是分布式系统中经典且棘手的问题。当数据同时存在于缓存(如Redis)和数据库(如MySQL)时,如何保证两者的数据一致性?这个问题在高并发场景下尤为复杂。

本文将深入分析缓存一致性的各种方案,包括:

  • 缓存更新策略对比
  • 双写一致性问题的根源
  • 主流解决方案及其优缺点
  • 延时双删方案
  • Canal监听binlog方案
  • 最终一致性的工程实践

二、问题根源分析

2.1 为什么会有一致性问题?

markdown 复制代码
┌─────────┐     读/写      ┌─────────┐
│  应用   │ ──────────────→│  缓存   │
└─────────┘                └─────────┘
     │
     │ 写
     ↓
┌─────────┐
│ 数据库  │
└─────────┘

问题场景:

场景1:先更新缓存,再更新数据库

makefile 复制代码
Thread1: 更新缓存(A→B)
Thread2: 更新缓存(A→C)
Thread2: 更新数据库(A→C)
Thread1: 更新数据库(A→B)  ← 数据库最终是B,缓存是C,不一致!

场景2:先更新数据库,再更新缓存

makefile 复制代码
Thread1: 更新数据库(A→B)
Thread2: 更新数据库(A→C)
Thread2: 更新缓存(A→C)
Thread1: 更新缓存(A→B)  ← 缓存最终是B,数据库是C,不一致!

场景3:先删除缓存,再更新数据库

makefile 复制代码
Thread1: 删除缓存(A)
Thread2: 读缓存miss,读数据库(A)
Thread1: 更新数据库(A→B)
Thread2: 写缓存(A)  ← 缓存又被写回旧值!

2.2 一致性等级

等级 说明 适用场景
强一致性 写后读一定能读到最新值 银行转账、库存扣减
最终一致性 一定时间后达到一致 社交动态、商品信息
弱一致性 不保证最终一致 新闻阅读数、点赞数

三、缓存更新策略

3.1 四种策略对比

策略 并发问题 一致性 复杂度 适用场景
更新缓存 + 更新数据库 不推荐
更新数据库 + 更新缓存 不推荐
删除缓存 + 更新数据库 读少写多
更新数据库 + 删除缓存 推荐

3.2 为什么删除优于更新?

java 复制代码
// 更新缓存的问题
public void update(String key, Object value) {
    db.update(key, value);      // 数据库更新
    cache.set(key, value);      // 缓存更新
    // 问题:如果value计算复杂,每次更新都重算浪费资源
    // 问题:并发时可能覆盖其他线程的更新
}

// 删除缓存的优势
public void update(String key, Object value) {
    db.update(key, value);      // 数据库更新
    cache.delete(key);          // 缓存删除
    // 优势:惰性计算,读时再加载
    // 优势:避免并发覆盖问题
}

四、主流解决方案

4.1 Cache Aside Pattern(旁路缓存)

读流程:

markdown 复制代码
1. 先读缓存
2. 缓存命中 → 返回
3. 缓存未命中 → 读数据库 → 写入缓存 → 返回

写流程:

markdown 复制代码
1. 先更新数据库
2. 再删除缓存

代码示例:

java 复制代码
@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private RedisTemplate<String, User> redisTemplate;
    
    private static final String CACHE_PREFIX = "user:";
    
    /**
     * 读取用户信息
     */
    public User getUser(Long userId) {
        String key = CACHE_PREFIX + userId;
        
        // 1. 先读缓存
        User user = redisTemplate.opsForValue().get(key);
        if (user != null) {
            return user;
        }
        
        // 2. 缓存未命中,读数据库
        user = userMapper.selectById(userId);
        
        // 3. 写入缓存(设置过期时间,防止脏数据)
        if (user != null) {
            redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
        }
        
        return user;
    }
    
    /**
     * 更新用户信息 - Cache Aside Pattern
     */
    @Transactional
    public void updateUser(User user) {
        // 1. 先更新数据库
        userMapper.updateById(user);
        
        // 2. 再删除缓存
        String key = CACHE_PREFIX + user.getId();
        redisTemplate.delete(key);
    }
}

问题:极端并发下仍可能不一致

makefile 复制代码
Thread1: 读缓存miss
Thread1: 读数据库(旧值)
Thread2: 更新数据库
Thread2: 删除缓存
Thread1: 写缓存(旧值)  ← 脏数据产生!

分析:这种概率极低,需要同时满足:

  1. 缓存刚好失效
  2. 读请求查询数据库(耗时)
  3. 写请求更新数据库(更快)
  4. 写请求删除缓存
  5. 读请求写入缓存(比写请求慢)

4.2 延时双删策略

核心思想: 删除 → 更新数据库 → 延时后再删除

java 复制代码
@Service
public class ProductService {
    
    @Autowired
    private ProductMapper productMapper;
    
    @Autowired
    private RedisTemplate<String, Product> redisTemplate;
    
    private static final String CACHE_PREFIX = "product:";
    private static final long DELAY_MS = 500; // 延时时间
    
    /**
     * 延时双删策略
     */
    @Transactional
    public void updateProduct(Product product) {
        String key = CACHE_PREFIX + product.getId();
        
        // 1. 第一次删除缓存
        redisTemplate.delete(key);
        
        // 2. 更新数据库
        productMapper.updateById(product);
        
        // 3. 延时后再次删除(异步执行)
        CompletableFuture.runAsync(() -> {
            try {
                Thread.sleep(DELAY_MS);
                redisTemplate.delete(key);
                log.info("延时双删执行完成, key={}", key);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

延时时间设置:

diff 复制代码
延时时间 > 读请求的耗时 + 几十毫秒缓冲

读请求耗时估算:
- 数据库查询:10-50ms
- 网络传输:5-20ms
- 对象序列化:1-5ms
- 总计:约20-100ms

建议延时时间:200ms-500ms

进阶版:基于消息队列的可靠延时双删

java 复制代码
@Service
public class ProductSyncService {
    
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String DELAY_EXCHANGE = "cache.delay.exchange";
    private static final String DELAY_ROUTING_KEY = "cache.delete";
    
    /**
     * 发送延时删除消息
     */
    public void sendDelayDelete(String cacheKey, long delayMs) {
        CacheDeleteMessage message = new CacheDeleteMessage(cacheKey, System.currentTimeMillis());
        
        rabbitTemplate.convertAndSend(DELAY_EXCHANGE, DELAY_ROUTING_KEY, message, msg -> {
            // 设置延时时间(RabbitMQ延时插件)
            msg.getMessageProperties().setDelay((int) delayMs);
            return msg;
        });
    }
    
    /**
     * 消费延时删除消息
     */
    @RabbitListener(queues = "cache.delete.queue")
    public void handleCacheDelete(CacheDeleteMessage message) {
        redisTemplate.delete(message.getCacheKey());
        log.info("延时删除缓存成功: key={}", message.getCacheKey());
    }
}

@Data
@AllArgsConstructor
class CacheDeleteMessage {
    private String cacheKey;
    private Long timestamp;
}

4.3 基于Canal的binlog监听方案

架构设计:

markdown 复制代码
┌──────────┐    写     ┌──────────┐
│  应用    │ ────────→ │  MySQL   │
└──────────┘           └──────────┘
                            │
                            │ binlog
                            ↓
                       ┌──────────┐
                       │  Canal   │
                       └──────────┘
                            │
                            │ 解析事件
                            ↓
                       ┌──────────┐
                       │  Redis   │
                       └──────────┘

Canal配置:

yaml 复制代码
# canal.properties
canal.serverMode = tcp
canal.destinations = example

# instance.properties
canal.instance.master.address = 127.0.0.1:3306
canal.instance.dbUsername = canal
canal.instance.dbPassword = canal
canal.instance.filter.regex = .*\\..*

Canal客户端实现:

java 复制代码
@Component
public class CanalClient implements InitializingBean, DisposableBean {
    
    private CanalConnector connector;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Value("${canal.server:127.0.0.1:11111}")
    private String canalServer;
    
    @Value("${canal.destination:example}")
    private String destination;
    
    private volatile boolean running = true;
    
    @Override
    public void afterPropertiesSet() {
        // 创建连接
        connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress(canalServer.split(":")[0], 
                Integer.parseInt(canalServer.split(":")[1])),
            destination, "", ""
        );
        
        // 启动消费线程
        new Thread(this::consumeBinlog, "canal-consumer").start();
    }
    
    private void consumeBinlog() {
        while (running) {
            try {
                connector.connect();
                connector.subscribe(".*\\..*");
                
                while (running) {
                    Message message = connector.getWithoutAck(100);
                    long batchId = message.getId();
                    
                    if (batchId != -1 && !message.getEntries().isEmpty()) {
                        processEntries(message.getEntries());
                    }
                    
                    connector.ack(batchId);
                }
            } catch (Exception e) {
                log.error("Canal消费异常", e);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
            }
        }
    }
    
    private void processEntries(List<CanalEntry.Entry> entries) {
        for (CanalEntry.Entry entry : entries) {
            if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
                continue;
            }
            
            try {
                CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                String tableName = entry.getHeader().getTableName();
                CanalEntry.EventType eventType = rowChange.getEventType();
                
                for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                    processRowChange(tableName, eventType, rowData);
                }
            } catch (Exception e) {
                log.error("解析binlog异常", e);
            }
        }
    }
    
    private void processRowChange(String tableName, CanalEntry.EventType eventType, 
                                   CanalEntry.RowData rowData) {
        // 根据表名映射缓存key前缀
        String cachePrefix = getCachePrefix(tableName);
        if (cachePrefix == null) {
            return;
        }
        
        switch (eventType) {
            case INSERT:
            case UPDATE:
                // 获取主键值
                String id = getColumnValue(rowData.getAfterColumnsList(), "id");
                String cacheKey = cachePrefix + id;
                // 删除缓存,让应用重新加载
                redisTemplate.delete(cacheKey);
                log.info("缓存删除: key={}, event={}", cacheKey, eventType);
                break;
                
            case DELETE:
                String deleteId = getColumnValue(rowData.getBeforeColumnsList(), "id");
                String deleteKey = cachePrefix + deleteId;
                redisTemplate.delete(deleteKey);
                log.info("缓存删除: key={}, event=DELETE", deleteKey);
                break;
                
            default:
                break;
        }
    }
    
    private String getCachePrefix(String tableName) {
        // 表名与缓存key的映射
        Map<String, String> mapping = Map.of(
            "t_user", "user:",
            "t_product", "product:",
            "t_order", "order:"
        );
        return mapping.get(tableName);
    }
    
    private String getColumnValue(List<CanalEntry.Column> columns, String columnName) {
        return columns.stream()
            .filter(col -> col.getName().equals(columnName))
            .findFirst()
            .map(CanalEntry.Column::getValue)
            .orElse(null);
    }
    
    @Override
    public void destroy() {
        running = false;
        if (connector != null) {
            connector.disconnect();
        }
    }
}

Canal方案优势:

  • 应用代码无需关心缓存删除
  • 解耦数据写入和缓存更新
  • 保证数据库与缓存的最终一致性
  • 支持多种数据库变更场景

4.4 订阅数据库变更(Spring事件)

java 复制代码
// 事件定义
public class DataChangeEvent extends ApplicationEvent {
    private final String tableName;
    private final Long id;
    private final OperationType operationType;
    
    public DataChangeEvent(Object source, String tableName, Long id, OperationType operationType) {
        super(source);
        this.tableName = tableName;
        this.id = id;
        this.operationType = operationType;
    }
    
    public enum OperationType {
        INSERT, UPDATE, DELETE
    }
}

// 事件发布
@Service
public class UserService {
    
    @Autowired
    private ApplicationEventPublisher eventPublisher;
    
    @Autowired
    private UserMapper userMapper;
    
    @Transactional
    public void updateUser(User user) {
        userMapper.updateById(user);
        // 发布数据变更事件
        eventPublisher.publishEvent(
            new DataChangeEvent(this, "t_user", user.getId(), OperationType.UPDATE)
        );
    }
}

// 事件监听 - 异步删除缓存
@Component
public class CacheInvalidationListener {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final Map<String, String> TABLE_CACHE_MAPPING = Map.of(
        "t_user", "user:",
        "t_product", "product:"
    );
    
    @Async
    @EventListener
    public void handleDataChange(DataChangeEvent event) {
        String cachePrefix = TABLE_CACHE_MAPPING.get(event.getTableName());
        if (cachePrefix != null) {
            String cacheKey = cachePrefix + event.getId();
            redisTemplate.delete(cacheKey);
            log.info("缓存失效: key={}", cacheKey);
        }
    }
}

五、最终一致性最佳实践

5.1 完整方案示例

java 复制代码
@Service
@Slf4j
public class ProductCacheService {
    
    @Autowired
    private ProductMapper productMapper;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    private static final String CACHE_PREFIX = "product:";
    private static final String LOCK_PREFIX = "lock:product:";
    private static final long CACHE_EXPIRE_HOURS = 2;
    private static final long DELAY_DELETE_MS = 500;
    
    /**
     * 查询商品 - 防止缓存穿透
     */
    public Product getProduct(Long productId) {
        String key = CACHE_PREFIX + productId;
        
        // 1. 查询缓存
        Object cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            if (cached instanceof NullValue) {
                // 空值标记,防止穿透
                return null;
            }
            return (Product) cached;
        }
        
        // 2. 分布式锁防止缓存击穿
        String lockKey = LOCK_PREFIX + productId;
        String lockValue = UUID.randomUUID().toString();
        
        try {
            Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
            
            if (Boolean.TRUE.equals(locked)) {
                try {
                    // 3. 查询数据库
                    Product product = productMapper.selectById(productId);
                    
                    // 4. 写入缓存
                    if (product != null) {
                        redisTemplate.opsForValue().set(
                            key, product, CACHE_EXPIRE_HOURS, TimeUnit.HOURS
                        );
                    } else {
                        // 空值标记,防止穿透(过期时间短一些)
                        redisTemplate.opsForValue().set(
                            key, NullValue.INSTANCE, 5, TimeUnit.MINUTES
                        );
                    }
                    
                    return product;
                } finally {
                    // 释放锁(Lua脚本保证原子性)
                    releaseLock(lockKey, lockValue);
                }
            } else {
                // 获取锁失败,等待后重试
                Thread.sleep(50);
                return getProduct(productId); // 递归重试
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取商品信息被中断", e);
        }
    }
    
    /**
     * 更新商品 - 延时双删 + 消息队列
     */
    @Transactional
    public void updateProduct(Product product) {
        String key = CACHE_PREFIX + product.getId();
        
        // 1. 第一次删除缓存
        redisTemplate.delete(key);
        
        // 2. 更新数据库
        productMapper.updateById(product);
        
        // 3. 发送延时删除消息
        sendDelayDeleteMessage(key, DELAY_DELETE_MS);
    }
    
    /**
     * 发送延时删除消息
     */
    private void sendDelayDeleteMessage(String cacheKey, long delayMs) {
        Map<String, Object> message = Map.of(
            "cacheKey", cacheKey,
            "timestamp", System.currentTimeMillis()
        );
        
        rabbitTemplate.convertAndSend(
            "cache.delay.exchange",
            "cache.delete",
            message,
            msg -> {
                msg.getMessageProperties().setDelay((int) delayMs);
                return msg;
            }
        );
    }
    
    /**
     * 释放分布式锁
     */
    private void releaseLock(String lockKey, String lockValue) {
        String script = """
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
            """;
        
        redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(lockKey),
            lockValue
        );
    }
    
    /**
     * 空值标记(防止缓存穿透)
     */
    private enum NullValue {
        INSTANCE
    }
}

5.2 消息队列配置

java 复制代码
@Configuration
public class RabbitMQConfig {
    
    // 延时交换机(需要安装rabbitmq_delayed_message_exchange插件)
    @Bean
    public CustomExchange delayExchange() {
        Map<String, Object> args = new HashMap<>();
        args.put("x-delayed-type", "direct");
        return new CustomExchange(
            "cache.delay.exchange",
            "x-delayed-message",
            true,
            false,
            args
        );
    }
    
    // 延时队列
    @Bean
    public Queue delayQueue() {
        return QueueBuilder.durable("cache.delete.queue").build();
    }
    
    // 绑定关系
    @Bean
    public Binding delayBinding() {
        return BindingBuilder.bind(delayQueue())
            .to(delayExchange())
            .with("cache.delete")
            .noargs();
    }
}

// 消费者
@Component
@Slf4j
public class CacheDeleteConsumer {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @RabbitListener(queues = "cache.delete.queue")
    public void handleCacheDelete(Map<String, Object> message) {
        String cacheKey = (String) message.get("cacheKey");
        Long timestamp = (Long) message.get("timestamp");
        
        redisTemplate.delete(cacheKey);
        
        log.info("延时删除缓存: key={}, delay={}ms", 
            cacheKey, System.currentTimeMillis() - timestamp);
    }
}

六、缓存三大问题回顾

6.1 缓存穿透

问题: 查询不存在的数据,请求穿透到数据库

解决方案:

java 复制代码
// 1. 空值缓存
if (product == null) {
    redisTemplate.opsForValue().set(key, NullValue.INSTANCE, 5, TimeUnit.MINUTES);
}

// 2. 布隆过滤器
@Component
public class BloomFilterService {
    
    private BloomFilter<Long> productBloomFilter;
    
    @PostConstruct
    public void init() {
        // 预期元素数量100万,误判率0.01%
        productBloomFilter = BloomFilter.create(
            Funnels.longFunnel(),
            1_000_000,
            0.0001
        );
        
        // 初始化加载所有商品ID
        List<Long> productIds = productMapper.selectAllIds();
        productIds.forEach(productBloomFilter::put);
    }
    
    public boolean mightContain(Long productId) {
        return productBloomFilter.mightContain(productId);
    }
}

6.2 缓存击穿

问题: 热点key过期瞬间,大量请求击穿到数据库

解决方案: 分布式锁 + 互斥更新(见上文代码)

6.3 缓存雪崩

问题: 大量key同时过期,或Redis宕机

解决方案:

java 复制代码
// 1. 过期时间随机化
Random random = new Random();
long expire = CACHE_EXPIRE_HOURS + random.nextInt(30); // 2小时 + 随机分钟
redisTemplate.opsForValue().set(key, value, expire, TimeUnit.MINUTES);

// 2. 多级缓存
@Service
public class MultiLevelCacheService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private final Cache<String, Object> localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();
    
    public Object get(String key) {
        // L1: 本地缓存
        Object value = localCache.getIfPresent(key);
        if (value != null) {
            return value;
        }
        
        // L2: Redis缓存
        value = redisTemplate.opsForValue().get(key);
        if (value != null) {
            localCache.put(key, value);
            return value;
        }
        
        // L3: 数据库
        value = loadFromDB(key);
        if (value != null) {
            localCache.put(key, value);
            redisTemplate.opsForValue().set(key, value);
        }
        
        return value;
    }
}

// 3. 熔断降级
@HystrixCommand(
    fallbackMethod = "getProductFallback",
    commandProperties = {
        @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
        @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50")
    }
)
public Product getProductWithFallback(Long productId) {
    return getProduct(productId);
}

public Product getProductFallback(Long productId) {
    // 降级:返回默认值或错误提示
    return Product.defaultProduct();
}

七、总结与最佳实践

7.1 方案选择指南

场景 推荐方案 理由
低并发、强一致要求 分布式锁 + 双写 保证一致性
高并发、最终一致 Cache Aside + 延时双删 性能好,最终一致
超高并发、解耦要求 Canal监听binlog 完全解耦,可靠性高
读多写少 缓存预热 + 延长过期 减少缓存失效

7.2 核心原则

  1. 先更新数据库,再删除缓存(推荐)
  2. 设置合理的缓存过期时间,避免雪崩
  3. 空值也要缓存,防止穿透
  4. 热点key加分布式锁,防止击穿
  5. 关键业务增加binlog监听,保证最终一致性
  6. 监控缓存命中率,及时发现问题

7.3 监控指标

java 复制代码
@Component
public class CacheMetrics {
    
    private final AtomicLong hitCount = new AtomicLong(0);
    private final AtomicLong missCount = new AtomicLong(0);
    
    public void recordHit() {
        hitCount.incrementAndGet();
    }
    
    public void recordMiss() {
        missCount.incrementAndGet();
    }
    
    @Scheduled(fixedRate = 60000)
    public void reportMetrics() {
        long hit = hitCount.getAndSet(0);
        long miss = missCount.getAndSet(0);
        long total = hit + miss;
        
        if (total > 0) {
            double hitRate = (double) hit / total * 100;
            log.info("缓存命中率: {:.2f}% ({}/{})", hitRate, hit, total);
        }
    }
}

缓存一致性是分布式系统的经典难题,没有完美的方案,只有最适合的方案。在实际应用中,需要根据业务特点、并发量、一致性要求综合考虑,选择合适的策略组合使用。 **:《Redis高级应用详解》- 学习 Redis 的更多高级特性

  • 扩展阅读:《数据密集型应用系统设计》一致性章节

📝 下一章预告

下一章将探索 Redis 的高级应用,包括发布订阅、Lua 脚本、Redlock 分布式锁、RediSearch 全文搜索等进阶主题,帮助你更深入地使用 Redis。


本章完

相关推荐
青槿吖2 小时前
Sentinel 进阶实战:Feign 整合 + 全局异常 + Nacos 持久化,生产环境直接用
java·开发语言·spring cloud·微服务·云原生·ribbon·sentinel
穿条秋裤到处跑2 小时前
每日一道leetcode(2026.04.21):执行交换操作后的最小汉明距离
java·算法·leetcode
疯狂打码的少年2 小时前
内存管理三雄对决:C、Java、Python 的堆区、栈区、常量区、静态区深度解析
java·c语言·python
Seven972 小时前
Tomcat组件管理源码详解
java
AI技术社区2 小时前
Claude Code源码分析之提示词工程
java·开发语言·ai·ai编程
StackNoOverflow2 小时前
Sentinel服务保护框架完全指南:从原理到实践
java·数据库·sentinel
敖正炀2 小时前
阻塞队列-0-2-全景分析
java
Leo8992 小时前
mysql从零单排之快照读与当前读
后端
Leo8992 小时前
mysql从零单排之B+与AHI
后端