并发编程的12种业务场景_数据


文章目录

1.简单定时任务

定时任务框架的源码, 底层为Thread类

public static void init() {
new Thread(() -> {
while (true) {
try {
System.out.println("下载文件");
Thread.sleep(1000 * 60 * 5);
} catch (Exception e) {
log.error(e);
}
}
}).start();
}

使用Thread类可以做最简单的定时任务,在run方法中有个while的死循环。
需要用try…catch捕获异常,否则如果出现异常,就直接退出循环,下次将无法继续执行了。

但这种方式做的定时任务,只能周期性执行,不能支持定时在某个时间点执行。
​​​守护线程​

使用场景:比如项目中有时需要每隔5分钟去下载某个文件,或者每隔10分钟去读取模板文件生成静态html页面等等,一些简单的周期性任务场景。

但是无法应对复杂场景, 比如某个时间点执行任务, 延迟执行。

2.监听器

有时候,我们需要写个监听器,去监听某些数据的变化。

比如:我们在使用​​canal​​的时候,需要监听binlog的变化,能够及时把数据库中的数据,同步到另外一个业务数据库中。

并发编程的12种业务场景_数据库_02
配置中心有个开关,配置监听器是否开启,如果开启了使用单线程异步执行。

@Service
public CanalService {
private volatile boolean running = false;
private Thread thread;

@Autowired
private CanalConnector canalConnector;

public void handle() {
//连接canal
while(running) {
//业务处理
}
}

public void start() {
thread = new Thread(this::handle, "name");
running = true;
thread.start();
}

public void stop() {
if(!running) {
return;
}
running = false;
}
}

在start方法中开启了一个线程,在该线程中异步执行handle方法的具体任务。然后通过调用stop方法,可以停止该线程。

其中,使用volatile关键字控制的running变量作为开关,它可以控制线程中的状态。

apollo配置, 来控制开关

public class CanalConfig {
@Autowired
private CanalService canalService;

@ApolloConfigChangeListener
public void change(ConfigChangeEvent event) {
String value = event.getChange("test.canal.enable").getNewValue();
if(BooleanUtils.toBoolean(value)) {
canalService.start();
} else {
canalService.stop();
}
}
}

通过apollo的ApolloConfigChangeListener注解,可以监听配置参数的变化。

如果test.canal.enable开关配置的true,则调用canalService类的start方法开启canal数据同步功能。如果开关配置的false,则调用canalService类的stop方法,自动停止canal数据同步功能。

3.收集日志

在某些高并发的场景中,我们需要收集部分用户的日志(比如:用户登录的日志),写到数据库中,以便于做分析。

但由于项目中,还没有引入消息中间件,比如:kafka、rocketmq等。

如果直接将日志同步写入数据库,可能会影响接口性能。

​异步处理​​:异步写入数据到数据库

但如果用户登录操作的耗时,比异步写入数据库的时间要少得多。这样导致的结果是:生产日志的速度,比消费日志的速度要快得多,最终的性能瓶颈在消费端。

日志生产端、日志存储端和日志消费端。
并发编程的12种业务场景_定时任务_03
阻塞队列:

@Component
public class LoginLogQueue {
private static final int QUEUE_MAX_SIZE = 1000;

private BlockingQueueblockingQueue queue = new LinkedBlockingQueue<>(QUEUE_MAX_SIZE);

//生成消息
public boolean push(LoginLog loginLog) {
return this.queue.add(loginLog);
}

//消费消息
public LoginLog poll() {
LoginLog loginLog = null;
try {
loginLog = this.queue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
return result;
}
}

日志的生产者

@Service
public class LoginSerivce {

@Autowired
private LoginLogQueue loginLogQueue;

public int login(UserInfo userInfo) {
//业务处理
LoginLog loginLog = convert(userInfo);
loginLogQueue.push(loginLog);
}
}

日志的消费者

@Service
public class LoginInfoConsumer {
@Autowired
private LoginLogQueue queue;

@PostConstruct
public voit init {
new Thread(() -> {
while (true) {
LoginLog loginLog = queue.take();
//写入数据库
}
}).start();
}
}

为了提升性能,也可以使用线程池来处理业务逻辑(比如:写入数据库)等

4.excel导入

我们可能会经常收到运营同学提过来的excel数据导入需求,比如:将某一大类下的所有子类一次性导入系统,或者导入一批新的供应商数据等等。

我们以导入供应商数据为例,它所涉及的业务流程很长,比如:

