ElIauk

高并发系统设计:读多写少

读多写少的瓶颈到底在哪 典型的内容型业务读写比可能到 100:1,甚至 1000:1。这时候数据库的 CPU 大部分花在重复执行同一条查询、重复做同样的行解析上,缓存的价值就是把“重复”消掉。 但缓存收益不是线性的。命中率 95% 意味着 5% 的请求打到数据库,命中率 99% 只剩 1%——命中率

评论
高并发系统设计:读多写少

读多写少的瓶颈到底在哪

典型的内容型业务读写比可能到 100:1,甚至 1000:1。这时候数据库的 CPU 大部分花在重复执行同一条查询、重复做同样的行解析上,缓存的价值就是把“重复”消掉。

但缓存收益不是线性的。命中率 95% 意味着 5% 的请求打到数据库,命中率 99% 只剩 1%——命中率每提升一个百分点,数据库压力下降的比例远大于 1%。很多团队扩容数据库之前没看命中率,结果加了两台从库,压力还是下不来,因为穿透的那部分请求本身就很重。

另一个容易被忽略的瓶颈是连接数。读请求再多,数据库能同时处理的连接是有限的,缓存真正的贡献是把并发连接需求降下来。

缓存不是一层,是三层

单机 Caffeine 加上 Redis,再后面才是数据库,这个组合几乎是读多写少的标配:

层 命中耗时 容量 失效方式
Caffeine(进程内) 百纳秒级 受堆限制,通常几万条 本地淘汰 + 广播失效
Redis(集中式) 亚毫秒级 大,按内存规划 删除 + 过期
MySQL 毫秒到几十毫秒 全量 事务保证

本地缓存解决的是热点读:一个爆款商品的详情页每秒几万次访问,全部打到 Redis 也会把单分片打满,放在进程内就只剩一次网络往返。代价是一致性——更新后必须让所有实例的本地副本失效,常见做法是用 Redis 的 pub/sub 或一条 MQ 广播消息。

一致性的两个来源:缓存与主从

读多写少的系统里,“数据不一致”几乎只有两个来源:缓存没被正确失效,或者主从延迟还没追平。两者要分开处理,混在一起讨论很容易互相掩盖。

缓存侧:先写库再删缓存

更新时先写库再删缓存,是最容易落地也最不容易出错的顺序。反过来“先删缓存再写库”,在删除和写库之间有一个读请求进来,会把旧值重新加载进缓存,而且这次脏数据会一直留到过期。

即便如此,仍有极小概率出现旧值回填:读请求在写库前读到旧值,在删缓存后才写回缓存。工程上通常用两种办法兜:给缓存加一个较短的过期时间作为最终兜底;或者用 binlog 订阅(Canal 之类)在数据变更后异步删缓存,把“删”这一步从业务代码里彻底解耦。

主从侧:写后立读怎么办

加了从库之后,“刚下单就查不到”会变成高频投诉。根因是主从复制延迟:写走主库、读走从库,从库还没追上。

处理方式有三种,按代价从低到高:一是写操作后的一段时间内(比如 1 秒)把该用户的读强制路由到主库;二是给请求加 master=true 的标记,由业务显式声明“这次必须读主”;三是把“写后立读”的场景改成写操作直接返回结果,不再回查。第三种最彻底,也最难改,通常只在核心链路做。

两级缓存的骨架

下面这段代码用 Caffeine 的 get(key, mappingFunction) 做单飞加载:同一个 key 并发未命中时,只有一个线程去回源,其余线程等它的结果。这正好挡住了缓存击穿。

@Service
public class ProductCacheService {

    private static final Logger log = LoggerFactory.getLogger(ProductCacheService.class);
    private static final String KEY_PREFIX = "product:detail:";
    private static final String EMPTY_MARK = "__NULL__";
    private static final Duration REDIS_TTL = Duration.ofMinutes(30);

