电商秒杀数据库崩溃的教训 读写分离分库分表加Redis缓存与连接池优化提升MySQL并发能力
记得那是2023年618大促的前夜,我和团队刚接到一个紧急需求——优化秒杀系统的数据库架构。原因很简单:上周的测试中,我们的秒杀活动把MySQL直接干崩了。那晚,整个团队的咖啡都喝光了,屏幕上全是红色的报错日志。
一、崩溃现场:我们到底做错了什么
那天是个周二下午,运营同学发了个好消息:某爆款商品预定秒杀,预估峰值QPS在5万左右。我们在测试环境模拟了一下,结果——MySQL直接挂了。
ERROR 1040 (HY000): Too many connections
ERROR 2013 (HY000): Lost connection to MySQL server during query
我们当时的架构简直”惨不忍睹”:
用户请求 → Web服务器 → 单台MySQL主库(读写混用)
没有缓存,没有分库分表,连连接池都配置得乱七八糟。MySQL默认的最大连接数是151,我们生产环境配置成了500,但每次秒杀活动一来,瞬时并发轻松突破2000+,数据库直接拒绝新连接。
更致命的是,查询和写入混在一起。查询需要锁表,写入也需要锁表,互相排队,响应时间从几十毫秒飙升到几秒甚至超时。
那一刻我们终于明白:用单点MySQL扛秒杀,无异于用自行车送快递——不是不能用,是根本扛不住。
二、先搞清楚:秒杀场景到底在压什么
在我们动手之前,必须先搞清楚问题的本质。很多团队一上来就搞技术选型,结果做了半天,问题还是没解决。
秒杀请求的特征
时间维度:
- 集中在某一秒爆发(秒杀开始的那一刻)
- 持续时间很短(几分钟到半小时)
- 之后迅速回落
请求类型:
- 大部分是读请求(查询库存、商品详情)
- 少量是写请求(下单、扣库存)
- 写请求有强烈的竞争(多人抢同一件商品)
数据特征:
- 热点数据集中(就那几件秒杀商品)
- 非热点数据分散(普通商品浏览)
普通查询 vs 秒杀查询
-- 普通查询:读多写少,数据分散
SELECT * FROM products WHERE category_id = 1024;
-- 秒杀查询:极高并发读 + 激烈竞争写
-- 1. 查询秒杀商品详情(高并发读)
SELECT * FROM seckill_products WHERE id = 8888;
-- 2. 扣减库存(激烈竞争写,可能上百人同时抢同一件)
UPDATE seckill_products SET stock = stock - 1 WHERE id = 8888 AND stock > 0;
看到区别了吗?普通业务数据库设计可以”差不多就行”,但秒杀场景,每一个环节都得精心设计。
三、第一道防线:Redis缓存层
我们做的第一件事,就是把所有热点数据全部放进Redis。这不是什么新技术,但效果立竿见影。
缓存架构设计
用户请求 → Web服务器 → Redis缓存 → MySQL数据库(缓存未命中时)
↓
热点数据优先加载
商品详情缓存
秒杀商品的信息相对稳定,不会每秒都变。我们把商品信息完全缓存到Redis:
import redis
import json
import time
class SeckillCache:
def __init__(self):
self.redis = redis.Redis(
host='10.0.1.100',
port=6379,
db=0,
password='your_password',
decode_responses=True,
max_connections=50 # 连接池大小
)
# 获取商品详情,带缓存
def get_product(self, product_id):
cache_key = f"seckill:product:{product_id}"
# 先查Redis
product_json = self.redis.get(cache_key)
if product_json:
return json.loads(product_json)
# Redis没有,查MySQL
product = self._query_from_db(product_id)
if product:
# 写入Redis,设置过期时间(防止缓存穿透)
self.redis.setex(cache_key, 300, json.dumps(product)) # 5分钟过期
return product
return None
def _query_from_db(self, product_id):
# 这里走的是从库,主从分离后从库负责读
pass
库存缓存:最关键的部分
库存是最难处理的,因为它是实时的,多人同时扣减。如果用Redis做库存扣减,必须保证原子性:
class StockService:
def __init__(self, redis_client):
self.redis = redis_client
# 使用Lua脚本保证原子性
def deduct_stock(self, product_id, quantity=1):
# Lua脚本,原子操作
lua_script = """
local stock_key = KEYS[1]
local quantity = tonumber(ARGV[1])
local current_stock = redis.call('GET', stock_key)
if current_stock == false then
return -1 -- 库存不存在
end
current_stock = tonumber(current_stock)
if current_stock < quantity then
return 0 -- 库存不足
end
-- 扣减库存
redis.call('DECRBY', stock_key, quantity)
return 1 -- 扣减成功
"""
cache_key = f"seckill:stock:{product_id}"
result = self.redis.eval(lua_script, 1, cache_key, quantity)
return result
# 预热库存到Redis
def warmup_stock(self, product_id, stock):
cache_key = f"seckill:stock:{product_id}"
self.redis.set(cache_key, stock)
用Lua脚本的目的是保证原子性。如果用两条Redis命令(先GET再SET),在高并发下会出现竞态条件。Lua脚本在Redis中是原子执行的,不会出现两个请求同时读到同一个库存值然后都扣减的情况。
缓存穿透、击穿、雪崩的应对
class CacheProtection:
def __init__(self, redis_client):
self.redis = redis_client
# 缓存穿透:查询不存在的数据
def get_with_bloom_filter(self, product_id):
# 使用布隆过滤器判断数据是否存在
bloom_key = "seckill:bloom"
if not self.redis.hget(bloom_key, str(product_id)):
return None # 布隆过滤器说没有,大概率真的没有
# 正常查询流程
return self.seckill_cache.get_product(product_id)
# 缓存击穿:热点key过期瞬间
def get_with_lock(self, product_id):
cache_key = f"seckill:product:{product_id}"
lock_key = f"seckill:lock:{product_id}"
# 先查缓存
product_json = self.redis.get(cache_key)
if product_json:
return json.loads(product_json)
# 缓存没有,尝试获取分布式锁
if self.redis.set(lock_key, "1", ex=10, nx=True):
try:
# double-check(双重检查)
product_json = self.redis.get(cache_key)
if product_json:
return json.loads(product_json)
# 查数据库
product = self._query_from_db(product_id)
if product:
self.redis.setex(cache_key, 300, json.dumps(product))
else:
# 缓存空值,防止穿透
self.redis.setex(cache_key, 60, json.dumps({}))
return product
finally:
self.redis.delete(lock_key)
else:
# 其他线程在重建缓存,等待重试
time.sleep(0.1)
return self.get_with_lock(product_id) # 递归重试
四、第二道防线:读写分离
缓存解决了读多写少的问题,但还有一些查询走不到缓存,或者缓存未命中的情况。这时候,读写分离就派上用场了。
架构设计
┌─────────────┐
│ 主库 (Master) │
│ 负责写操作 │
└──────┬──────┘
│ 异步复制
▼
┌────────────────────────┐
│ 从库 (Slaves) │
│ 负责读操作(可多从) │
└────────────────────────┘
读写分离的实现
import pymysql
from db_utils import read_db_pool, write_db_pool
class ReadOnlyWriteService:
def __init__(self):
# 写操作走主库
self.write_db = write_db_pool
# 读操作走从库
self.read_db = read_db_pool
# 写操作
def create_order(self, order_data):
conn = self.write_db.get_connection()
try:
cursor = conn.cursor()
sql = """
INSERT INTO orders
(user_id, product_id, quantity, total_price, status, create_time)
VALUES (%s, %s, %s, %s, %s, NOW())
"""
cursor.execute(sql, (
order_data['user_id'],
order_data['product_id'],
order_data['quantity'],
order_data['total_price'],
'pending'
))
conn.commit()
return cursor.lastrowid
finally:
self.write_db.release_connection(conn)
# 读操作
def get_product(self, product_id):
conn = self.read_db.get_connection()
try:
cursor = conn.cursor()
sql = "SELECT * FROM products WHERE id = %s"
cursor.execute(sql, (product_id,))
return cursor.fetchone()
finally:
self.read_db.release_connection(conn)
主从复制的原理
MySQL主从复制是基于binlog的:
主库操作:
1. 执行写操作(INSERT/UPDATE/DELETE)
2. 将操作记录到binlog
3. 从库通过I/O线程读取binlog
4. 从库通过SQL线程回放binlog
延迟问题:
- 异步复制:主库不等待从库,延迟可能几秒
- 半同步复制:主库等待至少一个从库确认
- 在秒杀场景中,我们用的是异步复制,读操作可以接受一定延迟
读写分离的注意事项
# 关键原则:写完之后读不到最新数据
# 场景:用户刚下单,立马来查询订单状态
# 如果用从库查询,可能查不到刚写的订单
# 解决方案:自己写的自己读
class ReadWriteConsistency:
def create_and_read_order(self, order_data):
# 1. 写操作走主库
order_id = self.create_order(order_data)
# 2. 读操作也走主库(或者缓存)
# 避免读到从库的旧数据
conn = self.write_db.get_connection()
try:
cursor = conn.cursor()
sql = "SELECT * FROM orders WHERE id = %s"
cursor.execute(sql, (order_id,))
return cursor.fetchone()
finally:
self.write_db.release_connection(conn)
五、第三道防线:分库分表
即使做了读写分离,当数据量达到千万级、并发达到数万QPS时,单库的压力依然巨大。这时候必须分库分表。
为什么要分库分表
单库的瓶颈:
1. 连接数瓶颈:单库最多处理几百个并发连接
2. 磁盘I/O瓶颈:单盘读写有上限
3. CPU瓶颈:单CPU处理能力有限
4. 内存瓶颈:Buffer Pool大小有限
5. 锁竞争瓶颈:行锁、表锁争用
分库分表后:
- 连接数分散到多个库
- 数据分散到多个表
- 并发处理能力线性增长
分库策略
# 按用户ID分库:同一用户的数据在同一个库
# 好处:用户相关查询不走跨库
class ShardingStrategy:
def __init__(self, num_shards=8):
self.num_shards = num_shards
def get_shard_index(self, user_id):
# 取模分片
return int(user_id) % self.num_shards
def get_db_name(self, user_id):
shard_index = self.get_shard_index(user_id)
return f"seckill_db_{shard_index}"
def get_table_name(self, table_name, user_id):
shard_index = self.get_shard_index(user_id)
return f"{table_name}_{shard_index}"
分表策略
-- 假设我们分8个库,每个库2张表
-- 总共16张表
-- 创建分表
CREATE TABLE orders_0 (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL,
total_price DECIMAL(10,2),
status VARCHAR(20),
create_time DATETIME,
INDEX idx_user_id (user_id)
);
CREATE TABLE orders_1 (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL,
total_price DECIMAL(10,2),
status VARCHAR(20),
create_time DATETIME,
INDEX idx_user_id (user_id)
);
-- ... 以此类推到 orders_15
分库分表的路由
class ShardingRouter:
def __init__(self, num_shards=8):
self.num_shards = num_shards
def route_table(self, table_name, user_id):
shard_index = int(user_id) % self.num_shards
return f"{table_name}_{shard_index}"
def route_db(self, user_id):
shard_index = int(user_id) % self.num_shards
return f"seckill_db_{shard_index}"
# 查询订单
def query_order(self, order_id, user_id):
table_name = self.route_table("orders", user_id)
db_name = self.route_db(user_id)
sql = f"SELECT * FROM `{db_name}`.`{table_name}` WHERE id = %s"
# 执行查询...
分库分表的难点
1. 跨库查询: JOIN、分页、排序都需要额外处理
解决方案:避免跨库查询,需要时走ES等搜索引擎
2. 全局ID生成: 需要分布式ID
解决方案:使用雪花算法(Snowflake)
3. 扩容问题: 分片后数据重新分配很麻烦
解决方案:提前规划好分片数量,预留扩容空间
4. 复杂查询: 不能再用原生SQL了
解决方案:使用ShardingSphere等中间件
雪花算法生成全局ID
import time
import threading
class SnowflakeID:
def __init__(self, worker_id=1):
self.worker_id = worker_id
self.sequence = 0
self.last_timestamp = -1
self.lock = threading.Lock()
# 64位ID组成:
# 1位符号位(固定0)
# 41位时间戳(毫秒级,可用69年)
# 5位数据中心ID
# 5位机器ID
# 12位序列号(每毫秒最多4096个ID)
self.worker_id_bits = 5
self.datacenter_id_bits = 5
self.sequence_bits = 12
self.max_worker_id = -1 ^ (-1 << self.worker_id_bits)
self.max_datacenter_id = -1 ^ (-1 << self.datacenter_id_bits)
self.worker_id_shift = self.sequence_bits
self.datacenter_id_shift = self.sequence_bits + self.worker_id_bits
self.timestamp_shift = self.sequence_bits + self.worker_id_bits + self.datacenter_id_bits
self.sequence_mask = -1 ^ (-1 << self.sequence_bits)
def _til_next_millis(self, last_timestamp):
timestamp = time.time() * 1000
while timestamp <= last_timestamp:
timestamp = time.time() * 1000
return timestamp
def generate_id(self):
with self.lock:
timestamp = time.time() * 1000
if timestamp < self.last_timestamp:
raise Exception("Clock moved backwards")
if timestamp == self.last_timestamp:
self.sequence = (self.sequence + 1) & self.sequence_mask
if self.sequence == 0:
timestamp = self._til_next_millis(self.last_timestamp)
else:
self.sequence = 0
self.last_timestamp = timestamp
return ((int(timestamp) & 0xFFFFFFFFFFF) << self.timestamp_shift) | \
(self.worker_id << self.worker_id_shift) | \
self.sequence
# 使用
id_generator = SnowflakeID(worker_id=1)
order_id = id_generator.generate_id()
六、第四道防线:连接池优化
就算前面三关都过了,如果连接池配置不当,MySQL依然会崩溃。
连接池的问题
默认配置的问题:
- MySQL默认最大连接数:151
- 连接池默认大小:10-20
- 连接超时时间:太长或太短都不行
秒杀场景下的问题:
- 瞬间高并发,连接池不够用
- 连接泄漏(用完没释放)
- 连接超时,排队等待
连接池的配置优化
import pymysql
from dbutils.pooled_db import PooledDB
import threading
class ConnectionPool:
def __init__(self, config):
self.config = config
self.pool = None
self._lock = threading.Lock()
def get_pool(self):
if self.pool is None:
with self._lock:
if self.pool is None:
self.pool = PooledDB(
creator=pymysql,
# 最小连接数
mincached=20,
# 最大连接数(根据MySQL max_connections配置)
maxcached=100,
# 最大共享连接数
maxshared=50,
# 最大连接数
maxconnections=200,
# 连接阻塞数
blocking=True,
# 最大等待时间
maxusage=1000,
# 会话初始设置
setsession=[],
# 过期检查
reset=True,
# 主机配置
host=config['host'],
port=config['port'],
user=config['user'],
password=config['password'],
database=config['database'],
charset=config.get('charset', 'utf8mb4'),
# 连接超时
connect_timeout=5,
# 读写超时
read_timeout=30,
write_timeout=30,
# 自动重连
autocommit=True
)
return self.pool
def get_connection(self):
pool = self.get_pool()
return pool.connection()
def release_connection(self, conn):
pool = self.get_pool()
pool.putconn(conn)
MySQL端连接参数优化
-- MySQL配置文件 my.cnf 或 my.ini
[mysqld]
# 最大连接数(根据服务器内存调整,通常1000-2000)
max_connections = 2000
# 每个连接的最大线程数
thread_cache_size = 500
# 等待超时(秒),空闲连接超时后断开
wait_timeout = 30
interactive_timeout = 30
# 连接超时
connect_timeout = 10
# 网络缓冲
net_read_timeout = 30
net_write_timeout = 60
# 临时表
max_heap_table_size = 64M
tmp_table_size = 64M
# 排序和join缓冲区
sort_buffer_size = 2M
read_buffer_size = 2M
read_rnd_buffer_size = 2M
join_buffer_size = 8M
# InnoDB配置
innodb_buffer_pool_size = 4G -- 根据内存调整,通常是总内存的50-70%
innodb_log_file_size = 512M
innodb_flush_log_at_trx_commit = 2 -- 每秒刷盘,平衡性能和安全性
innodb_flush_method = O_DIRECT
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000
连接池监控
import psutil
import pymysql
class ConnectionPoolMonitor:
def __init__(self, pool):
self.pool = pool
def get_stats(self):
"""获取连接池状态"""
stats = {
'connected': self.pool._used_connections,
'idle': self.pool._idle_cache,
'created': self.pool._created_connections,
'max_connections': self.pool._maxconnections
}
return stats
def get_mysql_status(self):
"""获取MySQL状态"""
conn = self.pool.connection()
try:
cursor = conn.cursor()
cursor.execute("SHOW STATUS LIKE 'Threads_connected'")
threads_connected = cursor.fetchone()[1]
cursor.execute("SHOW STATUS LIKE 'Threads_running'")
threads_running = cursor.fetchone()[1]
cursor.execute("SHOW STATUS LIKE 'Questions'")
questions = cursor.fetchone()[1]
return {
'threads_connected': threads_connected,
'threads_running': threads_running,
'questions': questions
}
finally:
self.pool.putconn(conn)
def check_health(self):
"""检查连接池健康状态"""
stats = self.get_mysql_status()
# 如果当前连接数超过最大连接数的80%,发出警告
if stats['threads_connected'] > 1600:
print(f"警告:MySQL连接数过高!{stats['threads_connected']}/2000")
# 如果正在执行的线程数过多,说明有慢查询
if stats['threads_running'] > 50:
print(f"警告:MySQL活跃线程过多!{stats['threads_running']}")
七、完整架构:四层防护如何协同工作
┌─────────────────────────┐
│ 负载均衡 (Nginx) │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Web服务器集群 │
└────────────┬────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌─────────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐
│ Redis集群 │ │ 消息队列 │ │ 限流/熔断器 │
│ - 商品详情缓存 │ │ - 订单异步 │ │ - 令牌桶限流 │
│ - 库存扣减 │ │ - 秒杀记录 │ │ - 降级开关 │
└─────────┬────────┘ └──────┬──────┘ └────────┬────────┘
│ │ │
┌─────────▼────────────────▼──────────────────▼────────┐
│ 读写分离 + 分库分表 │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 主库 (写) │◄──►│ 从库 (读) │ │
│ │ - 下单 │ │ - 查询 │ │
│ │ - 扣库存 │ │ - 详情 │ │
│ └─────────────┘ └─────────────┘ │
└──────────────────────────────────────────────────────┘
请求流程示例
class SeckillService:
def __init__(self, cache_service, order_service, stock_service):
self.cache = cache_service
self.order = order_service
self.stock = stock_service
def seckill(self, user_id, product_id, quantity=1):
# 第一层:限流
if not self.rate_limiter.allow(user_id):
return {"success": False, "message": "请求过于频繁,请稍后再试"}
# 第二层:缓存查询商品
product = self.cache.get_product(product_id)
if not product:
return {"success": False, "message": "商品不存在"}
if product['status'] != 'active':
return {"success": False, "message": "秒杀活动未开始或已结束"}
# 第三层:Redis扣减库存(原子操作)
stock_result = self.stock.deduct_stock(product_id, quantity)
if stock_result == 0:
return {"success": False, "message": "库存不足"}
if stock_result == -1:
return {"success": False, "message": "秒杀未开始"}
# 第四层:写入订单(异步)
order_id = self.order.create_order({
'user_id': user_id,
'product_id': product_id,
'quantity': quantity,
'total_price': product['price'] * quantity
})
# 写入消息队列,异步处理后续流程
self.message_queue.send({
'type': 'order_created',
'order_id': order_id,
'user_id': user_id,
'product_id': product_id
})
return {"success": True, "order_id": order_id}
八、实战数据:优化前后的对比
改造完成后,我们做了一轮压力测试:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 最大并发连接数 | 500(直接崩溃) | 2000+(稳定运行) | - |
| 平均响应时间 | 3-5秒(超时) | 50-100ms | 98%↓ |
| QPS峰值 | 800 | 8000+ | 10倍↑ |
| MySQL CPU使用率 | 100%(崩溃) | 40-60% | 大幅降低 |
| 成功率 | 30% | 99.9% | - |
关键数字说明:
优化前:
- 5万QPS请求涌入,单库MySQL只能处理约800 QPS
- 其余请求排队,超时,崩溃
- 用户看到:页面转圈,然后报错
优化后:
- 5万QPS请求,Redis处理了4.5万(90%的读请求)
- 剩下的5000 QPS写入,通过分库分表分散到8个库
- 每个库实际只承担约600 QPS,完全在承受范围内
九、我们踩过的坑
坑一:缓存和数据库数据不一致
# 错误做法:先更新数据库,再删除缓存
# 如果删除缓存失败,数据库已经是新值,缓存是旧值
def update_product_wrong(product_id, new_price):
# 1. 更新数据库
db.update_product(product_id, new_price)
# 2. 删除缓存
redis.delete(f"product:{product_id}")
# 如果第2步失败,数据就不一致了
# 正确做法:先删除缓存,再更新数据库
# 或者使用延迟双删
def update_product_correct(product_id, new_price):
# 1. 先删除缓存
redis.delete(f"product:{product_id}")
# 2. 更新数据库
db.update_product(product_id, new_price)
# 3. 延迟再删一次缓存(防止并发问题)
time.sleep(0.5)
redis.delete(f"product:{product_id}")
坑二:分库分表后跨库查询
问题:订单表和订单详情表关联查询,但它们在分库之后可能不在同一个库
解决方案:
1. 避免跨库JOIN,拆分成多次查询
2. 使用ES做全文检索和复杂查询
3. 数据冗余,把需要的字段直接存到订单表
坑三:连接池配置不当
问题:连接池配置了500个连接,但MySQL max_connections只有200
结果:连接池创建了大量连接,但MySQL拒绝连接,全部超时
解决方案:
1. 连接池最大连接数 <= MySQL max_connections / 应用实例数
2. 例如:MySQL max_connections=2000,有4个应用实例
每个实例的连接池最大连接数 = 2000 / 4 = 500
坑四:Redis缓存雪崩
问题:大量缓存key同时过期,请求全部打到数据库
解决方案:
1. 过期时间加随机值(避免同时过期)
2. 使用互斥锁,只有一个线程重建缓存
3. 热点数据永不过期(定期刷新)
十、给还在用单库MySQL的同行们几句心里话
说实话,我们团队在那个崩溃的夜晚之后,真的反思了很久。之前总觉得”数据量不大,单库够用了”,结果一次大促就把我们打回原形。
几个真心建议:
别等到出问题再优化。架构升级是有成本的,越早做成本越低。我们在大促前一周才开始重构,整个团队连续熬了三个通宵,效率低、bug多、身心俱疲。如果提前规划,完全可以从容很多。
缓存是第一道防线。能放缓存的尽量放缓存,尤其是商品详情、秒杀库存这些热点数据。Redis的内存读取速度是微秒级,MySQL是毫秒级,差了一个数量级。
读写分离是最基本的优化。只要做一点改动,性能就能翻倍。我们当时把读操作全部切到从库,QPS直接翻了3倍。
分库分表要提前规划。分库分表一旦上线,后期扩容和数据迁移成本很高。我们在设计时就预留了8个库、16张表的容量,后续扩容很顺畅。
连接池不是越大越好。连接池太大反而会增加MySQL的负担。要根据MySQL的最大连接数和应用实例数合理配置。
最后想说,那次崩溃虽然痛苦,但也让我们彻底搞明白了高并发架构的核心逻辑。现在回头看,秒杀系统的优化本质上就是一场”把压力从数据库往外赶”的游戏:
用户请求 → 限流 → Redis缓存 → 读写分离 → 分库分表 → MySQL
↑
能挡多少挡多少
每一层都在帮下一层减负,最终MySQL只需要处理最核心的写操作。这种层层防御的思路,其实适用于很多高并发场景,不只是秒杀。
希望这篇总结能对正在为数据库性能发愁的朋友们有些帮助。如果你们也遇到过类似的问题,欢迎交流,我们一起想办法。
