Redis-场景理解

Redis 和 MySQL

为什么使用Redis

Redis 具备高性能高并发两大特性。

1. 高性能

Redis 以其卓越的性能著称,主要得益于Redis 将数据存储在内存中,而 MySQL 则主要依赖磁盘存储。内存的读写速度远高于磁盘,因此 Redis 能够显著提升数据访问速度。

2. 高并发

Redis 采用单线程模型,避免了多线程竞争带来的锁和上下文切换开销。这种设计使得 Redis 在处理高并发请求时表现出色,其 QPS(每秒查询次数)轻松突破 10 万。此外,Redis 还可以通过部署 Redis 切片集群进一步增加整个系统的吞吐量。

高并发情况等下,Redis+MySQL单点能有多大并发量?

  • 若命中Redis缓存,4C8G内存配置 ,单点Redis能够达到10wQPS。
  • 若未命中Redis缓存,4C8G内存配置,单点MySQL仅能达到5k左右QPS。

如何保证Redis和MySQL数据缓存一致性问题

在分布式系统中,缓存和数据库之间的数据一致性是一个常见的问题。为了保证数据的一致性,通常采用旁路缓存策略,即先更新数据库,再删除缓存。

缓存通过牺牲强一致性来实现高性能,也就是CAP理论中的AP模式。所以,要保持数据的强一致性,就不适合使用缓存。但是,我们可以通过一些缓存方案优化来保证最终一致性

消息队列方案

为了保证缓存删除操作的可靠性,可以引入消息队列,由消费者来完成缓存删除操作。

旁路缓存机制(图片来源小林coding)
  1. 生产者:在写数据时,将删除缓存的操作发送到消息队列中。
1
2
3
4
public void writeData(String key, String value) {
mysql.update(key, value);
messageQueue.send("delete_cache", key);
}
  1. 消费者:消费者从消息队列中获取消息,执行缓存删除操作。
1
2
3
4
5
6
7
public void consumeMessage() {
while (true) {
String message = messageQueue.receive("delete_cache");
String key = message.getKey();
redis.del(key);
}
}

如果应用删除缓存失败,可以从消息队列中重新获取消息,再次尝试删除缓存。这就是消息重试机制

1
2
3
4
5
6
7
8
9
10
11
12
public void consumeMessage() {
while (true) {
String message = messageQueue.receive("delete_cache");
String key = message.getKey();
try {
redis.del(key);
messageQueue.ack(message); // 删除缓存成功,确认消息
} catch (Exception e) {
messageQueue.nack(message); // 删除缓存失败,重新入队
}
}
}

重试删除缓存机制总体可用,但可能造成业务代码入侵(业务代码入侵(Business Code Invasion)是指在业务逻辑代码中嵌入了与业务逻辑无关的代码,导致业务代码变得复杂、难以维护,并且增加了系统的耦合度。)。

订阅MySQL binlog,再操作缓存

该策略的第一步是更新数据库,当数据库更新成功,会将旧的值存入 binlog 中。于是我们可以通过订阅 binlog 日志,拿到具体要删除的记录,再执行缓存删除。

可以使用开源工具如 Canal 来订阅 MySQL binlog。订阅 MySQL binlog,获取更新操作的记录,以下是示例代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.Message;

public class BinlogSubscriber {
public static void main(String[] args) {
CanalConnector connector = CanalConnectors.newSingleConnector("localhost", 11111, "example", "", "");
connector.connect();
connector.subscribe(".*\\..*");

while (true) {
Message message = connector.getWithoutAck(100);
long batchId = message.getId();
if (batchId == -1 || message.getEntries().isEmpty()) {
continue;
}

for (Entry entry : message.getEntries()) {
if (entry.getEntryType() == EntryType.ROWDATA) {
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
for (RowData rowData : rowChange.getRowDatasList()) {
if (rowChange.getEventType() == EventType.UPDATE) {
String key = rowData.getAfterColumns(0).getValue();
redis.del(key);
}
}
}
}

connector.ack(batchId);
}
}
}

