Redis当主库实测?秒杀场景下Redis与MySQL真实分工与高可用架构
我第一次看到这个标题的时候,整个人是分裂的——一半觉得这种"大厂抛弃XX"的句式纯粹是流量密码,把缓存和数据库拉到对立面上,根本不成立;另一半又确实好奇,如果真把Redis放到主库的位置,从零复现一遍高并发业务,到底会发生什么。带着这个疑问,我花了两周时间,用一台2核4G的虚机、一套Docker部署的Redis主从哨兵集群,加上一个秒杀扣减服务,完整复现了"Redis作为核心存储扛住亿级流量"的实验。
先说结论:Redis确实能在特定场景下扛起主库的职责,但"抛弃MySQL"这个说法从根上就是错的。真实的大厂架构里,Redis和MySQL是分工协作的关系——Redis挡在流量最前面,MySQL退到最终落地的位置。这篇文章我把我自己的复现过程、压测数据、故意杀主库做故障转移的实测结果,以及踩过的四个大坑全部写出来,希望能帮大家把这个流量密码标题背后的技术真相看清楚。
1. 先拆掉这个争议标题:MySQL与Redis到底谁取代谁
1.1 "抛弃MySQL"的传言到底从哪来
这个说法的源头,其实是很多大厂在"缓存层"上越做越重,Redis从原来MySQL前面的缓存层,逐渐在某些高频场景里变成了真正的主体存储。比如电商大促时的秒杀库存、直播间的实时排行榜、社交App的未读计数、网关层限流窗口计数器,这些业务的数据量不大、写入极频繁、读多写多但几乎不做复杂查询,MySQL在这种场景里反而成了瓶颈。
于是架构上就出现了一个变化:流量入口直接用Redis读写,MySQL退到后台异步接收Redis沉淀出来的数据。"Redis为主、MySQL为从"的说法,就是这么来的。但大部分人只看到了前半句"Redis做主库",没看到后半句"MySQL还在异步兜底",整个技术讨论就歪了。
另外还有一个原因:很多人误把"缓存命中率极高"理解成"可以干掉数据库"。实际上Redis的数据是内存态,内存再便宜也比磁盘贵一个量级,所以"全量数据都放Redis"只适用于数据量可控的业务。大厂从来没有抛弃MySQL,他们只是用架构把MySQL保护起来了。
1.2 为什么"Redis只做主库"在绝大多数业务里都是灾难
我在动手之前先做了一个简单的反推:假设一个真实业务有订单表、用户表、商品表,要做范围查询、多条件组合筛选、事务内跨行修改,Redis能撑住吗?
答案是很难。Redis的数据结构是KV、Hash、ZSet这类,它没有传统数据库的二级索引,更没有执行计划。你想查"近30天订单金额大于100且状态为已支付的订单",在MySQL里一条SQL就出来了,在Redis里你得扫描所有订单Key,再逐个判断。数据量一旦过万,这种查法基本不可用。
成本层面也一样:1TB内存的云主机月成本,通常是同等容量SSD云盘的十几倍以上。而一个中型电商的订单表一年轻松涨到几百GB甚至上TB,如果全量塞进Redis,光是存储成本就能把一个业务拖垮。再加上Redis持久化依赖RDB快照和AOF日志,异步复制下主从切换必然有秒级数据丢失窗口,账务、订单这类不能接受丢失的业务,拿Redis做唯一主库就是事故。
所以我的结论很清楚:Redis做主库不是不行,但必须同时满足三个苛刻前提——数据总量放得进内存、业务可以容忍秒级数据丢失、查询模式能用Redis的数据结构直接表达。秒杀库存就是这种场景的教科书。
2. 什么样的业务才配得上"Redis主库":我选定的复现场景
2.1 选型条件:读多写少、数据可放内存、容忍秒级丢失
复现之前我把自己逼问了一遍:如果不允许用MySQL做主存,什么样的业务我敢直接上Redis?
我列了一个准入清单:
- 数据总量可控,全部塞进内存不心疼,比如库存商品数也就几万条,一条Hash几个字段。
- 写入集中在少数热点Key上,读多写也多,但每条数据都很小,单Key不超过几百字节。
- 不需要复杂查询,按照Key直接读取,最多用Hash的字段做聚合。
- 万一主从切换丢失最后1秒的写入,业务可以接受,或者有额外的日志系统做补偿。
按照这个清单,秒杀库存预扣是最合适的。它数据量小、每次扣减是原子操作、QPS可以高到让MySQL直接跪,而且就算丢失最后一秒的扣减记录,后台还有订单流水可以核对修正。
排名第二的是排行榜和计数器,但那种场景偏向读多写少,复现起来没有秒杀那种"刺刀见红"的效果。我最终选定了秒杀扣减服务,因为它最能检验Redis在极端并发写入下的真实承载力。
2.2 我复现的具体业务:一个高并发秒杀扣减服务
整个服务模拟的是大促秒杀:10000件商品挂在Redis里,大量用户同时抢购,每抢到一次就原子扣减库存,扣减成功后把订单流水写入Redis Stream,后台有一个异步消费者把流水同步到MySQL,最终以MySQL里的流水为准做对账。
技术栈很简单:Docker部署Redis 7.0一主两从三哨兵,Python用redis-py做接口模拟,压测工具用自带的redis-benchmark和memtier_benchmark交叉验证。Java生态那套RedisTemplate序列化问题,我放在第4章的踩坑环节单独讲。
为什么用HASH而不是一个简单的STRING计数器?因为一个商品除了库存,还有已售数量、状态、限购数等好几个字段,如果用多个String Key存,扣减时要保证几个Key的一致性就只能靠Lua脚本包裹,逻辑分散而且不直观。用一个HASH把所有字段聚合在一起,一条Lua脚本全部搞定,可维护性会好很多。
3. 复现环境搭建:从零部署一套能扛亿级请求的Redis集群
3.1 容器化部署一主两从三哨兵
我直接用docker-compose在虚机上拉起一个主从哨兵集群。网上很多教程是单机单实例玩一玩,但那测出来的数据不能代表"主库"形态,因为生产级的Redis主库不可能没有高可用。一主两从三哨兵是最小的高可用配置。
version: '3.8'
services:
redis-master:
image: redis:7.0
container_name: redis-master
command: ["redis-server", "--requirepass", "redispass", "--masterauth", "redispass", "--appendonly", "yes", "--appendfsync", "everysec"]
ports:
- "6379:6379"
volumes:
- ./data/master:/data
redis-slave-1:
image: redis:7.0
container_name: redis-slave-1
command: ["redis-server", "--requirepass", "redispass", "--masterauth", "redispass", "--replicaof", "redis-master", "6379", "--appendonly", "yes"]
ports:
- "6380:6379"
volumes:
- ./data/slave1:/data
redis-slave-2:
image: redis:7.0
container_name: redis-slave-2
command: ["redis-server", "--requirepass", "redispass", "--masterauth", "redispass", "--replicaof", "redis-master", "6379", "--appendonly", "yes"]
ports:
- "6381:6379"
volumes:
- ./data/slave2:/data
sentinel-1:
image: redis:7.0
container_name: sentinel-1
command: ["redis-sentinel", "/etc/sentinel.conf"]
volumes:
- ./sentinel.conf:/etc/sentinel.conf
depends_on:
- redis-master
sentinel-2:
image: redis:7.0
container_name: sentinel-2
command: ["redis-sentinel", "/etc/sentinel.conf"]
volumes:
- ./sentinel.conf:/etc/sentinel.conf
depends_on:
- redis-master
sentinel-3:
image: redis:7.0
container_name: sentinel-3
command: ["redis-sentinel", "/etc/sentinel.conf"]
volumes:
- ./sentinel.conf:/etc/sentinel.conf
depends_on:
- redis-master
sentinel.conf需要提前写好,每个哨兵监听的master地址要指向redis-master:6379,同时配置上密码,不然哨兵连不上带密码的实例:
port 26379
dir /tmp
sentinel monitor mymaster redis-master 6379 2
sentinel auth-pass mymaster redispass
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 15000
sentinel parallel-syncs mymaster 1
注意这里有个新手最容易踩的坑:三个哨兵如果各自维护一份独立的sentinel.conf,运行一段时间后它们之间会互相改写配置文件来同步状态,所以容器里的配置必须挂载出来,否则容器重启配置就丢了,哨兵的quorum判定会失效。我在实验里把配置挂载到了宿主机的
./sentinel
目录,这个问题才彻底解决。
启动之后先验证主从复制状态:
docker exec redis-master redis-cli -a redispass info replication
看到
connected_slaves:2
就说明两个从节点已经追上来了。再验证哨兵集群,用
redis-cli -p 26379 sentinel get-master-addr-by-name mymaster
,能返回master的IP和端口就说明哨兵已经正确监控到了整个集群。
3.2 关键参数调优:这5个配置直接决定吞吐量上限
Redis默认配置偏向保守,我在实验里逐个调过这些参数,每一个都必须知道它为什么重要,否则压测数据会误导你。
第一个是
appendfsync
。它决定AOF日志刷盘策略,默认everysec,也就是每秒把缓冲区的日志刷到磁盘一次。改成always每次写操作都刷盘,数据安全提升但写吞吐量直接掉20%到40%;改成no则完全交给操作系统刷盘,写入最快但崩溃时可能丢好几秒的数据。作为"主库"角色,我强烈建议生产环境锁死everysec,这是性能和安全的最优平衡点。
第二个是
maxmemory-policy
。没有设置maxmemory时,Redis可以无限占用内存直到OOM。一旦设置,就必须决定内存满时怎么办。秒杀场景里我设置的是
allkeys-lru
,因为库存数据按Key访问的特征非常明显,冷数据淘汰掉不心疼。千万不要无脑用
noeviction
,那会导致写入直接报错,线上事故的常见原因。
第三个是
tcp-backlog
和
timeout
。
tcp-backlog
默认511,并发连接数高时这个队列会被打满,表现为客户端连接超时。
timeout
默认0,也就是连接永不主动断开,但高并发下大量空闲连接会吃掉文件描述符。我设置
timeout 300
让300秒不活跃的连接被系统回收。
第四个是Linux内核的THP,这个坑特别隐蔽。Redis在
fork()
做RDB快照时,如果系统开启了透明大页,内存页从4KB变成2MB,写时复制的放大效应会非常夸张,严重时RDB期间内存直接翻倍。生产环境必须执行
echo never > /sys/kernel/mm/transparent_hugepage/enabled
来关闭。这不是Redis配置,是系统配置,但和Redis主库的稳定性直接相关。
第五个是
io-threads
。Redis 6.0之后引入了IO多线程,但这个多线程只负责网络读写,核心命令执行仍然单线程。所以它在多核机器上对高并发GET/SET有明显提升,在我那台2C4G的虚机上效果反而不明显,因为CPU核心数太少。这个参数实验之前最好先测一遍,不是所有场景都能靠它提速。
3.3 压测工具的选择与使用
压测我用了两个工具互相验证。
redis-benchmark
是Redis自带的,简单直接,但它是单进程模型,压测时容易自己先到瓶颈;
memtier_benchmark
支持多线程多客户端,更适合模拟真实并发。
redis-benchmark里最容易用错的是
-P
参数,也就是pipeline。默认P=1,每个请求都要等上一次的RTT回来再发下一次,这个模式下我测出来的SET QPS只有4万左右,看起来Redis也就那样。但当我把P提高到16,同样一批请求打包发给Redis,QPS直接超过10万。这不是Redis变快了,是网络往返的开销被pipeline摊薄了。真实业务里客户端如果用单条读写而不是pipeline,是打不出高吞吐的,这也是很多初学Redis的人"压测结果和大厂分享对不上"的根本原因。
| 压测模式 | QPS(约) | 平均延迟 | 说明 |
|---|---|---|---|
| redis-benchmark SET,P=1 | 3.8万 | 2.7ms | 受限于网络RTT |
| redis-benchmark SET,P=16 | 10.1万 | 0.9ms | pipeline摊薄RTT |
| redis-benchmark GET,P=16 | 11.5万 | 0.8ms | 读性能略高于写 |
| memtier 4线程,P=8 | 9.6万 | 1.2ms | 混合读写接近真实业务 |
压测环境是2核4G虚机、Redis 7.0单实例、千兆网卡。如果你用8核16G的机器,数字会翻倍。我想强调的是,别拿着我这个数字去跟别人比较,压测数据必须附带机器规格和pipeline参数才有意义。
4. 核心代码与踩坑:把"库存、订单、补偿"串起来
4.1 秒杀扣减的Lua脚本与原子性
秒杀最怕超卖,Redis单线程特性决定了同一个时刻只有一个命令在执行,但先查库存再扣减这两个动作之间仍然可能穿插其他请求。解决办法是用Lua脚本把"查库存、判断、扣减、增加已售"这几个步骤打包成一个原子操作,Redis执行整个脚本期间不会插入任何其他命令。
我写的第一版脚本长这样:
-- KEYS[1]: 商品hash的key, ARGV[1]: 初始库存
if redis.call('EXISTS', KEYS[1]) == 0 then
redis.call('HMSET', KEYS[1], 'stock', ARGV[1], 'sold', 0)
end
local stock = tonumber(redis.call('HGET', KEYS[1], 'stock'))
if stock <= 0 then
return 0
end
redis.call('HINCRBY', KEYS[1], 'stock', -1)
redis.call('HINCRBY', KEYS[1], 'sold', 1)
return 1
在Python里调用:
import redis
r = redis.Redis(host='127.0.0.1', port=6379, password='redispass', decode_responses=True)
lua_script = """
if redis.call('EXISTS', KEYS[1]) == 0 then
redis.call('HMSET', KEYS[1], 'stock', ARGV[1], 'sold', 0)
end
local stock = tonumber(redis.call('HGET', KEYS[1], 'stock'))
if stock <= 0 then
return 0
end
redis.call('HINCRBY', KEYS[1], 'stock', -1)
redis.call('HINCRBY', KEYS[1], 'sold', 1)
return 1
"""
sku_key = 'sku:10001'
for i in range(1000):
ok = r.eval(lua_script, 1, sku_key, 10000)
if ok:
# 抢购成功,写订单流水
r.xadd('order:stream', {'sku': sku_key, 'user': f'user_{i}', 'ts': i})
这里有一个设计细节:我没有直接在脚本里返回剩余库存让客户端展示,因为频繁的展示请求会放大读压力。真实秒杀页面上的剩余库存是秒级刷新一次的,和扣减是两条路径。
为什么不用MULTI/EXEC事务?Redis的事务是"批量执行"而不是"条件回滚",你没法在执行过程中根据查询结果决定是否提交。WATCH能做到乐观锁,但高并发下WATCH的冲突率极高,大量请求会返回EXECABORT然后重试,反而把吞吐拖垮。Lua脚本是天然原子又带业务流程逻辑的方案,这也是官方推荐的做法。
4.2 用Stream代替MySQL做订单流水,数据从Redis沉淀出去的路径
库存扣减只是前半段,Redis作为主库还要解决"订单数据怎么落地"的问题。我用了Redis 5.0引入的Stream数据结构作为消息队列,每条成功的扣减记录都追加到Stream里,后台消费者组读取后异步写入MySQL。
# 后台异步消费者:从Stream消费订单流水并回写MySQL
import redis
import pymysql
r = redis.Redis(host='127.0.0.1', port=6379, password='redispass', decode_responses=True)
group_name = 'order-consumer-group'
stream_key = 'order:stream'
try:
r.xgroup_create(stream_key, group_name, id='0', mkstream=True)
except redis.ResponseError as e:
if 'BUSYGROUP' not in str(e):
raise
while True:
# 阻塞式读取新消息,约2秒没有新消息就重新创建消费者
entries = r.xreadgroup(group_name, 'consumer-1', {stream_key: '>'}, count=10, block=2000)
if not entries:
continue
for stream_name, messages in entries:
for msg_id, fields in messages:
# 回写MySQL,这里用参数化SQL
sql = "INSERT INTO order_flow(sku, user_id, ts) VALUES (%s, %s, %s)"
try:
cursor.execute(sql, (fields['sku'], fields['user'], int(fields['ts'])))
conn.commit()
# 确认消息已处理
r.xack(stream_key, group_name, msg_id)
except Exception as e:
# 写MySQL失败,不要XACK,等待下次重试
print(f'order sync failed: {e}')
time.sleep(1)
这套链路里最关键的逻辑是:只有MySQL写入成功并commit之后才执行XACK确认,否则消息会一直在Pending列表里,重启消费者后XREADGROUP会重新消费这些未确认消息。这就保证了Redis到MySQL的异步回写是至少一次的,配合HASH里存的总库存做定期对账,可以修掉极端情况下的重复和丢失。
这个"Redis Stream当消息队列+MySQL当最终存储"的组合,才是"Redis做主库"的真正含义。它解决了我的服务需要把已在Redis里产生的数据持久化出去的问题。
4.3 四个高频踩坑:连接池、序列化、BigKey、慢查询
坑一:连接池耗尽。
我一开始直接用redis-py默认的连接池,每来一个请求就新建连接,压测到3万QPS时客户端直接报连接超时。排查链路是:先看Redis服务端日志,没有异常;再看系统连接数,发现大量TIME_WAIT连接堆积;最后确认是客户端每次请求都走TCP三次握手,根本没有复用连接。修复方法是在redis-py里显式构造连接池,并设置合适的池大小:
pool = redis.ConnectionPool(
host='127.0.0.1', port=6379, password='redispass',
max_connections=200, decode_responses=True
)
r = redis.Redis(connection_pool=pool)
这个坑在Java生态同样常见,Jedis的
maxTotal
、Lettuce的
maxActive
配置不对,压测一上来就报"Unable to acquire connection"。连接池的size不是越大越好,200个连接已经能让单实例打满,继续加大会把客户端本身的线程调度拖慢。
坑二:Java RedisTemplate序列化乱码。
我在复现后半程用Spring Boot写了一个秒杀Manager来对比Java客户端表现,结果从Redis Desktop Manager里看到一堆
\xac\xed\x00\x05t\x00
开头的乱码。根因是RedisTemplate默认使用JdkSerializationRedisSerializer,Key被序列化成Java二进制。修复方式:Key用StringRedisSerializer,Value用Jackson2JsonRedisSerializer,这样Redis里看到的就是可读字符串和JSON,排查问题方便,也避免跨语言客户端互相读不懂。
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
坑三:BigKey阻塞主线程。
我没有直接写BigKey,但模拟过一种情况:把某商品当天所有的流水都追加到同一个List里,压测到一半,Redis的延迟从1ms掉到300ms。这符合BigKey的症状。排查时用
redis-cli --bigkeys
扫描,或者
DEBUG OBJECT key
看serializedlength,都能发现单个Key已经存了几十万条记录。Redis是单线程执行命令,BigKey的读写和删除都会长时间占用主线程,后面的请求全部排队。
根治办法是换数据结构:流水按分钟拆分Key,或者直接进Stream按消息ID消费,而不是堆在List里越积越大。流量高峰期间不要用
KEYS
命令做全量扫描,它会阻塞整个Redis,应该用
SCAN
游标方式分批迭代。
坑四:慢查询监控缺失。
前期没开慢日志,出了问题全靠猜。开启方式是:
slowlog-log-slower-than 10000
slowlog-max-len 128
单位是微秒,10000微秒即10毫秒。压测结束后用
SLOWLOG GET 20
查看慢命令列表,排查是不是有非预期的
HGETALL
或者
KEYS
混进了业务逻辑。这套监控手段应该是Redis主库上线前的强制标配,不是可选项。
5. 复现结果复盘:我到底验证了什么,又证伪了什么
5.1 实测数据:单点QPS、集群吞吐、故障转移耗时
全部实验做完,有用的数据我整理成了一张表:
| 项目 | 实测结果 | 环境说明 |
|---|---|---|
| 单实例SET QPS | 6-10万,取决于pipeline配置 | 2核4G虚机,Redis 7.0 |
| 单实例GET QPS | 8-11.5万 | 同上 |
| 哨兵故障转移耗时 | 约9秒 | 5秒判down + 4秒选举切换 |
| 故障转移期间的写入 | 全部报错,约7秒窗口不可写 | 主库已挂,新主未选出 |
| 10000件库存扣减完成 | 12.6秒,约0.7万QPS | Lua脚本+HASH结构 |
单点就能稳定跑出十万级QPS,意味着一个Redis主库配上一个从库,支撑日请求量上亿的业务是完全成立的——1亿请求分摊到一天,峰值无非就是几万QPS。这也是"亿级流量"这个标题没有完全夸张的地方,前提是你把"亿级"理解成日请求量而非持续峰值。
但故障转移的9秒窗口让我很清醒。这9秒里所有写入Redis主库的请求都会失败,如果是账务系统早就炸了。所以Redis合适的是"丢了最后几秒还能补"的业务,秒杀扣减丢了库存记录,后台Stream里还有流水可以重放;银行转账丢了,那就不是重放能解决的问题。
5.2 "亿级流量"的真实含义:峰值QPS还是日活量
很多被标题唬住的人,第一个问题都是"Redis凭什么扛住亿级流量"。我把这个亿级认真算了一笔账:如果是1亿次请求每天,一天86400秒,平均QPS只有约1157,就算业务峰值是均值的10倍,也就是约1.2万QPS。这个量级用一个Redis主库加两个从库,在正确配置下绰绰有余。
如果是1亿次请求每秒,那性质完全不同,那不是数据库的范畴,是CDN和接入网关的范畴,而且一定是从边缘节点就开始分流,Redis只是其中一环。这两个"亿级"差着十万八千里,但都被包装成同一个"亿级流量"。
所以复现这个项目最有价值的部分,是我亲手测出了Redis的吞吐边界和故障边界。它让我再看到"亿级流量"这种词时,第一反应不是兴奋,而是先问清楚:你说的是日活请求还是峰值并发?数据量多大?可不可以忍受秒级丢失?这三个问题直接决定架构怎么设计。
5.3 真正的"Redis为主"长什么样
复盘整个实验,我最后画在纸上的架构是这样的:
- 流量入口层:Redis哨兵集群,读写都走它,库存扣减用Lua脚本保证原子性。
- 数据落地层:Redis Stream承载订单流水,后台消费者组异步消费,回写MySQL作为最终持久化。
- 对账修正层:定时任务比对Redis里的已售数量和MySQL里的流水条数,不一致时以流水记录为准修正Redis。
- 高可用层:一主两从三哨兵,主库故障后哨兵自动把从库提升为新主,客户端通过哨兵感知新主地址。
这套架构的本质是"让Redis承担最前线的读写压力,让MySQL退到最终一致性的位置"。它不是替代MySQL,而是给MySQL减负,让它只处理真正需要复杂查询和强一致性的数据。
我在压测完又做了一件事:把哨兵集群里的主库容器直接
docker stop
,然后观察整个故障切换的过程。大约5秒后哨兵判定主观下线,再等几秒从库完成晋升,新主开始接收写入。这个实验做一次你就明白了,为什么Redis做主库对高可用架构的要求那么苛刻——客户端不是简单配置一个IP就完事的,要用哨兵的地址去动态获取当前主节点,Java里用Lettuce的MasterSlave配置,Python里用redis.sentinel.Sentinel对象去拿写节点,应用层必须无感知地跟随切换。
这套方案跑通之后,我可以很负责任地说:Redis做主库不是传闻,也不是银弹。它是特定场景下性价比极高的存储方案,但数据模型、成本、故障窗口这三个约束条件一个都不能放松。如果业务的数据量已经大到内存放不下,或者需要跨行事务和复杂查询,老老实实把MySQL请回来才是最优解。
复现到最后一晚,我又把压测数据翻出来看了一遍,其实最有价值的不是我测出了多高的QPS,而是我确认了Redis在高并发写入下的延迟稳定性——在Lua脚本被正确使用、连接池合理配置的前提下,Redis的P99延迟一直稳定在3ms以内,这才是它能当主库的真正底气。顺便提一个小建议:如果你们团队在争论"要不要引入Redis做主库",别听任何人的PPT,自己搭一套环境压一遍,把故障转移的窗口实测出来,决策自然就清楚了。
更多推荐



所有评论(0)