缓存与数据库的双写一致性探讨
2022-06-145.4k 阅读
缓存与数据库双写一致性问题的背景
在现代后端开发中,缓存(如 Redis)和数据库(如 MySQL)是构建高性能、高可用应用系统的关键组件。缓存的存在主要是为了减轻数据库的负载,提高系统的响应速度。当应用程序需要读取数据时,首先尝试从缓存中获取,如果缓存中不存在,则从数据库中读取,然后将数据放入缓存。而在数据写入时,情况则变得复杂起来,这就引出了缓存与数据库双写一致性的问题。
假设一个简单的电商场景,商品的库存数据存储在数据库中,同时在缓存中也有缓存副本。当有用户下单购买商品时,库存数据需要更新。如果先更新数据库,再更新缓存,在这两个操作之间,另一个读取请求过来,可能会从缓存中读取到旧数据,导致数据不一致。同样,如果先更新缓存,再更新数据库,在数据库更新失败时,也会造成数据不一致。这种不一致可能会引发一系列严重问题,如超卖、数据混乱等,严重影响业务的正常运转。
缓存与数据库双写的常见策略及问题分析
- 先更新数据库,再更新缓存
- 原理:在数据发生变化时,首先对数据库进行更新操作,确保数据库中的数据是最新的。然后,更新相应的缓存数据,使得缓存中的数据与数据库保持一致。
- 代码示例(以 Python 和 MySQL、Redis 为例):
import mysql.connector
import redis
# 连接数据库
db = mysql.connector.connect(
host="localhost",
user="root",
password="password",
database="test"
)
cursor = db.cursor()
# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db = 0)
def update_data():
# 更新数据库
sql = "UPDATE products SET stock = stock - 1 WHERE product_id = 1"
cursor.execute(sql)
db.commit()
# 更新缓存
new_stock = cursor.lastrowid
r.set('product:1:stock', new_stock)
- 存在问题:在高并发场景下,可能会出现缓存更新失败的情况。例如,更新数据库成功后,由于网络故障等原因,缓存更新操作未能成功执行,此时缓存中的数据与数据库不一致。而且,如果在更新数据库和更新缓存之间有读取请求,会读到旧的缓存数据。
- 先更新缓存,再更新数据库
- 原理:先对缓存中的数据进行更新,使得应用程序后续读取时能获取到最新数据。然后,再对数据库进行更新,保证持久化数据的一致性。
- 代码示例:
import mysql.connector
import redis
# 连接数据库
db = mysql.connector.connect(
host="localhost",
user="root",
password="password",
database="test"
)
cursor = db.cursor()
# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db = 0)
def update_data():
# 更新缓存
current_stock = r.get('product:1:stock')
if current_stock:
new_stock = int(current_stock) - 1
r.set('product:1:stock', new_stock)
# 更新数据库
sql = "UPDATE products SET stock = stock - 1 WHERE product_id = 1"
cursor.execute(sql)
db.commit()
- 存在问题:如果在更新缓存后,数据库更新失败,会导致缓存中的数据与数据库不一致。而且,在高并发环境下,可能会出现缓存击穿问题。例如,一个写操作更新了缓存,但数据库更新还未完成时,另一个写操作也来更新缓存,此时第一个写操作对数据库的更新可能会被覆盖,造成数据不一致。
- 先删除缓存,再更新数据库
- 原理:当数据发生变化时,首先删除缓存中的数据。这样,下次读取数据时,缓存中不存在数据,会从数据库中读取最新数据并重新放入缓存,从而保证数据一致性。
- 代码示例:
import mysql.connector
import redis
# 连接数据库
db = mysql.connector.connect(
host="localhost",
user="root",
password="password",
database="test"
)
cursor = db.cursor()
# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db = 0)
def update_data():
# 删除缓存
r.delete('product:1:stock')
# 更新数据库
sql = "UPDATE products SET stock = stock - 1 WHERE product_id = 1"
cursor.execute(sql)
db.commit()
- 存在问题:在高并发场景下,可能会出现缓存击穿问题。如果在删除缓存和更新数据库之间有大量读取请求,这些请求会同时穿透到数据库,可能会导致数据库压力过大甚至崩溃。另外,如果数据库更新成功,但在重新写入缓存前,又有删除缓存的操作(例如另一个写操作),也会造成数据不一致。
- 先更新数据库,再删除缓存
- 原理:首先对数据库进行更新操作,确保数据的持久化存储是最新的。然后,删除相应的缓存数据,使得下次读取时从数据库获取最新数据并更新缓存。
- 代码示例:
import mysql.connector
import redis
# 连接数据库
db = mysql.connector.connect(
host="localhost",
user="root",
password="password",
database="test"
)
cursor = db.cursor()
# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db = 0)
def update_data():
# 更新数据库
sql = "UPDATE products SET stock = stock - 1 WHERE product_id = 1"
cursor.execute(sql)
db.commit()
# 删除缓存
r.delete('product:1:stock')
- 存在问题:在高并发环境下,也可能出现不一致情况。例如,一个写操作更新了数据库并删除了缓存,在新数据还未重新写入缓存时,另一个读操作过来,从数据库读取到旧数据并写入缓存,而此时数据库中的数据已经是新的,就造成了缓存与数据库不一致。
解决缓存与数据库双写一致性的方案探讨
- 使用分布式事务
- 原理:分布式事务可以保证在多个操作(如数据库更新和缓存更新)之间的原子性,要么所有操作都成功,要么都失败。常见的分布式事务解决方案有两阶段提交(2PC)和三阶段提交(3PC)。
- 以 2PC 为例:在数据更新时,协调者首先向所有参与者(数据库和缓存)发送准备消息。如果所有参与者都准备成功,协调者再发送提交消息;如果有任何一个参与者准备失败,协调者发送回滚消息。
- 代码示例(使用 Seata 框架实现分布式事务,以 Java 为例):
import io.seata.spring.annotation.GlobalTransactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import redis.clients.jedis.Jedis;
@Service
public class ProductService {
@Autowired
private JdbcTemplate jdbcTemplate;
@GlobalTransactional
public void updateStock(int productId, int amount) {
// 更新数据库
jdbcTemplate.update("UPDATE products SET stock = stock -? WHERE product_id =?", amount, productId);
// 更新缓存
try (Jedis jedis = new Jedis("localhost", 6379)) {
jedis.decrBy("product:" + productId + ":stock", amount);
}
}
}
- 优点:能严格保证数据的一致性。
- 缺点:性能开销较大,因为涉及多次网络交互。而且 2PC 存在单点故障问题(协调者故障可能导致事务无法完成),3PC 虽然在一定程度上解决了单点故障问题,但实现复杂。
- 使用消息队列(MQ)
- 原理:将缓存更新操作异步化。当数据发生变化时,先更新数据库,然后将缓存更新操作发送到消息队列。消息队列按顺序处理消息,依次执行缓存更新操作,从而避免高并发场景下的不一致问题。
- 代码示例(以 RabbitMQ 和 Python 为例):
import pika
import mysql.connector
import redis
# 连接数据库
db = mysql.connector.connect(
host="localhost",
user="root",
password="password",
database="test"
)
cursor = db.cursor()
# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db = 0)
# 连接 RabbitMQ
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
channel.queue_declare(queue='cache_update')
def update_data():
# 更新数据库
sql = "UPDATE products SET stock = stock - 1 WHERE product_id = 1"
cursor.execute(sql)
db.commit()
# 发送缓存更新消息到队列
channel.basic_publish(exchange='', routing_key='cache_update', body='product:1:stock')
def consume_messages():
def callback(ch, method, properties, body):
key = body.decode('utf - 8')
new_stock = get_stock_from_db()
r.set(key, new_stock)
channel.basic_consume(queue='cache_update', on_message_callback=callback, auto_ack=True)
channel.start_consuming()
def get_stock_from_db():
sql = "SELECT stock FROM products WHERE product_id = 1"
cursor.execute(sql)
result = cursor.fetchone()
if result:
return result[0]
return 0
- 优点:通过异步处理,降低了系统的响应时间,提高了整体性能。而且消息队列可以保证消息的顺序性,一定程度上解决了高并发下的一致性问题。
- 缺点:引入了新的组件,增加了系统的复杂性。消息队列可能会出现消息丢失、重复消费等问题,需要额外的机制来保证消息的可靠性。
- 设置缓存过期时间
- 原理:为缓存数据设置一个合理的过期时间。在数据更新后,即使缓存与数据库暂时不一致,当缓存过期后,下次读取时会从数据库获取最新数据并重新放入缓存,从而保证最终一致性。
- 代码示例:
import mysql.connector
import redis
# 连接数据库
db = mysql.connector.connect(
host="localhost",
user="root",
password="password",
database="test"
)
cursor = db.cursor()
# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db = 0)
def update_data():
# 更新数据库
sql = "UPDATE products SET stock = stock - 1 WHERE product_id = 1"
cursor.execute(sql)
db.commit()
# 删除缓存或设置较短过期时间
r.setex('product:1:stock', 300, get_stock_from_db())
def get_stock_from_db():
sql = "SELECT stock FROM products WHERE product_id = 1"
cursor.execute(sql)
result = cursor.fetchone()
if result:
return result[0]
return 0
- 优点:实现简单,不需要引入复杂的分布式事务或消息队列机制。
- 缺点:在缓存过期前,可能会存在数据不一致的情况,对于一些对数据一致性要求极高的场景不太适用。而且过期时间的设置需要根据业务场景仔细权衡,如果设置过短,会增加数据库的读取压力;设置过长,不一致时间会延长。
- 使用读写锁
- 原理:在数据更新时,获取写锁,禁止其他读操作和写操作。更新完成后释放写锁。在读取数据时,获取读锁,允许多个读操作同时进行,但禁止写操作。这样可以避免在更新过程中有读操作获取到不一致的数据。
- 代码示例(以 Java 的 ReentrantReadWriteLock 为例):
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ProductCache {
private static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private static int stock;
public static void updateStock(int amount) {
lock.writeLock().lock();
try {
// 更新数据库和缓存的逻辑
stock = stock - amount;
} finally {
lock.writeLock().unlock();
}
}
public static int getStock() {
lock.readLock().lock();
try {
return stock;
} finally {
lock.readLock().unlock();
}
}
}
- 优点:可以有效避免在更新过程中的并发读取导致的不一致问题。
- 缺点:会降低系统的并发性能,因为写锁会阻塞其他读操作和写操作,读锁也会阻塞写操作。在高并发场景下,可能会导致系统性能瓶颈。
不同业务场景下的策略选择
- 对一致性要求极高且并发量较低的场景:可以选择使用分布式事务。虽然其性能开销较大,但能严格保证数据的一致性,适用于如金融交易等对数据准确性要求极高的业务场景。例如,在银行转账业务中,每一笔转账的金额更新必须保证数据库和缓存数据完全一致,否则可能导致资金错误。
- 对性能要求较高且一致性要求相对宽松的场景:设置缓存过期时间是一个不错的选择。如一些新闻资讯类应用,对于新闻内容的展示,即使在短时间内缓存与数据库存在不一致,用户也不会有太大感知,而且通过设置合理的过期时间,可以在保证一定性能的同时达到最终一致性。
- 高并发且对一致性有一定要求的场景:消息队列是比较合适的。例如电商的库存更新场景,通过消息队列异步处理缓存更新,可以避免高并发下的缓存与数据库不一致问题,同时保证系统的高性能和响应速度。而且通过消息队列的重试机制和幂等性设计,可以有效解决消息丢失和重复消费等问题。
- 读多写少且对一致性有一定要求的场景:读写锁可以在一定程度上满足需求。例如一些大型网站的用户信息展示,读操作远远多于写操作,通过读写锁可以在保证数据一致性的前提下,尽量减少对读操作性能的影响。
实际应用中的注意事项
- 缓存穿透的防范:无论采用哪种双写策略,都要注意缓存穿透问题。可以采用布隆过滤器(Bloom Filter)来预防。布隆过滤器可以快速判断一个数据是否存在,如果不存在则直接返回,避免请求穿透到数据库。例如,在电商商品查询中,先通过布隆过滤器判断商品 ID 是否存在,不存在则不查询数据库,从而减轻数据库压力。
- 缓存雪崩的防范:为了防止缓存雪崩,即大量缓存同时过期导致数据库压力瞬间增大,可以采用随机过期时间的方式。为每个缓存数据设置一个在一定范围内的随机过期时间,避免所有缓存同时过期。同时,可以使用二级缓存,当一级缓存失效时,从二级缓存获取数据,减轻数据库压力。
- 缓存击穿的防范:对于缓存击穿问题,可以使用互斥锁(Mutex)来解决。在缓存失效时,只允许一个请求去查询数据库并更新缓存,其他请求等待。这样可以避免大量请求同时穿透到数据库。例如,在抢购活动中,对于商品库存的缓存,可以在缓存失效时使用互斥锁保证只有一个请求去更新缓存,防止数据库压力过大。
- 幂等性设计:在使用消息队列等异步更新缓存的方案时,要保证缓存更新操作的幂等性。即多次执行相同的操作,结果应该是一致的。例如,在更新商品库存缓存时,可以使用版本号或者时间戳来保证幂等性。如果消息重复消费,根据版本号判断,如果已经是最新版本,则不进行重复更新。
- 监控与报警:在实际生产环境中,要对缓存和数据库的一致性进行监控。可以通过定时任务检查缓存与数据库数据的差异,一旦发现不一致,及时报警并进行修复。例如,使用 Prometheus 和 Grafana 等工具进行监控和可视化展示,及时发现和处理一致性问题。
通过深入理解缓存与数据库双写一致性的问题本质,结合不同的业务场景选择合适的解决方案,并注意实际应用中的各种细节,可以有效解决缓存与数据库双写一致性问题,构建高性能、高可用且数据一致的后端系统。