本地缓存与 Redis 缓存:性能与应用场景的对比

在现代应用开发中,缓存是提升系统性能和响应速度的重要手段。本地缓存和分布式缓存(如 Redis)是两种常见的缓存策略,各自具有独特的优势和适用场景。本文将深入探讨本地缓存和 Redis 缓存的性能特点,并通过对比帮助读者理解它们在不同场景下的应用。

本地缓存

本地缓存是指将数据存储在本地程序或服务器上,通常使用内存作为存储介质。本地缓存通过利用内存的高速读写特性,显著提升数据访问速度。

  1. 优势
  • 访问速度快:由于数据存储在本地内存中,访问速度极快,适合需要高频访问的数据。
  • 减轻网络压力:本地缓存减少了对外部数据源的依赖,从而减轻了网络带宽的压力。
  1. 不足
  • 可扩展性有限:本地缓存通常局限于单个服务器或进程,难以应对大规模分布式系统的需求。

分布式缓存(Redis)

分布式缓存是指将数据存储在多个分布式节点上,通过协同工作提升高性能的数据访问服务。Redis 是一种常见的分布式缓存解决方案,通常采用集群的方式进行部署,通过多台服务器分担数据压力。

  1. 优势
  • 可扩展性强:可以根据业务需求动态增加或减少集群节点,灵活应对数据量的变化。
  • 数据高一致性:Redis 采用主从同步复制机制,确保数据在多个节点之间保持一致性。
  • 易于维护:分布式缓存通常采用自动化管理方式,降低维护成本,提高运维效率。
  1. 不足
  • 访问相对较慢:相比于本地缓存,分布式缓存需要通过网络访问数据,访问速度相对较慢。
  • 网络开销大:分布式缓存依赖网络通信,网络延迟和带宽限制可能影响性能。

应用场景对比

本地缓存适用场景

  • 高频访问数据:适合存储频繁访问的热数据,如用户会话、配置信息等。
  • 单机应用:适用于单机或单进程的应用场景,数据量较小且不需要分布式扩展。

Redis 缓存适用场景

  • 大规模分布式系统:适合需要处理海量数据和高并发请求的分布式系统。
  • 数据一致性要求高:适用于需要确保数据一致性的场景,如电商平台的商品库存管理。
  • 动态扩展需求:适用于需要根据业务需求动态调整缓存规模的场景。

Redis 应用场景

Redis 是一种基于内存的高性能数据库,凭借其快速的读写速度和丰富的数据结构,广泛应用于各种场景中。本文将详细介绍 Redis 在不同应用场景中的具体用途,并通过实例帮助读者更好地理解其优势。

  • 缓存:缓存是 Redis 最常见的应用场景之一。通过将热门数据存储在内存中,Redis 可以显著提高系统的访问速度,减轻后端数据库的压力。
  • 计数器:Redis 的单线程模式和操作的原子性使其非常适合用于实现计数器和统计功能。常见的应用包括页面访问量统计、用户行为统计等。(常用数据结构String、HyperLogLog)
  • 排行榜:Redis 中的有序集合(Sorted Set,Zset)能够实现对数据的自动排序,非常适合用于排行榜、热门文章等需要排序的应用场景。
  • 分布式锁:在分布式系统中,为了避免多个进程同时操作同一资源,可以使用 Redis 实现分布式锁。常见的应用包括资源访问控制、任务调度等。
  • 消息队列:Redis 的发布和订阅功能(Pub/Sub)使其可以作为一个轻量级的消息队列系统。常见的应用包括异步任务处理、事件通知等。(常用数据结构List、Stream)

Redis 实现消息队列

使用Pub/Sub模式

Redis 的 Pub/Sub 模式是一种基于发布者/订阅者的模式。任何客户端都可以订阅一个或多个频道,发布者可以向特定频道发布消息,所有订阅了该频道的订阅者都会收到消息。发布者和订阅者完全解耦,并且支持模式匹配。但是这种方式并不支持持久化,也就是说当发布者将消息发布后,若此时无订阅者,消息就会丢失。