  1. 调用天眼查接口校验企业名称和统一社会信用代码。
  2. 写入供应商基本表
  3. 写入组织表
  4. 给供应商自动创建一个用户
  5. 给该用户分配权限
  6. 自定义域名
  7. 发站内通知

如果在程序中,解析完excel,读取了所有数据之后。用单线程一条条处理业务逻辑,可能耗时会非常长。

当然在java中实现多线程的手段有很多种,下面重点聊聊java8中最简单的实现方式:​​parallelStream​​。

supplierList.parallelStream().forEach(x -> importSupplier(x));

​parallelStream​​​是一个并行执行的流,它默认通过​​ForkJoinPool​​实现的,能提高你的多线程任务的速度。

​ForkJoinPool​​处理的过程会分而治之,它的核心思想是:将一个大任务切分成多个小任务。每个小任务都能单独执行,最后它会把所用任务的执行结果进行汇总。

并发编程的12种业务场景_数据库_04

5.查询接口

很多时候,我们需要在某个查询接口中,调用其他服务的接口,组合数据之后,一起返回。

在用户信息查询接口中需要返回:用户名称、性别、等级、头像、积分、成长值等信息。

而用户名称、性别、等级、头像在用户服务中,积分在积分服务中,成长值在成长值服务中。为了汇总这些数据统一返回,需要另外提供一个对外接口服务。

于是,用户信息查询接口需要调用用户查询接口、积分查询接口 和 成长值查询接口,然后汇总数据统一返回。

并发编程的12种业务场景_数据库_05
调用远程接口总耗时 530ms = 200ms + 150ms + 180ms

并行

并发编程的12种业务场景_数据_06
在java8之前可以通过实现​​​Callable​​接口,获取线程返回结果。

java8以后通过​​CompleteFuture​​​类实现该功能。我们这里以​​CompleteFuture​​为例:

public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
final UserInfo userInfo = new UserInfo();
CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
getRemoteUserAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);

CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
getRemoteBonusAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);

CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
getRemoteGrowthAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();

userFuture.get();
bonusFuture.get();
growthFuture.get();
return userInfo;
}

6.获取用户上下文

获取的用户上下文,我们以CurrentUser为例。

CurrentUser内部包含了一个​​ThreadLocal​​​对象,它负责保存当前线程的用户上下文信息。当然为了保证在线程池中,也能从用户上下文中获取到正确的用户信息,这里用了阿里的​​TransmittableThreadLocal​​。伪代码如下:

@Data
public class CurrentUser {
private static final TransmittableThreadLocal<CurrentUser> THREA_LOCAL = new TransmittableThreadLocal<>();

private String id;
private String userName;
private String password;
private String phone;
...

public statis void set(CurrentUser user) {
THREA_LOCAL.set(user);
}

public static void getCurrent() {
return THREA_LOCAL.get();
}
}

在线程池中,由于线程会被多次复用,导致从普通的ThreadLocal中无法获取正确的用户信息。父线程中的参数,没法传递给子线程,而TransmittableThreadLocal很好解决了这个问题。

然后在项目中定义一个全局的spring mvc拦截器,专门设置用户上下文到ThreadLocal中。

public class UserInterceptor extends HandlerInterceptorAdapter {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
CurrentUser user = getUser(request);
if(Objects.nonNull(user)) {
CurrentUser.set(user);
}
}
}

