为什么有了 MySQL,还要 Redis 和 Elasticsearch?从一件商品看数据系统的权衡

一件商品上架后,会经历几种很不一样的访问。

商家修改标题和 SKU;用户搜索关键词、筛选价格;打开详情;最后下单,预占库存。

这些动作围绕同一件商品,却很难长期用同一种数据组织方式照顾好。修改要保证业务记录正确,详情要避免重复组装,搜索要支持文本与组合条件,下单还要处理并发。

读项目代码时,我更关心的也是这些问题:为什么某个入口查 MySQL,另一个入口读 Redis,列表又交给 Elasticsearch?多存几份数据以后,它们不一致怎么办?

下面沿商品链路展开,穿插订单查询中的一个补充案例。案例依据是现有源码、提交记录和测试代码,不包含虚构的线上流量、耗时或事故经过;示意数字也不代表实测结果。

1. 商品上架:先有一份能够承担业务记录的数据

商品不是一个标题加一张图片。一个商品下面有多个 SKU,每个 SKU 有规格、价格和库存;商品还有审核状态、展示策略、开售配置。它们之间存在关系,也存在需要一起成功或一起回滚的修改。

项目使用 MySQL 保存这类记录,Repository 负责读写商品与 SKU,Service 在需要的地方划定数据库事务。

这里先澄清一个常见误解:MySQL 的价值不能概括为"数据存磁盘"。InnoDB 本身也会利用内存缓存;更关键的是,关系模型、索引、约束与本地事务,给业务数据的组织和修改提供了基础。事务隔离与锁的具体行为还取决于语句和配置,可以参考 InnoDB 的事务与锁模型。

以库存为例,项目既有库存增量更新,也有 SELECT ... FOR UPDATE 这样的锁定读取。增量更新可以表达"减少一件",锁定读取用于需要串行协调的特定路径。不能只因为方法上有事务,就认为所有并发问题都解决了。

如果商品量、访问量和查询方式都不复杂,只用 MySQL 完全可以是合理起点。先检查 SQL、索引、批量访问和返回字段,往往比再引入一套存储更直接。

增加组件之前,应该先能回答:现有瓶颈具体是什么,新增组件打算接走哪一类工作?

2. 查看详情:Redis 缓存的是一次组装的结果

打开详情时,用户需要的通常是"可以直接展示的商品信息"。数据库里的数据则可能分散在商品、SKU、类目等多种记录里。

项目的详情读取路径很典型:先批量读 Redis;找出未命中的商品;调用共享商品服务,从数据库组装详情;再写回缓存。

下面是按源码简化的流程示意,不是可直接替换原方法的完整实现:

java 复制代码
Map<Long, Detail> result = multiGetFromRedis(ids);
for (Long id : missingIds(ids, result)) {
    Detail detail = loadAndAssembleFromDatabase(id);
    writeToRedis(id, detail);
    if (detail != null) result.put(id, detail);
}

它节省的除了数据库读取,还有多次关联查询与对象组装的重复工作。批量读取也能减少往返次数。

这只是缓存层的取数过程,并非完整接口到这里就返回。当前商品批量读取在拿到详情后,还会调用展示状态服务,从数据库读取相关记录并覆盖展示状态。它保留了普通详情的缓存收益,也让这条路径里的关键状态不只依赖整份缓存。代价是缓存命中后仍有数据库读取,不能用"命中率很高"推断"数据库完全没有压力"。

代价是多了一份会过时的数据,以及序列化、过期、失效、重建这些维护工作。缓存未命中也会把压力带回数据库,不能拿"命中时很快"代表所有请求的表现。

项目里的坑:已经改了商品,详情还在展示旧状态

项目中能看到两种商品变更监听:一个在数据库提交后同步尝试删除详情缓存;另一个在提交后异步重建详情、SKU 和相关展示数据。

为什么有了异步刷新,还需要同步删除?

因为异步任务可能尚未执行。数据库已经提交,新请求却仍命中原来的缓存,看到旧的售卖或展示状态。

删除旧值让后续请求有机会走未命中回源;异步重建则提前准备好后面的访问。代码还对同步删除做了最多三次尝试,最终失败会记录日志。

这项变化可以从提交记录中核对,但它处理的是一个具体窗口,不能扩写成"彻底解决了缓存一致性"。

比如,一个读请求先读到数据库旧值,随后写请求提交并删掉缓存,之前的读请求最后又把旧值写回 Redis。这类并发回填需要额外判断。删除连续失败、提交后进程退出,也不是"加了监听器"就自然消失。

项目的这类普通对象缓存写入设置了七天过期。这个事实尤其说明:过期时间不等于新鲜度承诺,七天更不能替代售卖状态变更时的主动失效。

选缓存方案时,要先确定旧数据影响什么。旧标题展示几秒和错误放行售卖的业务后果不同,不能只按命中率做决定。

3. 搜索商品:把面向查询的结构单独组织出来