示例:

发布者(Publisher)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import redis.clients.jedis.Jedis;

public class RedisPublisher {
public static void main(String[] args) {
// 连接到 Redis 服务器
Jedis jedis = new Jedis("localhost", 6379);

// 发布消息到频道 "news"
jedis.publish("news", "Breaking News: Redis Pub/Sub Example");

// 关闭连接
jedis.close();
}
}

订阅者(Subscriber)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPubSub;

public class RedisSubscriber {
public static void main(String[] args) {
// 连接到 Redis 服务器
Jedis jedis = new Jedis("localhost", 6379);

// 订阅频道 "news"
jedis.subscribe(new JedisPubSub() {
@Override
public void onMessage(String channel, String message) {
System.out.println("Received: " + message);
}
}, "news");

// 关闭连接
jedis.close();
}
}

使用List

使用 List 实现消息队列是一种简单且高效的方式。生产者使用 LPUSH 命令将消息添加到 List 的队尾,消费者使用 BLPOPBRPOP 命令阻塞地从队首取出消息进行消费(先进先出,FIFO)。这种方式可以结合Redis的过期时间特性实现消息的TTL,通过Redis事务可以保证操作的原子性,但需要客户端自己实现消息确认、消息重试机制。

生产者(Producer)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import redis.clients.jedis.Jedis;

public class RedisProducer {
public static void main(String[] args) {
// 连接到 Redis 服务器
Jedis jedis = new Jedis("localhost", 6379);

// 将消息添加到 List "messageQueue"
jedis.lpush("messageQueue", "Message 1");
jedis.lpush("messageQueue", "Message 2");

// 关闭连接
jedis.close();
}
}

消费者(Consumer)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import redis.clients.jedis.Jedis;

public class RedisConsumer {
public static void main(String[] args) {
// 连接到 Redis 服务器
Jedis jedis = new Jedis("localhost", 6379);

// 阻塞地从 List "messageQueue" 中取出消息
while (true) {
String message = jedis.brpop(0, "messageQueue").get(1);
System.out.println("Consumed: " + message);
}

// 关闭连接
// jedis.close();
}
}

使用 Stream(Redis 5.0 后)

Redis Stream 是 Redis 5.0 引入的一种新的数据结构,专门用于实现消息队列。Stream 提供了更强大的功能,如消息持久化、消费者组、消息确认等。

生产者(Producer)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import redis.clients.jedis.Jedis;
import redis.clients.jedis.StreamEntryID;

import java.util.HashMap;
import java.util.Map;

public class RedisStreamProducer {
public static void main(String[] args) {
// 连接到 Redis 服务器
Jedis jedis = new Jedis("localhost", 6379);

// 创建消息
Map<String, String> message = new HashMap<>();
message.put("event", "New Order");
message.put("orderId", "12345");

// 将消息添加到 Stream "orderStream"
jedis.xadd("orderStream", StreamEntryID.NEW_ENTRY, message);

// 关闭连接
jedis.close();
}
}

消费者(Consumer)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import redis.clients.jedis.Jedis;
import redis.clients.jedis.StreamEntry;
import redis.clients.jedis.StreamEntryID;
import redis.clients.jedis.StreamGroup;
import redis.clients.jedis.StreamInfo;
import redis.clients.jedis.StreamPendingEntry;

import java.util.List;
import java.util.Map;

public class RedisStreamConsumer {
public static void main(String[] args) {
// 连接到 Redis 服务器
Jedis jedis = new Jedis("localhost", 6379);

// 创建消费者组
jedis.xgroupCreate("orderStream", "orderGroup", new StreamEntryID(), true);

// 消费消息
while (true) {
List<StreamEntry> messages = jedis.xreadGroup("orderGroup", "consumer1", 1, 0, false, "orderStream");
for (StreamEntry message : messages) {
System.out.println("Consumed: " + message.getFields());
// 确认消息
jedis.xack("orderStream", "orderGroup", message.getID());
}
}

// 关闭连接
// jedis.close();
}
}