用户在请求我们接口时,会先触发该拦截器,它会根据用户cookie中的token,调用接口获取redis中的用户信息。如果能获取到,说明用户已经登录,则把用户信息设置到CurrentUser类的ThreadLocal中。

接下来,在api服务的下层,即business层的方法中,就能轻松通过CurrentUser.getCurrent();方法获取到想要的用户上下文信息了。

并发编程的12种业务场景_开发语言_07
Question:

并发编程的12种业务场景_数据库_08
所以如果mq用户调用了CurrentUser.getCurrent()的话, 就会出现找不到用户上下文的问题

需先判断一下能否从CurrentUser中获取用户信息

@Autowired
private BusinessConfig businessConfig;

CurrentUser user = CurrentUser.getCurrent();
if(Objects.nonNull(user)) {
entity.setUserId(user.getUserId());
entity.setUserName(user.getUserName());
} else {
entity.setUserId(businessConfig.getDefaultUserId());
entity.setUserName(businessConfig.getDefaultUserName());
}

8.传递参数

MDC: Mapped Diagnostic Context, 一个线程安全的存放诊断日志的容器。底层是用了ThreadLocal来保存数据的。

例如现在有这样一种场景:我们使用RestTemplate调用远程接口时,有时需要在header中传递信息,比如:traceId,source等,便于在查询日志时能够串联一次完整的请求链路,快速定位问题。

这种业务场景就能通过​​ClientHttpRequestInterceptor​​接口实现,具体做法如下:

第一步,定义一个LogFilter拦截所有接口请求,在MDC中设置traceId:

public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
MdcUtil.add(UUID.randomUUID().toString());
System.out.println("记录请求日志");
chain.doFilter(request, response);
System.out.println("记录响应日志");
}

@Override
public void destroy() {
}
}

第二步,实现ClientHttpRequestInterceptor接口,MDC中获取当前请求的traceId,然后设置到header中:

public class RestTemplateInterceptor implements ClientHttpRequestInterceptor {

@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().set("traceId", MdcUtil.get());
return execution.execute(request, body);
}
}

第三步,定义配置类,配置上面定义的RestTemplateInterceptor类:

@Configuration
public class RestTemplateConfiguration {

@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setInterceptors(Collections.singletonList(restTemplateInterceptor()));
return restTemplate;
}

@Bean
public RestTemplateInterceptor restTemplateInterceptor() {
return new RestTemplateInterceptor();
}
}

其中MdcUtil其实是利用MDC工具在​​ThreadLocal​​中存储和获取traceId

public class MdcUtil {

private static final String TRACE_ID = "TRACE_ID";

public static String get() {
return MDC.get(TRACE_ID);
}

public static void add(String value) {
MDC.put(TRACE_ID, value);
}
}

能使用MDC保存traceId等参数的根本原因是,用户请求到应用服务器,Tomcat会从线程池中分配一个线程去处理该请求。

保存到MDC的ThreadLocal中的参数,也是该线程独享的,所以不会有线程安全问题。

9.模拟高并发

压力测试: Jmeter、LoadRunner

手写一个简单的模拟并发操作的工具, ​​CountDownLatch​​实现

public static void concurrenceTest() {
/**
* 模拟高并发情况代码
*/
final AtomicInteger atomicInteger = new AtomicInteger(0);
final CountDownLatch countDownLatch = new CountDownLatch(1000); // 相当于计数器,当所有都准备好了,再一起执行,模仿多并发,保证并发量
final CountDownLatch countDownLatch2 = new CountDownLatch(1000); // 保证所有线程执行完了再打印atomicInteger的值
ExecutorService executorService = Executors.newFixedThreadPool(10);
try {
for (int i = 0; i < 1000; i++) {
executorService.submit(new Runnable() {
@Override
public void run() {
try {
countDownLatch.await(); //一直阻塞当前线程,直到计时器的值为0,保证同时并发
} catch (InterruptedException e) {
log.error(e.getMessage(),e);
}
//每个线程增加1000次,每次加1
for (int j = 0; j < 1000; j++) {
atomicInteger.incrementAndGet();
}
countDownLatch2.countDown();
}
});
countDownLatch.countDown();
}

countDownLatch2.await();// 保证所有线程执行完
executorService.shutdown();
} catch (Exception e){
log.error(e.getMessage(),e);
}
}

