Hello everyone, I'm Alang. I need to use current limiting in my work recently. This article introduces common current limiting methods.
The article is continuously updated, you can follow the public programmer Alang or visit unread code blog .
This article Github.com/niumoo/JavaNotes has been included, welcome to Star.
foreword
In recent years, with the popularity of , the dependence between services has become stronger and stronger, and the calling relationship has become more and more complex. The stability between services and services has more and more important. When encountering a sudden surge in request volume, malicious user access, or high request frequency that brings greater pressure to downstream services, we often need to ensure that through caching, current limiting, circuit breaker downgrade, load balancing and other means Service stability. Among them, current limiting is an indispensable part. This article introduces the relevant knowledge of current limiting .
1. Current limiting
current limiting , as the name suggests, is to limit the number of requests or concurrency; by limiting the amount of requests within a time window to ensure the normal operation of the system. If our service has limited resources and limited processing capacity, we need to limit the upstream requests that call our service to prevent our own service from stopping due to resource exhaustion.
There are two concepts to understand in throttling.
- threshold : The amount of requests allowed in one unit of time. If the QPS limit is 10, it means that a maximum of 10 requests can be accepted within 1 second.
- Rejection policy : Rejection policy for requests that exceed the threshold. Common rejection policies include direct rejection, queuing, etc.
2. Fixed window algorithm
fixed window algorithm , also known as counter algorithm , is a convenient current limiting algorithm. Mainly through a counter that supports atomic operation to accumulate the number of requests within 1 second, and trigger the rejection policy when the count reaches the current limit threshold within 1 second. Every 1 second, the counter resets to 0 and starts counting again.
2.1. Code implementation
The following is a simple code implementation, the QPS limit is 2, the code here does some optimization , and does not open a separate thread to reset the counter every 1 second, but calculates the time interval for each call to determine Whether to reset the counter first.
/**
* @author https://www.wdbyte.com
*/
public class RateLimiterSimpleWindow {
// 阈值
private static Integer QPS = 2;
// 时间窗口(毫秒)
private static long TIME_WINDOWS = 1000;
// 计数器
private static AtomicInteger REQ_COUNT = new AtomicInteger();
private static long START_TIME = System.currentTimeMillis();
public synchronized static boolean tryAcquire() {
if ((System.currentTimeMillis() - START_TIME) > TIME_WINDOWS) {
REQ_COUNT.set(0);
START_TIME = System.currentTimeMillis();
}
return REQ_COUNT.incrementAndGet() <= QPS;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
Thread.sleep(250);
LocalTime now = LocalTime.now();
if (!tryAcquire()) {
System.out.println(now + " 被限流");
} else {
System.out.println(now + " 做点什么");
}
}
}
}
operation result:
20:53:43.038922 做点什么
20:53:43.291435 做点什么
20:53:43.543087 被限流
20:53:43.796666 做点什么
20:53:44.050855 做点什么
20:53:44.303547 被限流
20:53:44.555008 被限流
20:53:44.809083 做点什么
20:53:45.063828 做点什么
20:53:45.314433 被限流
It can be seen from the output results that there are about 3 operations per second. Since the QPS is limited to 2, the average will be limited once. It seems to be OK, but if we think about it, we will find that this simple current limiting method is problematic. Although we limit the QPS to 2, when we encounter a critical mutation of the time window, such as the last 500 ms in 1s And the first 500ms of the 2s, although the total time is 1s, it can be requested 4 times.
Simply modify the test code to verify:
// 先休眠 400ms,可以更快的到达时间窗口。
Thread.sleep(400);
for (int i = 0; i < 10; i++) {
Thread.sleep(250);
if (!tryAcquire()) {
System.out.println("被限流");
} else {
System.out.println("做点什么");
}
}
In the output, you can see that there are 4 consecutive requests, with an interval of 250 ms, but there is no limit. :
20:51:17.395087 做点什么
20:51:17.653114 做点什么
20:51:17.903543 做点什么
20:51:18.154104 被限流
20:51:18.405497 做点什么
20:51:18.655885 做点什么
20:51:18.906177 做点什么
20:51:19.158113 被限流
20:51:19.410512 做点什么
20:51:19.661629 做点什么
3. Sliding window algorithm
We already know how the fixed window algorithm is implemented and its problems, and the sliding window algorithm is an improvement on the fixed window algorithm. Since the fixed-window algorithm has problems when it encounters a critical mutation of the time window, can't we also adjust the time window before encountering the next time window?
Below is an illustration of a sliding window.
In the example above, the window is slid every 500ms. It can be found that the shorter the window sliding interval, the smaller the probability of the critical mutation of the time window. However, as long as there is a time window, it is still possible to have time window The Critical Mutation Issue .
3.1. Code Implementation
The following is a simple sliding window current limiting tool class based on the above sliding window idea.
package com.wdbyte.rate.limiter;
import java.time.LocalTime;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 滑动窗口限流工具类
*
* @author https://www.wdbyte.com
*/
public class RateLimiterSlidingWindow {
/**
* 阈值
*/
private int qps = 2;
/**
* 时间窗口总大小(毫秒)
*/
private long windowSize = 1000;
/**
* 多少个子窗口
*/
private Integer windowCount = 10;
/**
* 窗口列表
*/
private WindowInfo[] windowArray = new WindowInfo[windowCount];
public RateLimiterSlidingWindow(int qps) {
this.qps = qps;
long currentTimeMillis = System.currentTimeMillis();
for (int i = 0; i < windowArray.length; i++) {
windowArray[i] = new WindowInfo(currentTimeMillis, new AtomicInteger(0));
}
}
/**
* 1. 计算当前时间窗口
* 2. 更新当前窗口计数 & 重置过期窗口计数
* 3. 当前 QPS 是否超过限制
*
* @return
*/
public synchronized boolean tryAcquire() {
long currentTimeMillis = System.currentTimeMillis();
// 1. 计算当前时间窗口
int currentIndex = (int)(currentTimeMillis % windowSize / (windowSize / windowCount));
// 2. 更新当前窗口计数 & 重置过期窗口计数
int sum = 0;
for (int i = 0; i < windowArray.length; i++) {
WindowInfo windowInfo = windowArray[i];
if ((currentTimeMillis - windowInfo.getTime()) > windowSize) {
windowInfo.getNumber().set(0);
windowInfo.setTime(currentTimeMillis);
}
if (currentIndex == i && windowInfo.getNumber().get() < qps) {
windowInfo.getNumber().incrementAndGet();
}
sum = sum + windowInfo.getNumber().get();
}
// 3. 当前 QPS 是否超过限制
return sum <= qps;
}
private class WindowInfo {
// 窗口开始时间
private Long time;
// 计数器
private AtomicInteger number;
public WindowInfo(long time, AtomicInteger number) {
this.time = time;
this.number = number;
}
// get...set...
}
}
The following is the test case, set the QPS to 2, the number of tests is 20 times, each time interval is 300 milliseconds, and the estimated number of successful times is about 12 times.
public static void main(String[] args) throws InterruptedException {
int qps = 2, count = 20, sleep = 300, success = count * sleep / 1000 * qps;
System.out.println(String.format("当前QPS限制为:%d,当前测试次数:%d,间隔:%dms,预计成功次数:%d", qps, count, sleep, success));
success = 0;
RateLimiterSlidingWindow myRateLimiter = new RateLimiterSlidingWindow(qps);
for (int i = 0; i < count; i++) {
Thread.sleep(sleep);
if (myRateLimiter.tryAcquire()) {
success++;
if (success % qps == 0) {
System.out.println(LocalTime.now() + ": success, ");
} else {
System.out.print(LocalTime.now() + ": success, ");
}
} else {
System.out.println(LocalTime.now() + ": fail");
}
}
System.out.println();
System.out.println("实际测试成功次数:" + success);
}
Below are the results of the test.
当前QPS限制为:2,当前测试次数:20,间隔:300ms,预计成功次数:12
16:04:27.077782: success, 16:04:27.380715: success,
16:04:27.684244: fail
16:04:27.989579: success, 16:04:28.293347: success,
16:04:28.597658: fail
16:04:28.901688: fail
16:04:29.205262: success, 16:04:29.507117: success,
16:04:29.812188: fail
16:04:30.115316: fail
16:04:30.420596: success, 16:04:30.725897: success,
16:04:31.028599: fail
16:04:31.331047: fail
16:04:31.634127: success, 16:04:31.939411: success,
16:04:32.242380: fail
16:04:32.547626: fail
16:04:32.847965: success,
实际测试成功次数:11
4. Sliding log algorithm
The sliding log algorithm is another method to implement current limiting, which is relatively simple. The basic logic is to record all request time points. When a new request arrives, first determine whether the number of requests in the latest specified time range exceeds the specified threshold, so as to determine whether the current limit is reached. This method does not have the problem of sudden changes in the time window. The current limit is more accurate, but because the time point of each request needs to be recorded, the memory occupied by is more .
4.1. Code Implementation
The following is a simple sliding log algorithm, because the sliding log needs to store a record separately for each request, which may take up too much memory. So the following implementation is not a rigorous sliding log, but more like a sliding window algorithm that divides 1 second into 1000 time windows.
package com.wdbyte.rate.limiter;
import java.time.LocalTime;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeMap;
/**
* 滑动日志方式限流
* 设置 QPS 为 2.
*
* @author https://www.wdbyte.com
*/
public class RateLimiterSildingLog {
/**
* 阈值
*/
private Integer qps = 2;
/**
* 记录请求的时间戳,和数量
*/
private TreeMap<Long, Long> treeMap = new TreeMap<>();
/**
* 清理请求记录间隔, 60 秒
*/
private long claerTime = 60 * 1000;
public RateLimiterSildingLog(Integer qps) {
this.qps = qps;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 清理过期的数据老数据,最长 60 秒清理一次
if (!treeMap.isEmpty() && (treeMap.firstKey() - now) > claerTime) {
Set<Long> keySet = new HashSet<>(treeMap.subMap(0L, now - 1000).keySet());
for (Long key : keySet) {
treeMap.remove(key);
}
}
// 计算当前请求次数
int sum = 0;
for (Long value : treeMap.subMap(now - 1000, now).values()) {
sum += value;
}
// 超过QPS限制,直接返回 false
if (sum + 1 > qps) {
return false;
}
// 记录本次请求
if (treeMap.containsKey(now)) {
treeMap.compute(now, (k, v) -> v + 1);
} else {
treeMap.put(now, 1L);
}
return sum <= qps;
}
public static void main(String[] args) throws InterruptedException {
RateLimiterSildingLog rateLimiterSildingLog = new RateLimiterSildingLog(3);
for (int i = 0; i < 10; i++) {
Thread.sleep(250);
LocalTime now = LocalTime.now();
if (rateLimiterSildingLog.tryAcquire()) {
System.out.println(now + " 做点什么");
} else {
System.out.println(now + " 被限流");
}
}
}
}
In the code, the threshold QPS is set to 3, and the following log can be obtained when running:
20:51:17.395087 做点什么
20:51:17.653114 做点什么
20:51:17.903543 做点什么
20:51:18.154104 被限流
20:51:18.405497 做点什么
20:51:18.655885 做点什么
20:51:18.906177 做点什么
20:51:19.158113 被限流
20:51:19.410512 做点什么
20:51:19.661629 做点什么
5. Leaky Bucket Algorithm
The leaky bucket in the leaky bucket algorithm is an image metaphor, which can be illustrated by the producer-consumer model. The request is a producer, and each request is like a drop of water. After the request arrives, it is placed in a queue (leaky bucket), There is a hole at the bottom of the bucket, and water droplets are continuously leaking out, just like consumers are constantly consuming the content in the queue, and the consumption rate (leakage speed) is equal to the current limiting threshold. That is, if the QPS is 2, it will be consumed every 1s / 2= 500ms
. The bucket size of the leaky bucket is like the capacity of the queue. When the request accumulation exceeds the specified capacity, the rejection policy will be triggered.
Below is a schematic diagram of the leaky bucket algorithm.
It can be seen from the introduction that the consumption processing in the leaky bucket mode can always be carried out at a constant speed, which can well its own system from being by sudden traffic; but this is also the disadvantage of the leaky bucket mode, assuming that the QPS is 2 , 2 requests come in at the same time, and the 2 requests cannot process the response at the same time, because each 1s / 2= 500ms
can only process one request.
6. Token Bucket Algorithm
The token bucket algorithm is also a common idea to implement current limiting. The most commonly used rate limiting tool class RateLimiter in Google's Java development kit Guava is an implementation of the token bucket. The idea of token bucket implementation is similar to the relationship between producers and consumers.
As a producer, the system service adds tokens to the bucket (container) at a specified frequency. For example, if the QPS is 2, a token is added to the bucket every 500ms. If the number of tokens in the bucket reaches the threshold, no more tokens will be added.
Request execution As a consumer, each request needs to go to the bucket to get a token, and continue to execute when the token is obtained; if there is no token available in the bucket, a rejection policy is triggered, which can be a timeout or a direct Reject this request, thereby achieving the purpose of current limiting.
The following is a schematic diagram of the token bucket current limiting algorithm.
Consider the implementation of token buckets can be characterized as follows.
- 1s / Threshold (QPS) = Token addition interval.
- The capacity of the bucket is equal to the current limit threshold, and when the number of tokens reaches the threshold, no more tokens will be added.
- It can adapt to traffic bursts. When N requests arrive, you only need to obtain N tokens from the bucket to continue processing.
- There is a startup process. When the token bucket is started, there is no token in the bucket, and then tokens are added according to the token addition time interval. If there are a threshold number of requests at startup, the rejection policy will be triggered because there are not enough tokens in the bucket. , but current limiting tools such as RateLimiter have optimized this kind of problem.
6.1. Code Implementation
The rate limiting tool class RateLimiter in Google's Java development kit Guava is an implementation of the token bucket. We will not implement it manually in daily development. Here, RateLimiter is used directly for testing.
Import dependencies:
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</exclusion>
RateLimiter current limiting experience:
// qps 2
RateLimiter rateLimiter = RateLimiter.create(2);
for (int i = 0; i < 10; i++) {
String time = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_TIME);
System.out.println(time + ":" + rateLimiter.tryAcquire());
Thread.sleep(250);
}
In the code, the QPS is limited to 2, that is, a token is generated every 500ms, but the program obtains a token every 250ms, so only one of the two acquisitions will succeed.
17:19:06.797557:true
17:19:07.061419:false
17:19:07.316283:true
17:19:07.566746:false
17:19:07.817035:true
17:19:08.072483:false
17:19:08.326347:true
17:19:08.577661:false
17:19:08.830252:true
17:19:09.085327:false
6.2. Thinking
Although the implementation of RateLimiter in the Google Guava toolkit is demonstrated, we need to think about how to add tokens. If tokens are added at specified intervals, then a thread needs to be opened to add them regularly. If there are many interfaces, many For a RateLimiter instance, the number of threads will increase by , which is obviously not a good way. Apparently Google has also taken this into account. In RateLimiter, it is that calculates whether the token is enough every time the token is acquired. It calculates whether the token is sufficient by storing the time difference between the generation time of the next token and the current time when the token is obtained, combined with the threshold, and records the generation time of the next token for the next call.
The following is the code analysis of the resync()
method of the subclass SmoothRateLimiter of the RateLimiter class in Guava, and you can see the token calculation logic.
void resync(long nowMicros) { // 当前微秒时间
// 当前时间是否大于下一个令牌生成时间
if (nowMicros > this.nextFreeTicketMicros) {
// 可生成的令牌数 newPermits = (当前时间 - 下一个令牌生成时间)/ 令牌生成时间间隔。
// 如果 QPS 为2,这里的 coolDownIntervalMicros 就是 500000.0 微秒(500ms)
double newPermits = (double)(nowMicros - this.nextFreeTicketMicros) / this.coolDownIntervalMicros();
// 更新令牌库存 storedPermits。
this.storedPermits = Math.min(this.maxPermits, this.storedPermits + newPermits);
// 更新下一个令牌生成时间 nextFreeTicketMicros
this.nextFreeTicketMicros = nowMicros;
}
}
7. Redis distributed current limiting
Redis is an open source in-memory database that can be used as a database, cache, message middleware, etc. Redis is single-threaded and operates in memory, so it is extremely fast. Thanks to the various characteristics of Redis, it is very convenient to use Redis to implement a current limiting tool.
The demos below are based on Spring Boot projects and require the following dependencies.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Configure Redis information.
spring:
redis:
database: 0
password:
port: 6379
host: 127.0.0.1
lettuce:
shutdown-timeout: 100ms
pool:
min-idle: 5
max-idle: 10
max-active: 8
max-wait: 1ms
7.1. Fixed Window Current Limit
The fixed window current limit in Redis is implemented using the incr
command. The incr
command is usually used to increment the count; if we use the timestamp information as the key, we can naturally count the number of requests per second, so as to achieve the purpose of current limit.
There are two things to note here.
- For a non-existing key, the value is always 1 when it is added for the first time.
- INCR and EXPIRE command operations should be submitted in a atomic operation to ensure that each key has the correct expiration time, otherwise there will be a memory overflow caused by the key value not being automatically deleted.
Due to the complexity of implementing transactions in Redis, only the lua
script is used here to implement atomic operations. Below is the content of the lua
script.
local count = redis.call("incr",KEYS[1])
if count == 1 then
redis.call('expire',KEYS[1],ARGV[2])
end
if count > tonumber(ARGV[1]) then
return 0
end
return 1
The following is the lua
script call test code implemented using RedisTemplate
in Spring Boot.
/**
* @author https://www.wdbyte.com
*/
@SpringBootTest
class RedisLuaLimiterByIncr {
private static String KEY_PREFIX = "limiter_";
private static String QPS = "4";
private static String EXPIRE_TIME = "1";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
public void redisLuaLimiterTests() throws InterruptedException, IOException {
for (int i = 0; i < 15; i++) {
Thread.sleep(200);
System.out.println(LocalTime.now() + " " + acquire("user1"));
}
}
/**
* 计数器限流
*
* @param key
* @return
*/
public boolean acquire(String key) {
// 当前秒数作为 key
key = KEY_PREFIX + key + System.currentTimeMillis() / 1000;
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setResultType(Long.class);
//lua文件存放在resources目录下
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("limiter.lua")));
return stringRedisTemplate.execute(redisScript, Arrays.asList(key), QPS, EXPIRE_TIME) == 1;
}
}
Although the QPS is limited to 4 in the code, because this current limiting implementation uses the millisecond timestamp as the key, there will be a problem of critical window mutation. The following is the running result. You can see that the change of the time window has caused QPS exceeds the limit of 4.
17:38:23.122044 true
17:38:23.695124 true
17:38:23.903220 true
# 此处有时间窗口变化,所以下面继续 true
17:38:24.106206 true
17:38:24.313458 true
17:38:24.519431 true
17:38:24.724446 true
17:38:24.932387 false
17:38:25.137912 true
17:38:25.355595 true
17:38:25.558219 true
17:38:25.765801 true
17:38:25.969426 false
17:38:26.176220 true
17:38:26.381918 true
7.3. Sliding window current limiting
Through the test of the Redis current limiting method based on the incr
command above, we have found the problems caused by the fixed window current limiting. The third part of this article has introduced the advantages of the sliding window current limiting, which can To greatly reduce the problems caused by the critical mutation of the window, how to use Redis to realize the current limit of the sliding window?
Here, the ZSET
ordered set is mainly used to realize the sliding window current limit. ZSET
set has the following characteristics:
- The key values in the ZSET collection can be sorted automatically.
- The value in the ZSET collection cannot have duplicate values.
- The ZSET collection can easily use the ZCARD command to get the number of elements.
- The ZSET collection can conveniently use the ZREMRANGEBYLEX command to remove a specified range of key values.
Based on the above four-point characteristics, a sliding window current limiting lua
script based on ZSET
can be written.
--KEYS[1]: 限流 key
--ARGV[1]: 时间戳 - 时间窗口
--ARGV[2]: 当前时间戳(作为score)
--ARGV[3]: 阈值
--ARGV[4]: score 对应的唯一value
-- 1. 移除时间窗口之前的数据
redis.call('zremrangeByScore', KEYS[1], 0, ARGV[1])
-- 2. 统计当前元素数量
local res = redis.call('zcard', KEYS[1])
-- 3. 是否超过阈值
if (res == nil) or (res < tonumber(ARGV[3])) then
redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])
return 1
else
return 0
end
The following is the lua
script call test code implemented using RedisTemplate
in Spring Boot.
@SpringBootTest
class RedisLuaLimiterByZset {
private String KEY_PREFIX = "limiter_";
private String QPS = "4";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
public void redisLuaLimiterTests() throws InterruptedException, IOException {
for (int i = 0; i < 15; i++) {
Thread.sleep(200);
System.out.println(LocalTime.now() + " " + acquire("user1"));
}
}
/**
* 计数器限流
*
* @param key
* @return
*/
public boolean acquire(String key) {
long now = System.currentTimeMillis();
key = KEY_PREFIX + key;
String oldest = String.valueOf(now - 1_000);
String score = String.valueOf(now);
String scoreValue = score;
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setResultType(Long.class);
//lua文件存放在resources目录下
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("limiter2.lua")));
return stringRedisTemplate.execute(redisScript, Arrays.asList(key), oldest, score, QPS, scoreValue) == 1;
}
}
The QPS is limited to 4 in the code, and the running result information is consistent with it.
17:36:37.150370 true
17:36:37.716341 true
17:36:37.922577 true
17:36:38.127497 true
17:36:38.335879 true
17:36:38.539225 false
17:36:38.745903 true
17:36:38.952491 true
17:36:39.159497 true
17:36:39.365239 true
17:36:39.570572 false
17:36:39.776635 true
17:36:39.982022 true
17:36:40.185614 true
17:36:40.389469 true
Here are two ways for Redis to implement current limiting. Of course, Redis can also be used to implement two current limiting algorithms, leaky bucket and token bucket. I will not demonstrate it here. If you are interested, you can study it yourself.
8. Summary
This article introduces several ways to implement current limiting, mainly the window algorithm and the bucket algorithm , both of which have their own advantages.
- The window algorithm is simple to implement, with clear logic, and can get the current QPS situation intuitively, but there will be a critical mutation of the time window, and there is no queue to buffer like a bucket.
Although the bucket algorithm is a little complicated and it is not easy to count the QPS situation, the bucket algorithm also has advantages.
- The bucket mode has a constant consumption rate, which can protect its own system , and can shape the traffic, but it cannot respond quickly in the face of burst traffic.
- The token bucket mode can face burst traffic, but there will be a slow acceleration process at startup, but this has been optimized in common open source tools.
Stand-alone current limiting and distributed current
The code-based window algorithm and bucket algorithm current limiting demonstrated above are suitable for single-machine current limiting. If distributed current limiting is required, the current limiting threshold of each service can be calculated in combination with the registry and load balancing, but this will reduce a certain accuracy. If the precision requirement is not too high, it can be used.
The current limiting of Redis, due to the stand-alone nature of Redis, can itself be used for distributed current limiting. Using Redis can implement various algorithms that can be used for current limiting. If you feel troublesome, you can also use open source tools such as redisson, which has encapsulated the current limiting based on Redis.
current limiting
The current limiting toolkit of Guava
has been mentioned in the article, but it is a stand-alone tool after all. There are also many distributed current limiting tools in the open source community. For example, Ali's open source Sentinel is a good tool. Sentinel Multiple dimensions such as circuit breaker downgrade and system load protection protect the stability of services.
As always, the code in the article is located at: github.com/niumoo/JavaNotes
refer to
Redis INCR:https://redis.io/commands/incr
Rate Limiting Wikipedia:https://en.wikipedia.org/wiki/Rate_limiting
SpringBoot Redis:https://www.cnblogs.com/lenve/p/10965667.html
subscription
You can search for programmer Alang or visit unread code blog to read.
This article Github.com/niumoo/JavaNotes has been included, welcome to Star.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。