Redis 实现分布式锁详解

在分布式系统中,为了避免多个进程同时操作同一资源,分布式锁是一种常用的解决方案。Redis 提供了多种实现分布式锁的方式,本文将详细介绍基于 SET 命令的争抢锁机制和 RedLock 算法,并通过代码示例帮助读者更好地理解其应用。

Redis 分布式锁实现原理

分布式锁是分布式并发状态下的一种机制,用于控制一个资源在一个时间内,只有一个应用能对其进行使用。

Redis本身可以被多个客户端进行访问,就像一个共享存储,可以用来保存分布式锁。Redis 的 SET 命令参数 NX 表示只有在键不存在时才设置,具有互斥性,非常适合用来构建分布式锁:

  • 若key存在,证明被加过锁了,所以此时可以认为加锁失败。
  • 若key不存在,证明未被加锁,可以认为加锁成功。

基于Redis节点实现分布式锁,对于加锁条件,我们需要满足三个条件:

  • 原子性:加锁操作涉及多个操作(读取锁变量、检查锁变量、设置锁变量),需要保证这些操作的原子性。
  • 过期时间:锁变量需要设置过期时间,避免客户端获得锁后挂掉,锁一直不释放造成死锁。
  • 唯一性:锁变量的值需要能区分是哪个客户端设置的锁,避免在释放锁时造成误释放操作。

基于 SET 命令的争抢锁机制

Redis 提供了 SET 命令的扩展参数,可以用于实现分布式锁。通过 SET resource_name lock_value NX PX milliseconds 命令,客户端可以尝试获取锁。其中:

  • NX:表示只有当键值不存在时才设置。
  • PX milliseconds:指定锁的过期时间(毫秒)。

如果设置成功,则认为当前客户端获得了锁。当客户端完成操作后,需要删除锁,这里涉及两个以上的操作(判断锁是否属于自己、删除锁),因此可以使用 Lua 脚本来保证 Redis 命令的原子性。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import redis.clients.jedis.Jedis;

public class RedisLock {
private static final String LOCK_KEY = "my_lock";
private static final String LOCK_VALUE = "lock_value";
private static final int LOCK_EXPIRE_TIME = 10000; // 锁的过期时间,单位:毫秒

public static void main(String[] args) {
// 连接到 Redis 服务器
Jedis jedis = new Jedis("localhost", 6379);

// 尝试获取锁
String result = jedis.set(LOCK_KEY, LOCK_VALUE, "NX", "PX", LOCK_EXPIRE_TIME);

if ("OK".equals(result)) {
System.out.println("Lock acquired successfully.");
// 执行需要加锁的操作
// ...

// 释放锁
releaseLock(jedis);
} else {
System.out.println("Failed to acquire lock.");
}

// 关闭连接
jedis.close();
}

private static void releaseLock(Jedis jedis) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, 1, LOCK_KEY, LOCK_VALUE);
}
}

注意事项

  • 锁的过期时间:设置合理的锁过期时间,避免锁长时间占用资源。
  • 锁的唯一性:确保锁的值是唯一的,通常使用 UUID 或其他唯一标识符。
  • Lua 脚本:使用 Lua 脚本保证释放锁操作的原子性。

RedLock 算法

RedLock 算法是 Redis 官方推荐的分布式锁实现方式,适用于需要高可用性和容错性的场景。RedLock 算法通过在多个独立的 Redis 实例上获取锁,并确保大多数实例(超过半数)成功获取锁,来实现分布式锁。

算法步骤

  1. 获取当前时间:记录当前时间戳。
  2. 尝试获取锁:在每个 Redis 实例上尝试获取锁,使用相同的键和随机值。
  3. 计算获取锁的时间:计算从开始获取锁到所有实例返回结果的时间。
  4. 判断锁是否获取成功:如果大多数实例(超过半数)成功获取锁,并且获取锁的时间小于锁的有效期,则认为锁获取成功。
  5. 释放锁:在所有实例上释放锁。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

