上一篇文章讲到我们通过读缓存以减少数据库读操作的压力,却也存在着不足,比如写操作并发量大时,这个方案不会奏效。那该怎么办呢?本篇就来讨论怎么处理写操作并发量大的场景。
1. 业务场景:如何以最小代价解决短期高频写请求
公司计划在15分钟内开展一场商品预约活动,预计产生数十万级写入请求,业务目标是100w的预约量,以过往到底经验通常会在前两分钟完成预约数的90%,其中峰值可能达90万/分钟(即1.5万TPS)。而现有系统预约功能仅支持约2200TPS,无法满足峰值需求。
由于该活动为临时性活动,且要求改动范围小、工期短,因此不适宜采用分库分表等重型架构调整方案。
最终采用的方案是:引入高性能缓存层,将瞬时高并发写入请求先缓存至中间缓冲层,后续再异步、平稳同步至数据库,以此抵御流量峰值,保障系统稳定。
2. 写缓存
写缓存是一种应对高并发写入的架构策略。其核心思路是:当后台服务接收并验证用户请求后,不直接将数据写入数据库,而是先存入一个高性能的中间存储层(即写缓存)。当缓存中的数据累积到一定阈值时,再以批量方式异步、匀速地同步至数据库。
此举的意义在于,利用写缓存高出数据库数个量级的吞吐能力来承接瞬时流量洪峰,从而保护数据库不被压垮,之后再将数据平稳"引流"至数据库,确保系统在极限压力下保持稳定。
3.实现思路
这个过程看起来简单,但是具体实施落地需要考虑6个问题
1)写请求与批量落库这两个操作同步还是异步?
2)如何触发批量落库?
3)缓冲数据存储在哪里?
4)缓存层并发操作需要注意什么?
5)批量落库失败了怎么办?
6)Redis的高可用配置。
3.1 写请求与批量落库这两个操作同步还是异步
3.1.1 优缺点对比
同步的优点是 用户预约完成可以立即看到预约数据,缺点是用户提交预约后,还需要等待一段时间才能返回结果,且这个时间不定,有可能需要等待一个完整的时间窗。
异步优点是用户能快速知道提交结果;缺点是用户提交完成后,如果查看"我的预约"页面,可能会出现没有数据的情况。
3.1.2 具体实现
在同步实现中,用户请求线程会阻塞等待,直到数据批量落库完成后才返回响应。这种模式会引发几个关键问题:如何控制用户等待时间(如每100毫秒批量写入一次)、设置落库超时时间、设计失败后的重试策略(重试次数与间隔),以及避免线程因重试而长期阻塞。
这些问题与请求合并等传统方案(如 Hystrix)的处理逻辑类似。若采用异步实现,则无需关注超时与线程阻塞问题------数据存入缓存后即可立即响应用户,后续落库操作异步完成。因此,从复杂度和可靠性考虑,项目最终采用了异步方案。
关于异步的用户体验设计,共有两种设计方案可供业务方选择。
1)在"我的预约"页面给用户一个提示:您的预约订单可能会有一定延迟。
2)用户预约成功后,直接进入预约完成详情页,此页面会定时发送请求去查询后台批量落库的状态,如果落库成功,则弹出成功提示,并跳转至下一个页面。
其实,第一种方案在实际应用中也经常遇到,不过项目中主要还是使用第二种方案。因为在第二种方案中,大部分情况下用户是感受不到延迟的,用户体验比较好,而如果选择第一种方案,用户还要去思考:这个延迟是什么意思?是不是失败了?这无形中就影响了用户体验。
3.2 如何触发批量落库
3.2.1 两种触发逻辑
1. 按请求次数触发
当累积的写请求达到预设数量(如每10次)时执行一次批量落库。其优势在于能将数据库访问频率降至原来的 1/N,显著减轻数据库压力。但缺点在于:若请求量始终未达到设定阈值,数据将无法及时落库。
2. 按时间窗口触发
每隔固定时间间隔(如每秒)执行一次批量落库。这种方式能保证数据在有限时间内被持久化,避免用户长时间等待。但若在某一时间窗口内累积的数据量过大(例如瞬间涌入5000条),单次批量操作可能无法完成,仍需拆分成多次执行------这在效果上实际又回归到了第一种按次数触发的逻辑。
3.2.2 具体实现
项目采用的方案是同时使用这两种方式。具体实现逻辑如下。
1)每收集一次写请求,就插入预约数据到缓存中,再判断缓存中预约的总数是否达到一定数量,达到后直接触发批量落库。
2)开一个定时器,每隔一秒触发一次批量落库。
有些同学会有些疑问,1s的时间窗口就够了嘛,对!对于一些没有接触过入库优化的同学来说,总觉得1s的时间窗口过短,但是他确实能够极大改善数据库压力,技术侧它可以改善瞬时数据连接占用量、多连接插入时的锁竞争... 用户体验侧1s也能做到优秀的用户体验。下面是对此的详细解释:
为什么1s窗口批量入库能极大改善数据库压力?
为什么1s窗口批量入库能极大改善数据库压力?
我们用一个比喻:假设数据库是仓库的卸货码头,每次请求是一辆送货小三轮。
- 准时大量请求(实时入库) :成千上万辆小三轮,每辆只运一个箱子,但每辆都要排队、登记、卸货、离开。码头管理员(数据库)绝大部分时间都在处理"流程开销"(建立连接、解析SQL、事务开启/提交、锁管理、网络往返),而不是真正卸货。
- 累计1秒批量入库 :让一个调度员先把1秒内所有小三轮的箱子集中到一个大卡车上,然后大卡车一次性运到码头卸货。码头管理员只需要处理一次"流程开销"。
技术上的核心收益:
- 减少事务开销 :将数百/数千次小事务合并为一次大事务。数据库提交事务需要写日志(WAL),这是昂贵的磁盘I/O操作。一次提交远小于N次提交的总开销。
- 减少网络往返和SQL解析 :从N次 INSERT INTO table VALUES (?) 变成一次 INSERT INTO table VALUES (?), (?), (?), ...。网络延迟、SQL解析和优化的成本被摊薄到大批数据上。
- 改善锁竞争 ⭐ :如果表有索引,每次插入都可能涉及索引页的调整和锁定。批量插入时,索引结构的更新可以更高效地批次完成,减少锁的持有和竞争时间。
- 利用数据库批量操作优化 :现代数据库(如MySQL的rewriteBatchedStatements参数,PostgreSQL的批量COPY)对批量操作有专门的、更高效的内部路径。
通过以上操作,既避免了触发方案一数量不足、无法落库的情况,也避免了方案二因为瞬时流量大而使待插入数据堆积太多的情况。
3.3 存数据存储在哪里
缓存数据的存放位置包括本地内存和分布式缓存(如 Redis),其中本地内存实现起来最为简单。
然而,虽然 Hystrix 的请求合并机制也使用本地内存,但它并不适合直接用于本场景的写操作合并。这是因为两者的核心关注点不同:Hystrix 的请求合并主要针对读请求 ,数据丢失的后果较轻;而写请求必须考虑容灾问题------如果服务器宕机,内存中的数据将随之丢失,导致用户提交的关键预约信息无法恢复。
对于这类写请求合并的场景,消息队列(MQ)也是一个可行的方案。它的主要优势在于削峰填谷,非常契合高并发写入的场景。不过,当前项目最终选择了 Redis,主要基于两点考虑::一是保持架构简洁,避免引入新的中间件;二是充分发挥团队现有技术栈优势,团队对 Redis 的批量数据操作有成熟的实践和性能把握,这能确保批量落库功能的快速、可靠落地。
3.4 缓存层并发操作需要注意什么
实际上,缓存层并发操作逻辑与冷热分离迁移冷数据的逻辑很相似,这里讲一些不一样的地方。
先看下MySQL官方文档中关于Concurrent Insert的描述:
The MyISAM storage engine supports concurrent inserts to reduce contention between readers and writers for a given table: If a MyISAM table has no holes in the data file(deleted rows in the middle), an INSERT statement can be executed to add rows to the end of the table at the same time that SELECTstatements are reading rows from the table.If there are multiple INSERT statements, they are queued and performed in sequence, concurrently with the SELECT statements.The results of a concurrent INSERT may not be visible immediately.
斜体部分的内容即:如果多个Insert语句同时执行,它们会根据排队情况按顺序执行,也可以与Select语句并发执行。所以多个Insert语句并行执行的性能未必会比单线程Insert更快。
这里再结合上面的场景具体说明下缓存层并发操作时需要注意什么。与冷热分离不一样的地方在于,这次并不需要迁移海量数据,因为每隔一秒或数据量凑满10条,数据就会自动迁移一次,所以一次批量插入操作就能轻松解决这个问题,只需要在并发性的设计方案中**++保证一次仅有一个线程批量落库即可++**。这个逻辑比较简单,就不赘述了。
3.5 批量落库失败了怎么办
在考虑落库失败这个问题之前,先来看看批量落库的实现逻辑。
1)当前线程从缓存中获取所有数据。
2)当前线程批量保存数据到数据库。
3)当前线程从缓存中删除对应数据
3.6 Redis的高可用配置
在这一业务场景中,用户提交的数据会首先存入缓存系统,因此必须确保缓存数据不会意外丢失,这要求我们为 Redis 实施可靠的数据备份机制。目前,Redis 主要支持两种数据备份方式(RDB\AOF)。
此外,Redis 还提供了主从复制功能,这里暂不详细展开。若公司已具备统一管理的 Redis 集群方案,建议直接复用 ,这样至少能获得运维层面的保障。如果需要从零开始搭建,最简单的实施步骤如下:
- 先采用简单的主从模式部署 Redis。
- 在 Slave Redis 节点上配置快照备份(每 30 秒一次)并开启 AOF 持久化(每秒同步一次)。
- 如果 Master Redis 发生宕机,切勿直接重启 ,应先将 Slave Redis 升级为 Master 节点。
- 此时代码中需预设容灾逻辑:若写缓存失败,则直接降级写入数据库。
让主节点专注于处理高并发写入,让从节点专职负责数据持久化,简单实现性能与可靠性的平衡。
不过,该方案也存在一个明显的缺点:一旦系统宕机,在手动恢复过程中容易出现操作忙乱的情况,但其数据可靠性相对较高。
5.4 小结
该方案已基本阐述完毕。项目在约两周时间内完成开发并顺利上线,在一次大型活动中成功支持了数十万量级的预约请求,后台日志与数据库监控均表现平稳,达到了预期的市场效果。整体来看,这一方案以较低的开发成本实现了显著的业务支撑能力,性价比很高。
当然,此方案也存在明显的局限性:
其一,它主要针对短期活动峰值导致的数据库压力,无法从根本上解决长期、持续的高并发写入问题;
其二,它适用于各写操作相互独立的场景,无法处理存在资源竞争(如商品库存扣减)的并发写入 。
后续章节将分别针对这两种情况,给出专门的解决方案。