文章目录
1.简介
对接口限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理
2.限流算法
常用的限流算法由:漏桶算法和令牌桶算法。
2.1.漏桶算法
漏桶(Leaky Bucket)算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率.示意图如下:
2.2.令牌桶算法
令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务,令牌桶算法通过发放令牌,根据令牌的rate频率做请求频率限制,容量限制等
3.基于guava的RateLimiter实现
RateLimiter控制的是访问速率,RateLimiter是令牌桶算法的一种实现方式
3.1.常用方法
方法 | 描述 |
---|---|
create(int permits) | 创建每秒发放permits个令牌的桶 |
acquire() | 不带参数表示获取一个令牌.如果没有令牌则一直等待,返回等待的时间(单位为秒),没有被限流则直接返回0.0 |
acquire(int permits ) | 获取permits 个令牌,.如果没有获取完令牌则一直等待,返回等待的时间(单位为秒),没有被限流则直接返回0.0 |
tryAcquire() | 尝试获取一个令牌,立即返回(非阻塞) |
tryAcquire(int permits) | 尝试获取permits 个令牌,立即返回(非阻塞) |
tryAcquire(long timeout, TimeUnit unit) | 尝试获取1个令牌,带超时时间 |
tryAcquire(int permits, long timeout, TimeUnit unit) | 尝试获取permits个令牌,带超时时间 |
获取令牌方法源码如下
@CanIgnoreReturnValue
public double acquire() {
return this.acquire(1);
}
@CanIgnoreReturnValue
public double acquire(int permits) {
long microsToWait = this.reserve(permits);
this.stopwatch.sleepMicrosUninterruptibly(microsToWait);//会进行线程休眠
return 1.0D * (double)microsToWait / (double)TimeUnit.SECONDS.toMicros(1L);
}
public boolean tryAcquire() {
return this.tryAcquire(1, 0L, TimeUnit.MICROSECONDS);
}
public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
long timeoutMicros = Math.max(unit.toMicros(timeout), 0L);
checkPermits(permits);
long microsToWait;
synchronized(this.mutex()) {
long nowMicros = this.stopwatch.readMicros();
//无参的tryAcquire方法默认的超时时间设置是0,如果在这里没有立即获取到令牌,会直接返回获取令牌失败
if (!this.canAcquire(nowMicros, timeoutMicros)) {
return false;
}
microsToWait = this.reserveAndGetWaitLength(permits, nowMicros);
}
this.stopwatch.sleepMicrosUninterruptibly(microsToWait);
return true;
}
3.2.main函数版本
public static void main(String[] args) {
SimpleDateFormat sdf=new SimpleDateFormat("HH:mm:ss");
long begin = System.currentTimeMillis();
// 每秒允许发放1个令牌
double permits=1.0;
RateLimiter limiter = RateLimiter.create(permits);
for (int i = 1; i <= 10; i++) {
// 获取i个令牌, 当i超过permits会被阻塞
double waitTime = limiter.acquire(i);
System.out.println("curTime=" + sdf.format(new Date()) + " call index:" + i + " waitTime:" + waitTime);
}
long end = System.currentTimeMillis();
System.out.println("begin time:" + sdf.format(new Date(begin))+",end time:"+sdf.format(new Date(end))+",Total task time:"+(end-begin));
}
测试结果如下
当i等于1的时候,直接获取到了令牌,当i大于1的时候会随着i的增长,获取令牌的等待时间也在增长
3.3.API接口限流实战
通关aop实现对接口的限流
3.3.1.引入依赖
<!-- guava 限流 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>25.1-jre</version>
</dependency>
<!--aop切面-->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.4</version>
</dependency>
3.3.2.自定义注解
该注解主要用于AOP功能的切入,不需要属性
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
String limitKey() default ""; //限流的方法名
double value() default 0d; //发放的许可证数量
}
3.3.3.自定义切面类
通过limiter.acquire()来获取令牌,当然也可以通过tryAcquire(int permits, long timeout, TimeUnit unit)来设置等待超时时间的方式获取令牌,如果超timeout为0或则调用无参的tryAcquire(),则代表非阻塞,获取不到立即返回,支持阻塞或可超时的令牌消费。
@Component
@Scope
@Aspect
public class RateLimitAspect {
/**
* 存储限流量和方法必须是static且线程安全
*/
public static Map<String, RateLimiter> rateLimitMap = new ConcurrentHashMap<>();
/**
* 业务层切点
*/
@Pointcut("@annotation(com.ljm.boot.apilimit.limit.RateLimit)")
public void ServiceAspect() {
}
@Around("ServiceAspect()")
public Object around(ProceedingJoinPoint joinPoint) {
Object obj = null;
try {
//获取目标对象
Class<?> clz = joinPoint.getTarget().getClass();
//tryAcquire()是非阻塞, rateLimiter.acquire()是阻塞的
Signature signature = joinPoint.getSignature();
String name = signature.getName();
String limitKey = getLimitKey(clz, name);
RateLimiter rateLimiter = rateLimitMap.get(limitKey);
if (rateLimiter.tryAcquire()) {
obj = joinPoint.proceed();
} else {
//拒绝了请求(服务降级)
obj = "The system is busy, please visit after a while";
}
} catch (Throwable e) {
e.printStackTrace();
}
return obj;
}
private String getLimitKey(Class<?> clz, String methodName) {
for (Method method : clz.getDeclaredMethods()) {
//找出目标方法
if (method.getName().equals(methodName)) {
//判断是否是限流方法
if (method.isAnnotationPresent(RateLimit.class)) {
String key= method.getAnnotation(RateLimit.class).limitKey();
if(key.equals("")){
key=method.getName();
}
return key;
}
}
}
return null;
}
}
3.3.4.初始化限流的许可证数量
/**
* @author Dominick Li
* @CreateTime 2020/5/2 12:35
* @description 初始化需要SemaphoreLimit限流的许可证数量
**/
@Component
public class InitRateLimit implements ApplicationContextAware {
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
Map<String, Object> beanMap = applicationContext.getBeansWithAnnotation(RestController.class);
beanMap.forEach((k, v) -> {
Class<?> controllerClass = v.getClass();
System.out.println(controllerClass.toString());
System.out.println(controllerClass.getSuperclass().toString());
//获取所有声明的方法
Method[] allMethods = controllerClass.getSuperclass().getDeclaredMethods();
for (Method method : allMethods) {
//判断方法是否使用了限流注解
if (method.isAnnotationPresent(RateLimit.class)) {
//获取配置的限流量,实际值可以动态获取,配置key,根据key从配置文件获取
double value = method.getAnnotation(RateLimit.class).value();
String key = method.getAnnotation(RateLimit.class).limitKey();
if(key.equals("")){
key=method.getName();
}
System.out.println("RatelimitKey:" +key+",许可证数是"+value);
//key作为key.value为具体限流量,传递到切面的map中
RateLimitAspect.rateLimitMap.put(key, RateLimiter.create(value));
}
}
});
}
}
3.3.5.web接口
@RestController
public class LimitTestController {
@RateLimit(value =3)
@RequestMapping("/ratelimit")
public String ratelimit() throws Exception{
//假设业务处理了1秒
TimeUnit.SECONDS.sleep(1);
return "success";
}
}
3.3.6.压测
public static void main(String[] args) throws Exception {
///设置线程池最大执行20个线程并发执行任务
int threadSize = 20;
//AtomicInteger通过CAS操作能保证统计数量的原子性
AtomicInteger successCount = new AtomicInteger(0);
CountDownLatch downLatch = new CountDownLatch(20);
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(threadSize);
for (int i = 0; i < threadSize; i++) {
fixedThreadPool.submit(() -> {
RestTemplate restTemplate = new RestTemplate();
String str = restTemplate.getForObject("http://localhost:8010/ratelimit", String.class);
if ("success".equals(str)) {
successCount.incrementAndGet();
}
System.out.println(str);
downLatch.countDown();
});
}
//等待所有线程都执行完任务
downLatch.await();
fixedThreadPool.shutdown();
System.out.println("总共有" + successCount.get() + "个线程获得到了令牌!");
}
可以看到大部分请求直接被拒绝了,只有4个线程获取到了令牌
4.基于Semaphore控制并发数
Semaphore(信号量),是用来控制同时访问特定资源的线程数量,它通过计数来协调各个线程,以保证合理的使用公共资源。我的理解是:信号量控制着一个线程池中并发线程的数量。就好像我们去一家饭店吃饭,这家饭店最多可以同时供应50人,如果饭店中已经坐满50人,这时新来的客人就必须等待,直到有客人离开他们才可以进入,并且总的数量不可以超过50人。这里饭店就好比线程池,饭店里的服务人员和厨师就好比共享的资源,每个客人都相当于一个线程, semaphore就记录着里面的人数,要根据semaphore的数量来决定是否让新的客人进入。为了得到一个资源,每个线程都要先获取permit,以确保当前可以访问。
4.1.常用方法
方法 | 描述 |
---|---|
acquire() | 从许可集中请求获取一个许可,此时当前线程开始阻塞,直到获得一个可用许可,或者当前线程被中断。 |
acquire(int permits) | 从许可集中请求获取指定个数(permits)的许可,此时当前线程开始阻塞,直到获得指定数据(permits)可用许可,或者当前线程被中断。 |
release() | 释放一个许可,将其返回给许可集。 |
release(int permits) | 释放指定个数(permits)许可,将其返回给许可集。 |
tryAcquire() | 尝试获取一个可用许可,如果此时有一个可用的许可,则立即返回true,同时许可集中许可个数减一;如果此时许可集中无可用许可,则立即返回false。 |
tryAcquire(int permits) | 尝试获取指定个数(permits)可用许可,如果此时有指定个数(permits)可用的许可,则立即返回true,同时许可集中许可个数减指定个数(permits);如果此时许可集中许可个数不足指定个数(permits),则立即返回false。 |
tryAcquire(long timeout, TimeUnit unit) | 在给定的等待时间内,尝试获取一个可用许可,如果此时有一个可用的许可,则立即返回true,同时许可集中许可个数减一;如果此时许可集中无可用许可,当前线程阻塞,直至其它某些线程调用此Semaphore的release()方法并且当前线程是下一个被分配许可的线程,或者其它某些线程中断当前线程或者已超出指定的等待时间 |
tryAcquire(int permits, long timeout, TimeUnit unit) | 在给定的等待时间内,尝试获取指定个数(permits)可用许可,如果此时有指定个数(permits)可用的许可,则立即返回true,同时许可集中许可个数减指定个数(permits);如果此时许可集中许可个数不足指定个数(permits),当前线程阻塞,直至其它某些线程调用此Semaphore的release()方法并且当前线程是下一个被分配许可的线程并且许可个数满足指定个数,或者其它某些线程中断当前线程,或者已超出指定的等待时间。 |
4.2.main函数版本
下面代码设置了20个线程并发执行任务,但是通过Semaphore 设置只允许5个并发的执行
public class SemaphoreTest {
private final static Semaphore permit = new Semaphore(5);
public static void main(String[] args) {
//设置线程池最大执行20个线程并发执行任务
int threadSize = 20;
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(threadSize);
for (int i = 0; i < threadSize; i++) {
fixedThreadPool.submit(() -> {
try {
//获取令牌
permit.acquire();
Thread.sleep(1L);
//业务逻辑处理
System.out.println("处理任务的线程是" + Thread.currentThread().getId() + ",当前时间是" + System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放令牌
permit.release();
}
});
}
}
}
执行结果
通过下图可以看到,每毫秒只有5个线程在执行任务
4.3.API接口限流实战
4.3.1.引入依赖
<!--aop切面-->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.4</version>
</dependency>
4.3.2.自定义注解
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SemaphoreLimit {
String limitKey() default ""; //限流的方法名
int value() default 0; //发放的许可证数量
}
4.3.3.自定义切面类
@Component
@Scope
@Aspect
public class SemaphoreLimitAspect {
/**
* 存储限流量和方法必须是static且线程安全
*/
public static Map<String, Semaphore> semaphoreMap = new ConcurrentHashMap<>();
/**
* 业务层切点
*/
@Pointcut("@annotation(com.ljm.boot.ratelimit.limit.SemaphoreLimit)")
public void ServiceAspect() {
}
@Around("ServiceAspect()")
public Object around(ProceedingJoinPoint joinPoint) {
//获取目标对象
Class<?> clz = joinPoint.getTarget().getClass();
//获取增强方法信息
Signature signature = joinPoint.getSignature();
String name = signature.getName();
String limitKey = getLimitKey(clz, name);
Semaphore semaphore = semaphoreMap.get(limitKey);
//立即获取许可证,非阻塞
boolean flag = semaphore.tryAcquire();
Object obj = null;
try {
//拿到许可证则执行任务
if (flag) {
obj = joinPoint.proceed();
} else {
//拒绝了请求(服务降级)
obj = "limitKey:"+limitKey+", The system is busy, please visit after a while";
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
if (flag) semaphore.release(); //拿到许可证后释放通行证
}
return obj;
}
/**
* 获取拦截方法配置的限流key,没有返回null
*/
private String getLimitKey(Class<?> clz, String methodName) {
for (Method method : clz.getDeclaredMethods()) {
//找出目标方法
if (method.getName().equals(methodName)) {
//判断是否是限流方法
if (method.isAnnotationPresent(SemaphoreLimit.class)) {
String key= method.getAnnotation(SemaphoreLimit.class).limitKey();
if(key.equals("")){
key=method.getName();
}
return key;
}
}
}
return null;
}
}
4.3.4.初始化限流的许可证数量
@Component
public class InitSemaphoreLimit implements ApplicationContextAware {
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
Map<String, Object> beanMap = applicationContext.getBeansWithAnnotation(RestController.class);
beanMap.forEach((k, v) -> {
Class<?> controllerClass = v.getClass();
System.out.println(controllerClass.toString());
System.out.println(controllerClass.getSuperclass().toString());
//获取所有声明的方法
Method[] allMethods = controllerClass.getSuperclass().getDeclaredMethods();
for (Method method : allMethods) {
System.out.println(method.getName());
//判断方法是否使用了限流注解
if (method.isAnnotationPresent(SemaphoreLimit.class)) {
//获取配置的限流量,实际值可以动态获取,配置key,根据key从配置文件获取
int value = method.getAnnotation(SemaphoreLimit.class).value();
String key = method.getAnnotation(SemaphoreLimit.class).limitKey();
if(key.equals("")){
key=method.getName();
}
System.out.println("SemaphoreLimitKey:" +key+",许可证数是"+value);
//key作为key.value为具体限流量,传递到切面的map中
SemaphoreLimitAspect.semaphoreMap.put(key, new Semaphore(value));
}
}
});
}
}
4.3.5.web接口
@RestController
public class LimitTestController {
/**
* 设置limitKey=SemaphoreKey,并且许可证只有3个
*/
@SemaphoreLimit(limitKey ="semaphoreKey", value =3)
@RequestMapping("/semaphoreLimit")
public String semaphoreLimit() throws Exception{
//假设业务处理了1秒
TimeUnit.SECONDS.sleep(1);
return "success";
}
}
4.3.6.压测
和标题3.3.6一样,把接口名称改成semaphoreLimit即可。
测试结果如下图
因为我们在调用web接口时候线程休眠了1秒,所以20个线程并发处理任务的时候,只有3个获取到个许可证,
和我们预期的结果一致.
5.基于redission的RateLimiter实现分布式限流
- Redission是Redis官方推荐的客户端,其中RateLimiter实现和guava的RateLimiter类似都是是要令牌桶算法实现限流,
- guava基于内存只能实现单节点的限流,Redission基于redis缓存数据库可以实现分布式限流。
下面示例设置的是限流的接口名称为rate_limiter,限流模式为所有实例共享,时间窗口2秒内流出10个令牌。
- 根据key创建限流对象: client.getRateLimiter(key);
- 设置限流参数: rRateLimiter.trySetRate(RateType.PER_CLIENT, 10,2, RateIntervalUnit.SECONDS);第一个参数表示限流模式,第二个参数表示令牌数,第三个是时间,第四个是时间单位。
- 限流模式包括: RateType.PER_CLIENT(单实例共享),RateType.OVERALL=所有实例共享。
- Redission的RRateLimiter和guava的RateLimiter接口函数大部分类似,就不过多再描述了。
5.1.main函数版本
Main函数测试
public static void main(String[] args) throws Exception {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient client = Redisson.create(config);
RRateLimiter rateLimiter = client.getRateLimiter("rate_limiter");
Integer threadSize=20;
//访问模式 访问数 访问速率 访问时间
//访问模式 RateType.PER_CLIENT=单实例共享 RateType.OVERALL=所有实例共享
rateLimiter.trySetRate(RateType.OVERALL, 10, 2, RateIntervalUnit.SECONDS);
ExecutorService executorService = Executors.newFixedThreadPool(threadSize);
CountDownLatch downLatch = new CountDownLatch(threadSize);
AtomicInteger successCount = new AtomicInteger(0);
for (int i = 0; i < threadSize; i++) {
executorService.submit(() -> {
try {
if(rateLimiter.tryAcquire()){
successCount.incrementAndGet();
System.out.println("处理任务的线程是" + Thread.currentThread().getId() + ",当前时间是" + System.currentTimeMillis());
}
downLatch.countDown();
} catch (Exception e) {
e.printStackTrace();
}
});
}
downLatch.await();
System.out.println(successCount.get());
}
测试效果如下
5.2.API接口限流实战
5.2.1.引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.17.6</version>
</dependency>
5.2.2.自定义注解
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedisRateLimit {
String limitKey() default ""; //限流的方法名
int time() default 1; //默认设置为1秒
int value() default 3; //发放的许可证数量
}
5.2.3.自定义切面类
@Component
@Scope
@Aspect
public class RedisRateLimitAspect {
/**
* 存储限流量和方法必须是static且线程安全
*/
public static Map<String, RRateLimiter> rateLimitMap = new ConcurrentHashMap<>();
/**
* 业务层切点
*/
@Pointcut("@annotation(com.ljm.boot.apilimit.limit.RedisRateLimit)")
public void ServiceAspect() {
}
@Around("ServiceAspect()")
public Object around(ProceedingJoinPoint joinPoint) {
Object obj = null;
try {
//获取目标对象
Class<?> clz = joinPoint.getTarget().getClass();
//tryAcquire()是非阻塞, rateLimiter.acquire()是阻塞的
Signature signature = joinPoint.getSignature();
String name = signature.getName();
String limitKey = getLimitKey(clz, name);
RRateLimiter rateLimiter = rateLimitMap.get(limitKey);
if (rateLimiter.tryAcquire()) {
obj = joinPoint.proceed();
} else {
//拒绝了请求(服务降级)
obj = "The system is busy, please visit after a while";
}
} catch (Throwable e) {
e.printStackTrace();
}
return obj;
}
private String getLimitKey(Class<?> clz, String methodName) {
for (Method method : clz.getDeclaredMethods()) {
//找出目标方法
if (method.getName().equals(methodName)) {
//判断是否是限流方法
if (method.isAnnotationPresent(RedisRateLimit.class)) {
String key= method.getAnnotation(RedisRateLimit.class).limitKey();
if(key.equals("")){
key=method.getName();
}
return key;
}
}
}
return null;
}
}
5.2.4.初始化限流的许可证数量
@Component
public class InitRedisRateLimit implements ApplicationContextAware {
@Autowired
private RedissonClient client;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
Map<String, Object> beanMap = applicationContext.getBeansWithAnnotation(RestController.class);
beanMap.forEach((k, v) -> {
Class<?> controllerClass = v.getClass();
System.out.println(controllerClass.toString());
System.out.println(controllerClass.getSuperclass().toString());
//获取所有声明的方法
Method[] allMethods = controllerClass.getSuperclass().getDeclaredMethods();
RedisRateLimit redisRateLimit;
RRateLimiter rRateLimiter;
for (Method method : allMethods) {
//判断方法是否使用了限流注解
if (method.isAnnotationPresent(RedisRateLimit.class)) {
//获取配置的限流量,实际值可以动态获取,配置key,根据key从配置文件获取
redisRateLimit = method.getAnnotation(RedisRateLimit.class);
String key = redisRateLimit.limitKey();
if (key.equals("")) {
key = method.getName();
}
System.out.println("RedisRatelimitKey:" + key + ",许可证数是" + redisRateLimit.value());
//key作为key.value为具体限流量,传递到切面的map中
rRateLimiter = client.getRateLimiter(key);
//访问模式 访问数 访问速率 访问时间
//访问模式分为 RateType.PER_CLIENT=单实例共享 RateType.OVERALL=所有实例共享
rRateLimiter.trySetRate(RateType.OVERALL, redisRateLimit.value(), redisRateLimit.time(), RateIntervalUnit.SECONDS);
RedisRateLimitAspect.rateLimitMap.put(key, rRateLimiter);
}
}
});
}
}
5.2.5.Web接口
/**
* 设置limitKey=redisRatelimit,并且每2秒许可证只有5个
*/
@RedisRateLimit(limitKey = "redisRatelimit",value =5,time = 2)
@RequestMapping("/redisRatelimit")
public String redisRatelimit() throws Exception{
//假设业务处理了1秒
TimeUnit.SECONDS.sleep(1);
return "success";
}
压测和标题3.3.6代码一样,把接口名称改成redisRatelimit即可。
6.项目配套代码
创作不易,要是觉得我写的对你有点帮助的话,麻烦在gitee上帮我点下 Star
【SpringBoot框架篇】其它文章如下,后续会继续更新。
- 1.搭建第一个springboot项目
- 2.Thymeleaf模板引擎实战
- 3.优化代码,让代码更简洁高效
- 4.集成jta-atomikos实现分布式事务
- 5.分布式锁的实现方式
- 6.docker部署,并挂载配置文件到宿主机上面
- 7.项目发布到生产环境
- 8.搭建自己的spring-boot-starter
- 9.dubbo入门实战
- 10.API接口限流实战
- 11.Spring Data Jpa实战
- 12.使用druid的monitor工具查看sql执行性能
- 13.使用springboot admin对springboot应用进行监控
- 14.mybatis-plus实战
- 15.使用shiro对web应用进行权限认证
- 16.security整合jwt实现对前后端分离的项目进行权限认证
- 17.使用swagger2生成RESTful风格的接口文档
- 18.使用Netty加websocket实现在线聊天功能
- 19.使用spring-session加redis来实现session共享
- 20.自定义@Configuration配置类启用开关
- 21.对springboot框架编译后的jar文件瘦身
- 22.集成RocketMQ实现消息发布和订阅
- 23.集成smart-doc插件零侵入自动生成RESTful格式API文档
- 24.集成FastDFS实现文件的分布式存储
- 25.集成Minio实现文件的私有化对象存储
- 26.集成spring-boot-starter-validation对接口参数校验
- 27.集成mail实现邮件推送带网页样式的消息
- 28.使用JdbcTemplate操作数据库
- 29.Jpa+vue实现单模型的低代码平台
- 30.使用sharding-jdbc实现读写分离和分库分表
- 31.基于分布式锁或xxx-job实现分布式任务调度
- 32.基于注解+redis实现表单防重复提交
- 33.优雅集成i18n实现国际化信息返回
- 34.使用Spring Retry完成任务的重试