import java.util.ArrayList;
import java.util.List;

public class RedLock {
private static final String LOCK_KEY = "my_lock";
private static final String LOCK_VALUE = "lock_value";
private static final int LOCK_EXPIRE_TIME = 10000; // 锁的过期时间,单位:毫秒
private static final int QUORUM = 3; // 大多数实例的数量

private List<JedisPool> jedisPools;

public RedLock(List<String> redisServers) {
jedisPools = new ArrayList<>();
for (String server : redisServers) {
String[] parts = server.split(":");
JedisPoolConfig config = new JedisPoolConfig();
JedisPool pool = new JedisPool(config, parts[0], Integer.parseInt(parts[1]));
jedisPools.add(pool);
}
}

public boolean acquireLock() {
int successCount = 0;
long startTime = System.currentTimeMillis();

for (JedisPool pool : jedisPools) {
try (Jedis jedis = pool.getResource()) {
String result = jedis.set(LOCK_KEY, LOCK_VALUE, "NX", "PX", LOCK_EXPIRE_TIME);
if ("OK".equals(result)) {
successCount++;
}
}
}

long elapsedTime = System.currentTimeMillis() - startTime;
return successCount >= QUORUM && elapsedTime < LOCK_EXPIRE_TIME;
}

public void releaseLock() {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
for (JedisPool pool : jedisPools) {
try (Jedis jedis = pool.getResource()) {
jedis.eval(script, 1, LOCK_KEY, LOCK_VALUE);
}
}
}

public static void main(String[] args) {
List<String> redisServers = new ArrayList<>();
redisServers.add("localhost:6379");
redisServers.add("localhost:6380");
redisServers.add("localhost:6381");

RedLock redLock = new RedLock(redisServers);

if (redLock.acquireLock()) {
System.out.println("Lock acquired successfully.");
// 执行需要加锁的操作
// ...

// 释放锁
redLock.releaseLock();
} else {
System.out.println("Failed to acquire lock.");
}
}
}

注意事项

  • 多数原则:确保大多数实例(超过半数)成功获取锁。
  • 时间同步:各个 Redis 实例的时间需要同步,避免时间差异导致锁失效。
  • 故障处理:处理 Redis 实例故障的情况,确保锁的可靠性。

Redis Key

Redis 大 Key 问题

在 Redis 中,大 Key 问题指的是一个键(Key)对应的值(Value)过大,导致 Redis 处理起来缓慢、内存不足、影响主从同步延迟等问题。

大地多大算大Key?这是没有标准的,通常认为字符串类型的key对应的value值空间占用超过了1M,或者集合中key对应的元素个数超过了1w,我们就认为该key是大key。

大 Key 问题的缺点

  1. 内存占用较高:大 Key 会占用大量的内存,导致 Redis 可用内存减少。Redis 是一个内存数据库,内存是其主要资源。如果内存被大 Key 占用过多,可能会导致内存不足,触发内存淘汰策略,影响其他键的访问。

  2. 降低性能:当对大 Key 进行处理时,会花费更多的 CPU 时间,导致整体性能下降,甚至会阻塞其他客户端的请求。大 Key 的读取、写入、删除等操作都会消耗更多的 CPU 资源,影响 Redis 的响应速度。

  3. 网络拥塞:大 Key 在网络传输时会占用大量带宽,可能导致网络拥塞。特别是在高并发场景下,大量请求传输大 Key 会导致网络资源被耗尽,影响其他请求的传输。假设有一个大 Key image:12345,对应的值是一个 1MB 的图片数据。如果有 1000 个请求同时传输这个大 Key,会占用 1000MB 的网络带宽,导致网络拥塞。

  4. 主从复制延迟:大 Key 在主从复制时需要传输大量数据,可能导致主从复制延迟,影响数据一致性。特别是在主从节点之间网络带宽有限的情况下,大 Key 的传输会占用大量带宽,导致复制延迟。

  5. 数据倾斜:一个大 Key 会造成单个切片节点使用了很大一部分内存,导致内存使用率远超其他切片节点。这种数据倾斜会导致集群中某些节点负载过重,影响整体性能。

