4. 缓存一致性问题
在分布式系统中引入缓存,本质上是将一份数据在多个位置(数据库与缓存)保存副本。当数据发生更新时,若未能同步更新所有副本,就会产生缓存一致性问题。对于 gRPC 微服务架构,服务间通过 RPC 调用频繁读写缓存,一致性问题的复杂度会进一步放大。
4.1 什么是缓存一致性
缓存一致性是指:缓存中的数据与后端数据源(如数据库)中的数据保持逻辑等价。当数据源发生写操作后,缓存要么立即失效,要么更新为新值,确保后续读取操作不会拿到过时的数据。
核心原则: 缓存一致性不是要求缓存与数据库“时刻完全相同”,而是要求系统对外呈现的读取结果符合业务定义的最终一致性或强一致性级别。
在 gRPC 服务中,缓存一致性通常涉及以下三个角色:
| 角色 | 说明 | 一致性关注点 |
|---|---|---|
| 数据源 | 持久化存储(MySQL、PostgreSQL 等) | 写入成功后必须保证数据不丢失 |
| 缓存层 | Redis、Memcached 或本地内存 | 缓存值必须与数据源最新值对齐 |
| gRPC 服务 | 通过 protobuf 契约定义读写接口 | 同一份数据可能被多个服务实例并发读写 |
4.2 数据不一致的典型场景
在实际生产环境中,缓存不一致主要由并发写和更新顺序错乱引发。以下是两种最常见的场景:
4.2.1 更新丢失
定义:两个或以上的写操作并发执行,后写入的结果覆盖了先写入的结果,导致先写入的数据永久丢失。
典型后果: 用户 A 将商品价格改为 100 元,用户 B 随后改为 80 元,但最终缓存中可能错误地保留了 100 元(或数据库与缓存各存不同值)。
更新丢失的典型时序:
- gRPC 服务实例 1 读取缓存中的商品价格(50 元)。
- gRPC 服务实例 2 也读取同一缓存(50 元)。
- 实例 1 计算新价格 100 元,写入数据库成功,并更新缓存为 100 元。
- 实例 2 基于旧值 50 元计算新价格 80 元,写入数据库成功,并更新缓存为 80 元。
- 结果:实例 1 的更新(100 元)被实例 2 的更新(80 元)覆盖,数据库和缓存中均丢失了 100 元这个正确值。
4.2.2 脏读
定义:一个事务或操作读取到了另一个未提交或已回滚的中间数据,即读取到了“脏数据”。
典型后果: 用户下单时读取到商品库存为 10 件(实际数据库已扣减但事务未提交),导致超卖或显示错误库存。
脏读的典型时序:
- gRPC 服务 A 更新数据库中的库存从 100 改为 90(事务未提交)。
- gRPC 服务 A 将缓存中的库存更新为 90。
- gRPC 服务 B 读取缓存,拿到库存 90。
- gRPC 服务 A 的事务回滚,数据库库存恢复为 100。
- 结果:缓存中仍为 90,后续所有读取都会看到错误的库存值(脏数据)。
避坑建议: 在 gRPC 服务中,应遵循“先更新数据库,再失效缓存”的策略,避免在事务未提交前操作缓存。对于强一致性要求的数据,可考虑使用分布式锁或乐观锁。
4.3 不一致场景对比
| 场景 | 根本原因 | 影响范围 | 典型修复策略 |
|---|---|---|---|
| 更新丢失 | 并发写操作基于旧值计算新值 | 数据永久丢失 | 乐观锁(CAS)、分布式锁 |
| 脏读 | 读取到未提交的中间数据 | 数据临时或永久错误 | 先更新 DB 再失效缓存、事务隔离 |
4.4 为什么 gRPC 场景下更需关注
在 gRPC 微服务架构中,缓存一致性问题的复杂度会进一步增加:
- 多实例并发:同一个 gRPC 服务通常部署多个实例,它们共享同一个缓存层,并发冲突概率更高。
- protobuf 序列化:缓存中存储的是 protobuf 序列化后的二进制数据,字段变更可能导致反序列化失败或数据错位。
- 跨服务调用:一个 gRPC 请求可能经过多个服务,每个服务都有自己的缓存,数据一致性需要端到端保障。
本章小结: 缓存一致性是分布式缓存系统的核心挑战。更新丢失和脏读是最常见的两类不一致场景,理解它们的产生时序是后续设计一致性策略的基础。在 gRPC 微服务中,由于多实例并发和跨服务调用,缓存一致性问题需要更严谨的契约设计和更新策略来应对。