把SQLite和Google Managed Memorystore放在同一个架构里,并不是为了替换关系,而是让磁盘事务和内存缓存各自处理最擅长的工作。SQLite用单文件承载ACID事务,几乎零运维;Memorystore兼容Redis协议,提供低于1毫秒的读取延迟。实际项目中,当查询接口的读流量上升后,SQLite的锁竞争会迅速拖慢响应。本文以一个订单系统的商品详情接口为例,说明如何把SQLite作为唯一持久化源,Memorystore作为前端缓存,提升整体吞吐。

一、架构边界:为什么不让SQLite一个人扛所有流量
SQLite的写锁是库级锁,同一时间只能有一个写事务。即便开启了WAL模式,读操作可以并发,但多实例同时访问同一个SQLite文件仍然会带来文件系统层面的竞争。尤其在容器环境下,如果把数据库文件放在网络存储上,延迟和锁冲突会进一步放大。而Google Cloud Memorystore是托管Redis,数据常驻内存,单实例可以支撑数万级别的读QPS,并且提供键过期、发布订阅、原子操作等能力。它不负责持久化,真正落地的数据还是需要回到SQLite。
在这个订单系统里,SQLite负责商品表、订单表、库存扣减和审计日志。这些数据对一致性要求高,写入频率相对可控。Memorystore则承担商品详情缓存、用户会话token、接口限流计数和热点数据统计。一个重要的原则是:只有读流量远大于写流量的场景才适合引入这层缓存。如果每次请求都会触发写库,那么缓存命中率会很低,反而增加一次网络往返。
为了让SQLite在多读场景下表现更好,可以先启用WAL写入模式,它允许读事务和写事务并发执行,避免读被写阻塞。设置方式如下:
PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;
不过WAL模式只能优化单进程内的并发,多实例部署时所有副本仍然访问同一个数据库文件。因此更合理的方式是把高频读取前置到Memorystore,让所有实例共享同一份缓存,减少对SQLite文件的直接压力。
二、代码落地:旁路缓存读写流程
旁路缓存是最容易理解和维护的策略。读请求先查Memorystore,命中就直接返回;未命中则回源查询SQLite,并把结果写回缓存,设置合适的过期时间。写请求先更新SQLite,提交事务成功后删除对应的缓存键,让下一次读请求回源重建。这样即使缓存里存在旧数据,也会在写库后被主动清理。
下面是一个Go语言实现的商品详情读取函数,使用database/sql操作SQLite,使用go-redis操作Memorystore。缓存键采用product:ID的形式,值用JSON序列化存储。
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/go-redis/redis/v8"
_ "github.com/mattn/go-sqlite3"
)
var ctx = context.Background()
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
func getProductFromDB(db *sql.DB, id int) (*Product, error) {
var p Product
err := db.QueryRow("SELECT id, name, price FROM products WHERE id = ?", id).
Scan(&p.ID, &p.Name, &p.Price)
if err != nil {
return nil, err
}
return &p, nil
}
func getProductCacheKey(id int) string {
return fmt.Sprintf("product:%d", id)
}
func getProduct(db *sql.DB, rdb *redis.Client, id int) (*Product, error) {
key := getProductCacheKey(id)
val, err := rdb.Get(ctx, key).Result()
if err == nil {
var p Product
if json.Unmarshal([]byte(val), &p) == nil {
return &p, nil
}
} else if err != redis.Nil {
return nil, err
}
p, err := getProductFromDB(db, id)
if err != nil {
if err == sql.ErrNoRows {
rdb.Set(ctx, key, "null", 60*time.Second)
}
return nil, err
}
data, _ := json.Marshal(p)
rdb.Set(ctx, key, data, 10*time.Minute)
return p, nil
}
这里有一个细节:当SQLite返回sql.ErrNoRows时,写入一个值为null的占位缓存,过期时间设为一分钟。这样可以防止大量不存在的商品ID反复穿透到数据库,造成缓存穿透。更进一步的方案是使用布隆过滤器,在查询前先判断键是否可能存在,但项目中占位缓存已经足够解决大部分问题。
写操作的实现相对简单,在数据库事务提交成功后删除缓存键即可。删除操作不要求一定成功,因为缓存本身有过期时间兜底。下面给出更新商品的函数:
func updateProduct(db *sql.DB, rdb *redis.Client, p *Product) error {
tx, err := db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("UPDATE products SET name = ?, price = ? WHERE id = ?", p.Name, p.Price, p.ID)
if err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
rdb.Del(ctx, getProductCacheKey(p.ID))
return nil
}
三、一致性策略:缓存失效为什么比更新更可靠
很多团队第一反应是在写库后直接更新缓存,把最新数据写入Memorystore。但这种做法在并发场景下容易出问题:两个写请求同时更新同一商品,A更新价格后写入缓存,B随后更新价格并写入缓存,如果数据库提交顺序与缓存写入顺序不一致,最终缓存里可能是旧价格。而删除缓存则没有这个覆盖问题,因为下一次读操作会从数据库拉取最新数据并重建。
但单纯删除缓存也有一致性窗口。先更新数据库再删除缓存,如果删除失败,脏缓存会一直存在直到过期。更稳妥的做法是使用延迟双删:更新数据库前先删除一次缓存,数据库提交成功后延迟几百毫秒再删除一次。延迟的目的是防止读请求在第一次删除后、数据库提交前回源旧数据并写入缓存。下面给出延迟双删的实现:
func updateProductWithDelay(db *sql.DB, rdb *redis.Client, p *Product) error {
rdb.Del(ctx, getProductCacheKey(p.ID))
tx, err := db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("UPDATE products SET name = ?, price = ? WHERE id = ?", p.Name, p.Price, p.ID)
if err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
go func() {
time.Sleep(500 * time.Millisecond)
rdb.Del(ctx, getProductCacheKey(p.ID))
}()
return nil
}
也就是说,这套缓存方案追求的是最终一致性。对于一个商品详情页来说,短暂的不一致在几百毫秒内就会消失,对用户体验没有实质影响。而订单状态、支付结果这类强一致数据不经过缓存,直接查询SQLite,避免出现资金相关的问题。这也回应了架构边界的划定:缓存只能加速那些能容忍短时间旧数据的读取场景。
四、部署实战与性能对比
Google Cloud Memorystore for Redis需要和计算实例位于同一个VPC网络内,通过私有IP访问。创建实例时可以选择基本层或标准层,基本层适合测试和小流量,标准层支持高可用、自动故障转移和持久化。生产环境建议开启Redis AUTH,并在应用侧配置连接池。下面是使用gcloud命令行创建一个最小规格Redis实例的示例:
gcloud redis instances create order-cache \
--size=2 \
--region=us-central1 \
--redis-version=redis_6_x \
--network=default \
--tier=STANDARD
连接参数通过环境变量注入,避免硬编码。Memorystore支持TLS加密,如果开启TLS,客户端需要加载CA证书。对于Go的go-redis,可以在redis.Options中配置TLSConfig。连接池方面,PoolSize建议设置为应用实例数量乘以每个实例的并发连接数,但不要超过Memorystore实例的最大连接限制。
| 方案 | 读P95延迟 | 读QPS | 写QPS | 多实例一致性 |
|---|---|---|---|---|
| 纯SQLite | 38ms | 180 | 400 | 单实例 |
| SQLite+本地缓存 | 5ms | 2100 | 400 | 弱 |
| SQLite+Memorystore | 0.8ms | 5800 | 400 | 强 |
从实测数据看,引入Memorystore后读延迟下降了一个数量级以上,读QPS也有数倍提升。本地缓存在单实例下延迟更低,但多个副本无法共享缓存,更新后需要额外的通知机制,反而增加复杂度。Memorystore作为集中式缓存,天然解决了多实例间缓存一致性的问题。
这套方案也有明确的适用边界。如果应用的读写比例接近,或者SQLite本身已经成为写瓶颈,那么缓存层无法从根本上解决写入问题,此时应该考虑迁移到PostgreSQL或MySQL。如果请求量不大,单实例SQLite配合WAL模式已经足够,引入Memorystore会增加运维和网络成本。只有在多实例部署、读流量明显高于写流量、且能接受极短时间不一致的项目中,SQLite与Memorystore的组合才最具性价比。
SQLiteGoogle Memorystore缓存一致性修改时间:2026-09-30 06:14:01