详情页通常知道商品 ID,搜索页却先不知道要找哪件商品。

用户可能给出关键词,再按店铺、状态、价格范围、库存等条件过滤,最后排序和翻页。项目中的 ES 查询就包含标题短语匹配、条件过滤、范围查询和排序。

Elasticsearch 在这里承担搜索读模型:围绕检索组织字段和索引,而不必沿用业务写入时的表结构。比如,文本匹配与精确过滤需要不同的字段设计,不能把所有字段都当成同一种字符串。

这也不意味着 MySQL 不能搜索。MySQL 有索引和全文检索能力;当现有查询能满足体验与容量要求时,继续使用它更省维护成本。引入 ES 的理由应是查询需求与实际成本,而不是"电商都要用"。

项目里的坑:数据库回滚了,外部副作用却已经发生

如果在数据库提交前就写 ES,随后事务回滚,搜索侧可能留下没有成功提交的业务状态。提前发送通知也会产生类似问题。

项目有一组测试,检查商品提交的后续处理:事务未提交时,不应写商品 ES 或提交外部图片处理;回滚时,这些调用不应发生;提交后才允许执行。

另一个商品变更同步服务采用 AFTER_COMMIT 事件监听并异步执行:重新从 MySQL 查询当前数据,再部分更新 ES 中由这条路径负责的展示与定时开售字段。

这两个实现不能混为一条通用同步管道:商品提交路径中能看到商品 ES 保存,变更监听中则有特定字段的部分更新。

部分更新的意义是缩小写入范围,避免顺手覆盖其他路径负责的字段。但"重新读数据库"和"重试 ES 更新冲突"也不能自动证明所有乱序问题都被消除了,仍要看并发执行、字段归属与版本控制。

提交后执行带来了另一个明确选择:数据库记录先成功,搜索同步可能失败。项目中能看到异常日志和部分指标,部分路径选择不让 ES 失败影响主流程。

它避免了某些提交前的错误副作用,却没有证明同步一定最终完成。进程退出、异步任务失败、目标文档不存在,都需要继续考虑。

如果业务要求可恢复的同步,可以评估持久化事件、事务发件箱、CDC 或重试与对账,但这些是进一步方案,本文没有确认当前商品链路已形成这样的可靠闭环。

此外,ES 写入完成与搜索立即可见也是两件事;应用同步本身和 ES 的刷新机制可能分别贡献延迟。商品提交的同步方法里能看到显式 refresh(),所以不能把所有路径统一描述为"都在等待默认刷新"。刷新控制可见性,也有执行成本,更不能补救应用侧根本没有成功写入的变更。需要严格即时确认的操作,应明确自己的读取来源与可见性要求,不能默认搜索结果就是交易判定依据。

4. 下单:Redis 已经不只是可随时重建的缓存

商品详情缓存丢了,可以回源重建。库存预占状态丢了,则可能不知道哪些数量已被订单占用。

项目在 Redis 中保存 SKU 的 total、reserved,以及订单对应的预占记录。可用数量按下面的关系判断:

text 复制代码
available = total - reserved

预占脚本检查可用数量与该订单是否已有预占,再增加 reserved 并保存订单记录。后续根据"下单扣库存"或"支付扣库存"的业务配置,由订单消息触发实际扣减;取消事件则进入释放处理。

为什么要用 Lua?因为把"读取、检查、修改"拆成几次请求,中间可能插入其他请求。Lua 可以让这组 Redis 操作作为一个脚本执行,避免检查和修改被其他命令穿插。Redis 官方文档说明了这种执行语义。

但长脚本也会占用执行时间,应控制工作量;多 key 脚本能否运行,还要结合实际 Redis 部署和 key 分布确认。

项目里的边界:Lua 成功以后,数据库还可能失败

实际扣减路径大致是:

text 复制代码
查询 DEDUCT / RESTORE / RELEASE 日志
→ 执行 Redis 扣减脚本
→ 更新 MySQL 中 SKU 和商品库存
→ 记录扣减日志

消费侧还有商品维度的锁,日志用于检查已处理状态,减少重复或不合时序的操作。这些机制各自有作用。

问题在于,Redis 与 MySQL 不在同一个本地数据库事务里。

假设 Redis 已经扣减成功,接下来的 SQL 更新或数据库提交失败,MySQL 可以回滚,Redis 的修改不会因此自动恢复。方法上的 @Transactional 不能把两套存储变成一次原子提交。

项目中也有库存日志队列,定时任务将日志写入数据库,处理异常时会尝试重新放回队尾。这有助于落库,但仍需区分普通异常与进程退出:取出日志后、落库前进程退出,并不一定能进入异常回队逻辑。

因此,这个案例应当这样总结:项目使用预占、锁、日志和重复操作检查,处理库存生命周期中的不同问题;静态阅读还不足以证明全部故障窗口已经闭合。