10.处理mq消息

消息积压问题

如果直接调大partition数量是不行的,历史消息已经存储到4个固定的partition,只有新增的消息才会到新的partition。我们重点需要处理的是已有的partition。

直接加服务节点也不行,因为kafka允许同组的多个partition被一个consumer消费,但不允许一个partition被同组的多个consumer消费,可能会造成资源浪费。

​多线程​

先定义一个线程池:

@Configuration
public class ThreadPoolConfig {

@Value("${thread.pool.corePoolSize:5}")
private int corePoolSize;

@Value("${thread.pool.maxPoolSize:10}")
private int maxPoolSize;

@Value("${thread.pool.queueCapacity:200}")
private int queueCapacity;

@Value("${thread.pool.keepAliveSeconds:30}")
private int keepAliveSeconds;

@Value("${thread.pool.threadNamePrefix:ASYNC_}")
private String threadNamePrefix;

@Bean("messageExecutor")
public Executor messageExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
executor.setKeepAliveSeconds(keepAliveSeconds);
executor.setThreadNamePrefix(threadNamePrefix);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

再定义一个消息的consumer:

@Service
public class MyConsumerService {
@Autowired
private Executor messageExecutor;

@KafkaListener(id="test",topics={"topic-test"})
public void listen(String message){
System.out.println("收到消息:" + message);
messageExecutor.submit(new MyWork(message);
}
}

在定义的Runable实现类中处理业务逻辑:

public class MyWork implements Runnable {
private String message;

public MyWork(String message) {
this.message = message;
}

@Override
public void run() {
System.out.println(message);
}
}

11.统计数量

用多线程导入供应商数据时,统计导入成功的供应商数有多少。

count++并非原子操作

AtomicInteger、AtomicLong等。

@Servcie
public class ImportSupplierService {
private static AtomicInteger count = new AtomicInteger(0);

public int importSupplier(List<SupplierInfo> supplierList) {
if(CollectionUtils.isEmpty(supplierList)) {
return 0;
}

supplierList.parallelStream().forEach(x -> {
try {
importSupplier(x);
count.addAndGet(1);
} catch(Exception e) {
log.error(e.getMessage(),e);
}
);

return count.get();
}
}

AtomicInteger的底层说白了使用自旋锁+CAS。

12.延迟定时任务

如果用户下单后,超过30分钟还未完成支付,则系统自动将该订单取消。

​延迟定时任务​​: ScheduledExecutorService: 多线程的,设计的初衷是为了解决Timer单线程执行,多个任务之间会互相影响的问题。

  • schedule(Runnable command,long delay,TimeUnit unit),带延迟时间的调度,只执行一次,调度之后可通过Future.get()阻塞直至任务执行完毕。
  • schedule(Callablecallable,long delay,TimeUnit unit),带延迟时间的调度,只执行一次,调度之后可通过Future.get()阻塞直至任务执行完毕,并且可以获取执行结果。
  • scheduleAtFixedRate,表示以固定频率执行的任务,如果当前任务耗时较多,超过定时周期period,则当前任务结束后会立即执行。
  • scheduleWithFixedDelay,表示以固定延时执行任务,延时是相对当前任务结束为起点计算开始时间。
public class ScheduleExecutorTest {

public static void main(String[] args) {
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5);
scheduledExecutorService.scheduleAtFixedRate(() -> {
System.out.println("doSomething");
},1000,1000, TimeUnit.MILLISECONDS);
}
}

scheduleAtFixedRate 方法实现周期性任务, 每隔1秒钟执行一次,每次延迟1秒再执行。

Timer, Spring Task, Quatz, Elastic-Job