如何解决大 Key 问题

  • 大Key拆分:将数万成员的大 Key 拆分为更小的分散的 Key,可以有效减少单个键的内存占用,提高 Redis 的处理效率。。
  • 内存清理:将大 Key 转移到其他存储介质(如文件系统、数据库等),然后释放 Redis 中的 Key,避免占用内存空间。(要使用异步删除)
  • 内存阈值监控:持续监控 Redis 的内存使用率,当发现内存用量到达阈值时或内存使用率突然大幅提升时,对内存进行相应的处理,比如删除不需要的 Key。。
  • 定期删除:定时对过期的 Key 进行删除,避免持续堆积而产生大 Key。

热Key

什么是热key

热 Key 是指那些被频繁访问的键(Key),其请求频率远高于其他键。热 Key 问题会导致 Redis 的性能瓶颈,影响整体系统的响应速度。

  • QPS 请求集中在特定 Key:假设 Redis 有 10,000 的 QPS(每秒查询次数),而某个 Key 被请求的频率达到 7,000 QPS。
  • 带宽使用率集中在特定的 Key:某个 Key 的传输数据量占用了大量网络带宽。
  • CPU 使用时间集中在特定的 Key:某个 Key 的处理占用了大量 CPU 时间。

热 Key 的影响

  • 性能瓶颈:热 Key 会导致 Redis 的性能瓶颈,影响整体系统的响应速度。
  • 资源浪费:热 Key 会占用大量 CPU、内存和网络资源,导致其他请求的资源不足。

如何解决热key

  • 负载均衡:由于 Redis 切片集群中,Key 的迁移粒度问题,无法将热 Key 迁移到其他节点分散单点压力。此时可以将热 Key 复制多几份,重新命名后装配到其他节点,并将热 Key 的请求分散到这些分散的节点中,以此降低单点压力。
  • 缓存预热:在系统启动时,提前加载热 Key 的数据到缓存中,避免系统启动后瞬间的高并发请求导致热 Key 问题。

三大缓存问题详解

在分布式系统中,缓存是提升系统性能的重要手段。然而,缓存也会带来一些问题,如缓存雪崩缓存击穿缓存穿透

缓存雪崩

缓存雪崩是指,在高并发场景下,同一时间内大量 Key 过期,或者 Redis 节点故障,导致大量的数据请求冲向数据库服务器,数据库可能会因无法承受大量数据请求而宕机。

缓存雪崩

对于大量key同时过期造成的缓存雪崩,我们可以设置以下方案解决:

  1. 均匀key过期时间:对于同一时间大量 Key 过期而产生的缓存雪崩,可以设置随机的过期时间,避免大量 Key 在同一时间失效。通过在基础过期时间上增加一个随机值,可以有效分散 Key 的过期时间,避免集中失效。
  2. 不设置 Key 过期时间:若业务场景可能长期需要该 Key,那么可以不设置过期时间,待业务活动过期后手动将 Key 删除。这种方式适用于那些长期有效的数据,避免频繁的缓存重建。
  3. 互斥锁:当业务线程发现该 Key 不在缓存中,就设置一个互斥锁,保证同一时间只有一个线程在处理缓存,避免在失效期间大量线程同一时间读数据库写缓存。通过互斥锁,可以有效控制缓存重建的并发度,减少数据库压力。

缓存击穿

缓存击穿是指,在高并发场景下,某个热点 Key 在缓存中过期,导致大量请求直接访问数据库,导致数据库压力骤增,甚至宕机。

缓存击穿

对于缓存击穿,我们也可以使用应对缓存雪崩中采取的两个方案:

  • 互斥锁:使用互斥锁,保证同一时间只有一个线程访问数据库,其他线程等待缓存更新。通过互斥锁,可以有效控制缓存重建的并发度,减少数据库压力。
  • 热点数据永不过期:对于热点数据,可以设置永不过期,或者设置较长的过期时间。这种方式适用于那些长期有效的数据,避免频繁的缓存重建。
  • 后台异步更新:保持热点 Key 持续在线,由后台异步更新缓存。或者在 Key 即将过期时通知后台线程,重新设置过期时间。这种方式可以避免缓存过期瞬间的大量请求冲击数据库。

