附录

缓存系统实战

缓存系统实战 application.properties:

1
2
3
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=3000

RedisTemplate 配置类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Configuration
public class RedisConfig {

    @Bean
    public StringRedisTemplate redisTemplate(RedisConnectionFactory factory) {
        return new StringRedisTemplate(factory);
    }

    @Bean
    public ObjectMapper objectMapper() {
        return JsonMapper.builder().build();
    }
}

Service 类:

 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
@Service
@RequiredArgsConstructor
public class UserService {

    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;
    private final FakeDatabase db;

    public User getUser(Long id) throws Exception {
        String key = "user:" + id;
        // 查缓存
        String cache = redis.opsForValue().get(key);

        if (cache != null) {
            if ("NULL".equals(cache)) {
                return null;
            }
            return mapper.readValue(cache, User.class);
        }

        // 防缓存击穿(热点 key 失效瞬间,大量请求直接打数据库),加锁
        Boolean lock = redis.opsForValue()
                .setIfAbsent("lock:" + id, "1", Duration.ofSeconds(5));
        if (Boolean.FALSE.equals(lock)) {
            Thread.sleep(50);
            return getUser(id);
        }

        // 查数据库
        User user = db.queryUser(id);
        if (user == null) {
            // 防缓存穿透,请求的数据本身就不存在,攻击者疯狂请求,每次请求都打 DB
            redis.opsForValue().set(key, "NULL", Duration.ofSeconds(30));
            return null;
        }

        // 写缓存 + 随机 TTL,防止缓存雪崩
        int ttl = 60 + new Random().nextInt(20);

        String json = mapper.writeValueAsString(user);

        redis.opsForValue().set(key, json, Duration.ofSeconds(ttl));
        return user;
    }
}

Controller 类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @GetMapping("/user/{id}")
    public User get(@PathVariable Long id) throws Exception {
        return userService.getUser(id);
    }
}

FakeDB 类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Component
public class FakeDatabase {

    public User queryUser(Long id) {
        try {
            Thread.sleep(100);
        }   catch (Exception ignored) {

        }
        if (id == 999L) {
            return null;
        }
        return new User(id, "User_" + id);
    }
}

秒杀系统实战

SeckillController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@RestController
@RequiredArgsConstructor
public class SeckillController {

    private final SeckillService service;

    @PostMapping("/seckill/{userId}")
    public String seckill(@PathVariable Long userId) {
        int r = service.seckill(userId);

        return switch (r) {
            case 1 -> "成功";
            case 0 -> "库存不足";
            case -1 -> "重复下单";
            case -2 -> "限流";
            case -3 -> "重复请求";
            default -> "失败";
        };
    }
}

RateLimiter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Component
@RequiredArgsConstructor
public class RateLimiter {

    private final StringRedisTemplate redis;

    public boolean allow(String key, int limit) {
        String redisKey = "limit:" + key + ":" + Instant.now().getEpochSecond();
        Long count = redis.opsForValue().increment(redisKey);

        if (count == 1) {
            redis.expire(redisKey, Duration.ofSeconds(2));
        }
        return count <= limit;
    }
}

RedisLock

 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
@Component
@RequiredArgsConstructor
public class RedisLock {

    private final StringRedisTemplate redis;

    private static final String LOCK_PREFIX = "lock:";
    private static final long DEFAULT_EXPIRE = 5;

    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setScriptText(
                """
                if redis.call('get', KEYS[1]) == ARGV[1] then
                    return redis.call('del', KEYS[1])
                else
                    return 0
                end
                """
        );
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    public String tryLock(String key) {
        String lockKey = LOCK_PREFIX + key;
        // 每个线程唯一 id
        String lockValue = UUID.randomUUID().toString();
        Boolean success = redis.opsForValue().setIfAbsent(lockKey, lockValue, Duration.ofSeconds(DEFAULT_EXPIRE));
        if (Boolean.TRUE.equals(success)) {
            return lockValue;
        }
        return null;
    }

    public boolean unlock(String key, String lockValue) {
        String lockKey = LOCK_PREFIX + key;
        Long result = redis.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(lockKey),
                lockValue
        );
        return Long.valueOf(1).equals(result);
    }

}

LuaConfig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Configuration
public class LuaConfig {

    @Bean
    public DefaultRedisScript<Long> stockScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setLocation(new ClassPathResource("lua/stock.lua"));
        script.setResultType(Long.class);

        return script;
    }
}

stock.lua

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- KEYS[1] = 库存 key
-- KEYS[2] = 订单集合 key
-- ARGV[1] = 用户 id
local stock = tonumber(redis.call('get', KEYS[1]))

if stock <= 0 then
    -- 库存不足
    return 0
end