进一步验证时,应覆盖 Redis 成功但 SQL 失败、数据库提交失败、重复消息、取消与支付消息乱序,以及日志尚未落库时进程退出。对账和补偿必须基于订单、预占和操作记录确定差异,不能简单拿数据库总量覆盖 Redis,更不能把库存 key 当普通缓存随意清空。

5. 补充案例:查询搬到 ES,仍可能被深分页拖慢

订单项目有一条很直接的提交记录:为订单列表增加慢查询日志与深分页保护。当前代码仍能看到单页大小不超过 100、from + size 不超过 10,000 的保护逻辑,也有使用 search_after 的游标查询路径。

页码查询越往后,代价并不只是"再取二十条"。ES 需要处理前面页的候选结果,深分页会增加资源消耗;游标则使用上一页的排序值继续查询。官方分页文档说明了这两种方式的限制。

项目的游标路径还会多取一条判断是否有下一页,在部分查询里关闭精确总数统计。它把"不需要知道精确总页数"的产品体验,换成了更节制的查询工作。

代价也很清楚:游标通常不支持直接跳到任意页;排序值要能稳定定位。数据在翻页期间变化时,严格一致的遍历还需要考虑唯一排序键和 PIT 等机制。本文没有确认这些接口使用 PIT,不能把 search_after 写成绝对不重不漏的保证。

不同订单接口也有不同取数方式:有直接使用 ES 文档组装结果的路径,也有先拿 ID 再批量回查 MySQL 的路径。回查能补充当前数据库字段,却不能找回压根没被 ES 检索到的记录,也不会自动修正搜索总数。

6. 每增加一份数据,就增加一份维护责任

有一个选型方法,比先比较组件性能更实用:按字段和动作确定一致性要求。

商品标题、展示状态、搜索命中和库存预占虽然属于同一件商品,允许的延迟并不相同。标题短暂过时可能只影响展示;预占错一件却会改变后续订单能否购买。把所有字段塞进一个缓存对象,再用同一个过期时间表达要求,往往不够。

项目里"复用详情缓存,再查展示状态"的组合,就是把不同要求拆开处理的一种实现。它多付出一次读取,换取该路径不完全依赖整份缓存中的状态。是否值得,要结合访问量、查询成本和业务后果衡量,不能直接推广为所有接口都要回源。

故障时也要按动作区分:详情缓存不可用,是否允许回源,需要看数据库承受能力;搜索不可用,是否还能提供按 ID 查询的详情,需要产品定义;库存预占结果不明确,则不能轻易当成失败再扣一次。超时可能发生在操作已经执行之后,恢复应该依据订单或请求记录确定结果。

这些是选型时需要明确的业务决策,不代表本文已经验证项目具备每一种降级或恢复能力。先定义成功、失败与结果未知分别怎么办,再选择实现机制,方案才容易落地。

读完这些实现,三种存储的取舍可以整理成下面这张表。

业务需要 可以先考虑什么 增加组件后要承担什么
保存商品、SKU、订单与业务关系 MySQL、合适的索引和事务 锁竞争、SQL 成本、容量与恢复
重复读取较稳定的详情 Redis 缓存组装结果 失效、过期、回源压力与旧值窗口
文本匹配、组合筛选与排序 评估 ES 搜索读模型 字段映射、同步延迟、重建与查询成本
协调并发预占 评估数据库条件更新或 Redis 原子操作 幂等、恢复与跨存储差异处理

这些项目案例给我的提醒是:先画清楚每条读写路径,再讨论技术选型。

哪一份数据保存业务事实,哪一份只是读取结果?哪种延迟能接受?一次失败发生在提交前还是提交后?用户重试时,会重复执行哪一步?

把这些问题回答清楚,才知道某个组件带来的收益,是否值得承担它附带的一致性和维护成本。

相关推荐
对象存储与RustFS17 分钟前
Jenkins 的构建产物别堆在控制器磁盘:用 RustFS 当它的 S3 工件仓库
后端·docker·云原生
王中阳Go18 分钟前
100 件货卖出 116 件,库存停在 -16
后端·mysql·go
泡海椒19 分钟前
jquick-pdf 多重折线(JMultiLineChartData)实战:Java 生成工单 SLA 月报
后端
Swot21 分钟前
SvelteKit Remote Functions 使用指南
前端·后端
Java旅途28 分钟前
让项目过程清晰可追溯:从课题双选到文档归档的一体化平台
后端
黄连木笛30 分钟前
不改 Redis Insight 原有代码,把它汉化了:DOM 整段匹配 + XHR 拦截 + asar 重打包
redis·后端
flash俊杰31 分钟前
v2.34-02 静默截断:10,000 个会话与 500 条消息是怎么被发现的
后端
vaskilab40 分钟前
Agent 上线后翻车的 6 种死法:一个 20 万人规模项目的四版重构实录
后端
鶴哥只手遮天1 小时前
《rustc 内幕》第 1 章 全景:rustc 编译流水线与 crate 版图
后端·rust·开源