缓存穿透

缓存穿透是指,在高并发场景下,请求的数据在缓存和数据库中都不存在,导致每次请求都直接访问数据库,导致数据库压力骤增,甚至宕机。

缓存穿透

对于缓存穿透,我们可以采取以下方案:

  1. 限制非法请求:当有恶意的请求故意查询一条不存在的数据,大量的恶意请求也会导致缓存穿透问题。由此我们需要判断请求参数是否合理,过滤掉非法的数据请求。通过参数校验和请求过滤,可以有效减少非法请求对数据库的冲击。
  2. 缓存空值或默认值:当我们发现环境中存在缓存穿透现象时,可以为不存在的数据设置为空值或默认值,当缓存中存在有值就不需要向数据库发起大量的请求了。通过缓存空值或默认值,可以避免大量请求直接访问数据库。
  3. 布隆过滤器:我们可以在写入数据库时,使用布隆过滤器做标记,然后再请求到来时先判断缓存中是否存在该数据,若不存在则通过布隆过滤器快速判断要请求的数据是否存在。若判断存在,则数据可能存在,可以进一步向数据库查询;若判断不存在,则一定不存在,直接返回。通过布隆过滤器,可以快速过滤掉不存在的数据请求,减少数据库压力,Redis本身也实现了布隆过滤器。

布隆过滤器原理

布隆过滤器是一种空间效率很高的概率型数据结构,用于判断一个元素是否在一个集合中。它由“初始值都为0的位图数组”和“n个哈希函数”两部分组成。布隆过滤器通过哈希计算来判断数据是否存在,具有高效的查询速度和较低的空间占用,但存在一定的误判率。当我们在写入数据库的时候,会在布隆过滤器中设置标记,证明该数据在数据库中存在,这样下次查询的时候直接先通过布隆过滤器就能判断出该数据在数据库中是否存在而不需要进一步查询数据库。

布隆过滤器会通过3个操作完成标记:

  1. 哈希计算:使用n个哈希函数对数据进行哈希计算,得到n个哈希值。
  2. 取模操作:将n个哈希值分别对位图数组的长度取模,得到每个哈希值在位图数组中相对应的位置。
  3. 设置标记:将每个哈希值在位图数组中相应的位置设置为1。

举个例子,假设有一个长度为5的位图数组、哈希函数有2个的布隆过滤器。

布隆过滤器

将数据x写入数据库后,会对数据x进行哈希计算,得到n个哈希值;再将得到的哈希值取模,也就得到了一堆数字结果,对应布隆过滤器中位图数组的位置;将这些位置设为1.下次要判断x是否存在于数据库中时,只需要对x进行哈希计算,查看对应位图数组上相应的位置是不是全都为1,如果全都是1,则认为数据可能存在于数据库中,可以继续下一步查询;否则,认为数据一定不存在。

布隆过滤器通过哈希计算来判断数据是否存在,必然存在哈希冲突,可能会有两个不同的数据计算出同样的哈希值,造成误判。具体来说:

  • 误判情况:布隆过滤器认为数据存在,但实际上数据可能不存在。这种情况称为“假阳性”(False Positive)。
  • 正确情况:布隆过滤器认为数据不存在,则数据一定不存在。这种情况称为“真阴性”(True Negative)。

所以,布隆过滤器认为该数据存在,则其可能存在;若认为数据不存在,则一定不存在。

Redis 应用设计

如何设计秒杀场景处理高并发以及超卖现象?

秒杀场景是一种典型的高并发场景,通常涉及大量用户在短时间内对有限数量的商品进行抢购。为了应对高并发和避免超卖现象,需要从数据库层面、分布式锁、分段锁以及 Redis 和异步队列等多个方面进行设计和优化。