-- 防止重复下单
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
    -- 重复下单
    return -1
end

-- 扣库存
redis.call('decr', KEYS[1])

-- 记录用户下单
redis.call('sadd', KEYS[2], ARGV[1])
-- 成功下单
return 1

OrderQueue

 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
@Component
@RequiredArgsConstructor
public class OrderQueue {

    private final StringRedisTemplate redis;
    private final ObjectMapper objectMapper;

    private static final String QUEUE_KEY = "queue:orders";

    public void push(Order order) {
        try {
            String json = objectMapper.writeValueAsString(order);
            redis.opsForList().leftPush(QUEUE_KEY, json);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public Order pop() {
        String result = redis.opsForList().rightPop(QUEUE_KEY, Duration.ofSeconds(0));
        if (result == null) {
            return null;
        }

        try {
            return objectMapper.readValue(result, Order.class);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

OrderWorker

 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
@Component
@RequiredArgsConstructor
public class OrderWorker {

    private final OrderQueue queue;

    @PostConstruct
    public void start() {
        Thread work = new Thread(() -> {
            while(true) {
                Order order =queue.pop();
                if (order != null) {
                    process(order);
                }
            }
        });

        work.setDaemon(true);
        work.start();
    }

    private void process(Order order) {
        System.out.println("处理订单: " + order);
        try {
            Thread.sleep(100);
        } catch (Exception ignored) {

        }
    }
}

SeckillService

 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
@Service
@RequiredArgsConstructor
public class SeckillService {

    private final StringRedisTemplate redis;
    private final DefaultRedisScript<Long> stockScript;
    private final OrderQueue queue;
    private final RateLimiter rateLimiter;
    private final RedisLock redisLock;

    public int seckill(Long userId) {

        if (!rateLimiter.allow("seckill", 100)) {
            return -2; // 系统繁忙
        }

        // 获取分布式锁
        String token = redisLock.tryLock("user:" + userId);
        if (token == null) {
            return -3;
        }

        try {
            List<String> keys = List.of(
                    "seckill:stock",
                    "seckill:orders"
            );

            Long result = redis.execute(stockScript, keys, userId.toString());

            if (result == 1) {
                Order order = new Order(userId, System.currentTimeMillis());
                queue.push(order);
            }
            return result.intValue();
        } finally {
            redisLock.unlock("user:" + userId, token);
        }
    }

}

秒杀系统滑动窗口限流进阶

ratelimit.lua

 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
-- KEYS[1] = rate limit key (zset)
-- ARGV[1] = now(ms)
-- ARGV[2] = window(ms)
-- ARGV[3] = limit(max requests in window)
-- ARGV[4] = member(unique id for this requests)
-- ARGV[5] = expireSeconds(Key ttl)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]
local expireSeconds = tonumber(ARGV[5])

-- 1 remove out-of-window
redis.call('zremrangebyscore', key, 0, now - window)

-- 2 current call
local cnt = redis.call('zcard', key)

-- 3 if exceed, reject
if cnt >= limit then
    return 0
end

-- 4 add current request
redis.call('zadd', key, now, member)

-- 5 set ttl to avoid key leak
redis.call('expire', key, expireSeconds)
return 1

LuaConfig

1
2
3
4
5
6
7
@Bean
public DefaultRedisScript<Long> rateLimitScript() {
    DefaultRedisScript<Long> script = new DefaultRedisScript<>();
    script.setLocation(new ClassPathResource("lua/ratelimit.lua"));
    script.setResultType(Long.class);
    return script;
}

SlidingWindowLimit

 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
@Component
@RequiredArgsConstructor
public class SlidingWindowRateLimiter {

    private final StringRedisTemplate redis;
    private final DefaultRedisScript<Long> rateLimitScript;

    public boolean allow(String key, int limit, long windowMs) {
        long now = System.currentTimeMillis();

        // 每次请求唯一 member
        String member = now + "-" + UUID.randomUUID();

        // key TTL,一般设置为 window 的 2-3 倍,保证窗口内数据可用并自动回收
        long expireSeconds = Math.max(2, (windowMs * 3) / 1000);

        String redisKey = "rl:" + key;

        Long result = redis.execute(
                rateLimitScript,
                Collections.singletonList(redisKey),
                String.valueOf(now),
                String.valueOf(windowMs),
                String.valueOf(limit),
                member,
                String.valueOf(expireSeconds)
        );
        System.out.println("result:" + result);
        return Long.valueOf(1).equals(result);
    }
}

秒杀系统进阶令牌桶

token_bucket.lua

 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
-- KEYS[1] = bucket key
-- ARGV[1] = now_ms
-- ARGV[2] = capacity (max tokens)
-- ARGV[3] = refill_rate_per_sec (tokens per second)
-- ARGV[4] = cost (tokens per request, usually 1)
-- ARGV[5] = ttl_seconds (expire key to avoid leaks)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local rate = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local ttl = tonumber(ARGV[5])

-- Read current state
local tokens = redis.call('hget', key, 'tokens')
local ts = redis.call('hget', key, 'ts')

if tokens == false or ts == false then
    tokens = capacity
    ts = now
else
    tokens = tonumber(tokens)
    ts = tonumber(ts)
end

-- Refill tokens based on elapsed time
local delta_ms = now - ts
if delta_ms < 0 then
    delta_ms = 0
end

local refill = (delta_ms / 1000.0) * rate
tokens = math.min(capacity, tokens + refill)

-- Decide allow or reject
if tokens < cost then
    redis.call('hset', key, 'tokens', tokens)
    redis.call('hset', key, 'ts', now)
    redis.call('expire', key, ttl)
    return 0
end

-- Consume tokens
tokens = tokens - cost

redis.call('hset', key, 'tokens', tokens)
redis.call('hset', key, 'ts', now)
redis.call('expire', key, ttl)

return 1

TokenBucketRateLimiter

 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
@Component
@RequiredArgsConstructor
public class TokenBucketRateLimiter {

    private final StringRedisTemplate redis;
    private final DefaultRedisScript<Long> tokenBucketScript;

    /**
     * 令牌桶限流
     * @param key 限流维度
     * @param capacity 桶容量
     * @param ratePerSec 补充速率
     * @param cost 每次请求消耗令牌数
     */
    public boolean allow(String key, long capacity, double ratePerSec, long cost) {
        long now = System.currentTimeMillis();
        String redisKey = "tb:" + key;

        // TTL: 建议 >= 桶完全补满的时间 * 2 (避免闲置 key 常驻)
        // refill time ≈ capacity / ratePerSec
        long refillSeconds = (long) Math.ceil(capacity / Math.max(ratePerSec, 0.0000001));
        long ttlSeconds = Math.max(2, refillSeconds * 2);

        Long result = redis.execute(
                tokenBucketScript,
                Collections.singletonList(redisKey),
                String.valueOf(now),
                String.valueOf(capacity),
                String.valueOf(ratePerSec),
                String.valueOf(cost),
                String.valueOf(ttlSeconds)
        );
        return Long.valueOf(1).equals(result);
    }
}

LuaConfig

1
2
3
4
5
6
7
@Bean
public DefaultRedisScript<Long> tokenBucketScript() {
    DefaultRedisScript<Long> script = new DefaultRedisScript<>();
    script.setLocation(new ClassPathResource("lua/token_bucket.lua"));
    script.setResultType(Long.class);
    return script;
}

哨兵实验

docker-compose

 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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
services:
  redis-master:
    image: redis:7
    container_name: redis-master
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
    volumes:
      - ./conf/redis-master.conf:/usr/local/etc/redis/redis.conf
      - ./data/master:/data
    ports:
      - "6379:6379"
    networks: [redisnet]

  redis-replica-1:
    image: redis:7
    container_name: redis-replica-1
    depends_on: [redis-master]
    command: ["redis-server", "/usr/local/etc/redis/redis.conf", "--replicaof", "redis-master", "6379"]
    volumes:
      - ./conf/redis-replica.conf:/usr/local/etc/redis/redis.conf
      - ./data/replica1:/data
    ports:
      - "6380:6379"
    networks: [redisnet]

  redis-replica-2:
    image: redis:7
    container_name: redis-replica-2
    depends_on: [redis-master]
    command: ["redis-server", "/usr/local/etc/redis/redis.conf", "--replicaof", "redis-master", "6379"]
    volumes:
      - ./conf/redis-replica.conf:/usr/local/etc/redis/redis.conf
      - ./data/replica2:/data
    ports:
      - "6381:6379"
    networks: [redisnet]

  sentinel-1:
    image: redis:7
    container_name: sentinel-1
    depends_on: [redis-master, redis-replica-1, redis-replica-2]
    command:
      [
        "sh","-c",
        "cp /usr/local/etc/redis/sentinel.conf /data/sentinel.conf && exec redis-sentinel /data/sentinel.conf"
      ]
    volumes:
      - ./conf/sentinel.conf:/usr/local/etc/redis/sentinel.conf
      - ./data/s1:/data
    ports:
      - "26379:26379"
    networks: [redisnet]

  sentinel-2:
    image: redis:7
    container_name: sentinel-2
    depends_on: [redis-master, redis-replica-1, redis-replica-2]
    command:
      [
        "sh","-c",
        "cp /usr/local/etc/redis/sentinel.conf /data/sentinel.conf && exec redis-sentinel /data/sentinel.conf"
      ]
    volumes:
      - ./conf/sentinel.conf:/usr/local/etc/redis/sentinel.conf
      - ./data/s2:/data
    ports:
      - "26380:26379"
    networks: [redisnet]

  sentinel-3:
    image: redis:7
    container_name: sentinel-3
    depends_on: [redis-master, redis-replica-1, redis-replica-2]
    command:
      [
        "sh","-c",
        "cp /usr/local/etc/redis/sentinel.conf /data/sentinel.conf && exec redis-sentinel /data/sentinel.conf"
      ]
    volumes:
      - ./conf/sentinel.conf:/usr/local/etc/redis/sentinel.conf
      - ./data/s3:/data
    ports:
      - "26381:26379"
    networks: [redisnet]

networks:
  redisnet:
    driver: bridge

哨兵故障演练实验

docker-compose.yml

 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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
services:
  redis-master:
    image: redis:7
    container_name: redis-master
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
    volumes:
      - ./conf/redis-master.conf:/usr/local/etc/redis/redis.conf
      - ./data/master:/data
    ports:
      - "6379:6379"
    networks:
      redisnet:
        ipv4_address: 172.28.0.10

  redis-replica-1:
    image: redis:7
    container_name: redis-replica-1
    depends_on: [redis-master]
    command: ["redis-server", "/usr/local/etc/redis/redis.conf", "--replicaof", "172.28.0.10", "6379"]
    volumes:
      - ./conf/redis-replica.conf:/usr/local/etc/redis/redis.conf
      - ./data/replica1:/data
    ports:
      - "6380:6379"
    networks: [redisnet]

  redis-replica-2:
    image: redis:7
    container_name: redis-replica-2
    depends_on: [redis-master]
    command: ["redis-server", "/usr/local/etc/redis/redis.conf", "--replicaof", "172.28.0.10", "6379"]
    volumes:
      - ./conf/redis-replica.conf:/usr/local/etc/redis/redis.conf
      - ./data/replica2:/data
    ports:
      - "6381:6379"
    networks: [redisnet]

  sentinel-1:
    image: redis:7
    container_name: sentinel-1
    depends_on: [redis-master, redis-replica-1, redis-replica-2]
    command:
      [
        "sh","-c",
        "cp /usr/local/etc/redis/sentinel.conf /data/sentinel.conf && exec redis-sentinel /data/sentinel.conf"
      ]
    volumes:
      - ./conf/sentinel.conf:/usr/local/etc/redis/sentinel.conf
      - ./data/s1:/data
    ports:
      - "26379:26379"
    networks: [redisnet]

  sentinel-2:
    image: redis:7
    container_name: sentinel-2
    depends_on: [redis-master, redis-replica-1, redis-replica-2]
    command:
      [
        "sh","-c",
        "cp /usr/local/etc/redis/sentinel.conf /data/sentinel.conf && exec redis-sentinel /data/sentinel.conf"
      ]
    volumes:
      - ./conf/sentinel.conf:/usr/local/etc/redis/sentinel.conf
      - ./data/s2:/data
    ports:
      - "26380:26379"
    networks: [redisnet]

  sentinel-3:
    image: redis:7
    container_name: sentinel-3
    depends_on: [redis-master, redis-replica-1, redis-replica-2]
    command:
      [
        "sh","-c",
        "cp /usr/local/etc/redis/sentinel.conf /data/sentinel.conf && exec redis-sentinel /data/sentinel.conf"
      ]
    volumes:
      - ./conf/sentinel.conf:/usr/local/etc/redis/sentinel.conf
      - ./data/s3:/data
    ports:
      - "26381:26379"
    networks: [redisnet]

networks:
  redisnet:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

sentinel.conf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
port 26379
bind 0.0.0.0
protected-mode no

# 监控主库:mymaster 名称、主库 host/port、quorum(法定票数)
sentinel monitor mymaster 172.28.0.10 6379 2

# 认证(主库有 requirepass 时必须加)
sentinel auth-pass mymaster 123456

# 多久没响应算主观下线(ms)
sentinel down-after-milliseconds mymaster 5000

# 故障转移超时(ms)
sentinel failover-timeout mymaster 10000

# 同时同步副本数量(不影响本实验搭建,可保留默认)
sentinel parallel-syncs mymaster 1

# Redis 7 的 Sentinel 默认会把 monitor 的 host 当成“IP/可直接解析的实例”,
# 在某些环境下它不会走你以为的 resolver 路径(或要求显式开启 hostname 解析)
# 解决方法是——打开 Sentinel 的 hostname 解析开关
sentinel resolve-hostnames yes
sentinel announce-hostnames yes