    private final Cache<String, Optional<ProductDetail>> localCache = Caffeine.newBuilder()
            .maximumSize(50_000)
            .expireAfterWrite(Duration.ofMinutes(3))
            .recordStats()
            .build();

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
    private final ProductRepository productRepository;

    public ProductCacheService(StringRedisTemplate redisTemplate,
                               ObjectMapper objectMapper,
                               ProductRepository productRepository) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
        this.productRepository = productRepository;
    }

    public Optional<ProductDetail> get(Long productId) {
        Objects.requireNonNull(productId, "productId 不能为空");
        String key = KEY_PREFIX + productId;
        return localCache.get(key, this::loadThrough);
    }

    private Optional<ProductDetail> loadThrough(String key) {
        String json = redisTemplate.opsForValue().get(key);
        if (EMPTY_MARK.equals(json)) {
            return Optional.empty();
        }
        if (json != null) {
            try {
                return Optional.of(objectMapper.readValue(json, ProductDetail.class));
            } catch (JsonProcessingException e) {
                log.error("商品缓存反序列化失败, 删除脏数据后回源, key={}", key, e);
                redisTemplate.delete(key);
            }
        }

        Long productId = Long.valueOf(key.substring(KEY_PREFIX.length()));
        ProductDetail detail = productRepository.selectById(productId);
        if (detail == null) {
            // 空值也缓存,防止不存在的 ID 反复穿透到数据库
            redisTemplate.opsForValue().set(key, EMPTY_MARK, Duration.ofSeconds(60));
            return Optional.empty();
        }
        redisTemplate.opsForValue().set(key, writeJson(detail), REDIS_TTL);
        return Optional.of(detail);
    }

    public void evict(Long productId) {
        String key = KEY_PREFIX + productId;
        redisTemplate.delete(key);
        localCache.invalidate(key);
        redisTemplate.convertAndSend("cache:evict:product", String.valueOf(productId));
    }

    private String writeJson(ProductDetail detail) {
        try {
            return objectMapper.writeValueAsString(detail);
        } catch (JsonProcessingException e) {
            throw new IllegalStateException("商品缓存序列化失败, productId=" + detail.id(), e);
        }
    }
}

所有实例订阅 cache:evict:product 频道,收到消息就 localCache.invalidate。注意这条广播是“尽力而为”的:消息丢了、实例重启期间没收到,本地仍可能留着旧值,所以本地缓存的 TTL 必须存在,不能指望广播百分百送达。

排查步骤

缓存相关的问题,先量四个数,再动手改代码:

  1. 命中率:Caffeine 的 hitRate()、Redis 的 keyspace_hits / (hits + misses)。
  2. 平均回源耗时:如果回源本身就是 50ms,再高的命中率也救不了 P99。
  3. 大 key 与热 key:redis-cli --bigkeys、redis-cli --hotkeys(需 LFU 策略)。
  4. 本地缓存与 Redis 的 TTL 是否匹配业务对陈旧度的容忍度。
redis-cli -h 10.0.3.21 info stats | grep -E 'keyspace_hits|keyspace_misses'
redis-cli -h 10.0.3.21 --bigkeys
redis-cli -h 10.0.3.21 --hotkeys

常见坑

缓存 null 不设短过期。 防穿透是防住了,但商品新增之后要等到过期才能查到,运营会以为数据没保存成功。

本地缓存没有失效通道。 更新完只有当前实例是对的,其他实例继续返回旧值,灰度发布期间尤其明显。

把缓存当成数据库用。 只写 Redis 不落地,Redis 一重启数据全没。缓存永远是可重建的副本,不能是唯一副本。

TTL 全部设成同一个值。 同一时刻批量失效,请求瞬间全部回源,缓存雪崩往往就是这么来的,加 10%–20% 的随机抖动就能化解。

只关注读,不关注写放大。 更新时删缓存、删本地副本、发广播,一次写触发三类操作,写多的场景反而要控制失效风暴。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者