数据库层面

  1. 查询商品库存时加排他锁

在查询商品库存时,可以使用数据库的排他锁(FOR UPDATE)来保证数据的一致性。

1
SELECT * FROM goods WHERE id = ? FOR UPDATE;

在事务中,线程 A 通过该语句给 id 为 ? 的数据行上了一个行级的排他锁。此时在事务期间,其他线程对该行的 UPDATEDELETE 操作都将被阻塞,直到事务提交或发生回滚释放锁。

  1. 更新数据库减库存时进行库存限制

在更新数据库减库存时,可以通过条件判断来避免超卖现象。

1
UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0;

这种通过数据库加锁来解决的方案,性能不是很好,在高并发的情况下可能会因为获取不到数据库的连接或超时等待报错。

利用分布式锁

分布式锁可以保证在同一时间内只有一个客户端能获取到锁,获得锁的线程才能进行接下来的业务逻辑,而其他客户端获取不到锁只能无限循环尝试获取锁。

可以使用 Redis 的 SETNX 命令来实现分布式锁。

1
2
3
4
5
6
7
8
9
public boolean acquireLock(String lockKey, String lockValue, int expireTime) {
String result = jedis.set(lockKey, lockValue, "NX", "PX", expireTime);
return "OK".equals(result);
}

public void releaseLock(String lockKey, String lockValue) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, 1, lockKey, lockValue);
}

在高并发状态下,分布式锁只能进行串行化处理,效率很低。比如大量用户对同一个热门商品下单,此时只能一个个处理下单操作,效率很慢。

利用分布式锁 + 分段锁

把数据分成很多段,每一段加上一个单独的锁,细粒度化,使得线程在对一段数据进行修改时,其他线程可以继续对剩下部分进行加锁操作。

假设商品库存分为多个段,每个段使用独立的锁。

1
2
3
4
5
6
7
8
9
public boolean acquireSegmentLock(String segmentKey, String lockValue, int expireTime) {
String result = jedis.set(segmentKey, lockValue, "NX", "PX", expireTime);
return "OK".equals(result);
}

public void releaseSegmentLock(String segmentKey, String lockValue) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, 1, segmentKey, lockValue);
}

通过分段锁,可以提高并发处理能力,减少锁的竞争,提升系统的吞吐量。

利用 Redis 的 INCRDECR 的原子性 + 异步队列

  1. 系统初始化时加载库存到 Redis

在系统初始化时,将商品的库存数量加载到 Redis 中。

1
2
3
public void loadStockToRedis(String key, int stock) {
jedis.set(key, String.valueOf(stock));
}
  1. 接收到秒杀请求时预减库存

接收到秒杀请求时,在 Redis 中进行预减库存(利用 Redis DECR 的原子性),当 Redis 的库存不足时直接返回秒杀失败,否则继续进行第三步。

1
2
3
4
public boolean preDecreaseStock(String key) {
long stock = jedis.decr(key);
return stock >= 0;
}

3, 将请求放入异步队列

将请求放入异步队列中,返回正在排队中。

1
2
3
public void enqueueRequest(String request) {
jedis.lpush("seckill_queue", request);
}
  1. 服务端异步队列请求出队

服务端异步队列请求出队(可以出队的情况根据业务来判定,比如判断是否已秒杀过,防重复秒杀),出队的请求可以生成秒杀订单,减少数据库存。

1
2
3
4
5
6
7
8
public void processQueue() {
while (true) {
String request = jedis.rpop("seckill_queue");
if (request != null) {
// 处理秒杀请求,生成订单,减少库存
}
}
}
  1. 客户端轮询查看秒杀结果

用户在客户端申请完秒杀后,进行轮询,查看是否秒杀成功,秒杀成功则进入秒杀订单详情,否则秒杀失败。

1
2
3
4
public boolean checkSeckillResult(String requestId) {
String result = jedis.get("seckill_result:" + requestId);
return "success".equals(result);
}

由于使用了异步队列写入数据库,可能存在数据不一致问题,其次引用多个组件,复杂度比较高。