先说明业务场景吧:
做的一个课程学习模块,要求同一个人同一个课程章节的学习记录只有一条
之前的处理流程伪代码如下:
if(!isExist()){ //第一步
insert(); //第二步
}
即每次插入前都做判断,在大部分情况下,都不会出问题,但是并发情况下,就极有可能出现重复的数据。因为上述第二步操作依赖于第一步操作,这两步操作并不是原子性的。
先看段单元测试,模拟并发:
package com.inesa.coursecenter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.inesa.coursecenter.domain.entity.LearnLog;
import com.inesa.coursecenter.web.LearnLogController;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@RunWith(SpringRunner.class)
@SpringBootTest
public class LearnLogControllerTest {
@Autowired
@InjectMocks
private LearnLogController learnLogController;
private MockMvc mockMvc;
@Before
public void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(learnLogController).alwaysDo(print()).build();;
}
@Test
public void batchAddLearnLog() throws InterruptedException {
final CountDownLatch countDownLatch = new CountDownLatch(5); // 1
ExecutorService executorService = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
executorService.execute(new Runnable() {
@Override
public void run() {
try {
addLearnLog();
countDownLatch.countDown(); // 2
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
countDownLatch.await(); // 3
executorService.shutdown();
}
private void addLearnLog() throws Exception {
try {
LearnLog learnLog = new LearnLog();
learnLog.setAuth(true);
learnLog.setUserId("a83f6f8d972b434eb8b1b690e9215b5c");
learnLog.setCourseId("559");
learnLog.setChapterId("7fefe1dd9b484becadaac1e036fc7132");
learnLog.setOrgId("32");
learnLog.setLearnHour(Double.valueOf(20));
ObjectMapper mapper = new ObjectMapper();
String requestJson = mapper.writeValueAsString(learnLog);
System.out.println(requestJson);
mockMvc.perform(post("/learn")
.accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.result").value("Success"))
.andReturn();
} catch (Exception e) {
e.printStackTrace();
}
}
}
测试代码中,标注的三行非常重要。如果去掉这三行,线程池中的代码还来不及运行,主程序就将线程池shutdown了。对于CountDownLatch的用法,如果不清楚的请参考我的另一篇博文线程安全性--atomic里面有详细的说明。 运行完上述代码,并发导致的重复插入问题就很好复现了,如图所示,同一个人,同一个章节的学习记录插入了四条数据,影响后续做统计或计算。
处理方案:
private ReentrantLock lock = new ReentrantLock();
@Override
@Transactional(rollbackFor = Exception.class)
public RestfulResult add(LearnLog learnLog, Chapter chapter, Course course) {
lock.lock();
try {
String existId = exist(learnLog);
if (!StringUtils.isBlank(existId)) {
learnLog.setId(existId);
} else {
if (DoubleUtil.isEqual(chapter.getAllHour(), learnLog.getLearnHour())) {
learnLog.setStatus(String.valueOf(Enums.ChapterStatus.LEARNED.getValue()));
} else {
learnLog.setStatus(String.valueOf(Enums.ChapterStatus.LEARNING.getValue()));
}
saveBase(learnLog);
learnLog.setUserId(learnLog.getCreateBy());
if (learnLog.getPer() == null) {
getChapterPer(learnLog, chapter.getAllHour());
} else {
if (DoubleUtil.isEqual(learnLog.getPer(), Double.valueOf(100))) {
learnLog.setStatus(String.valueOf(Enums.ChapterStatus.LEARNED.getValue()));
}
}
learnLogMapper.insertSelective(learnLog);
//异步更新课程学习进度
executorCourse.executorUpdateCourseStatus(course, learnLog.getUserId());
return RestfulResult.success(learnLog);
}
} finally {
lock.unlock();
}
//更新 课程章节学习完之后状态不再修改
LearnLog tmp = getById(learnLog.getId());
if (!String.valueOf(Enums.ChapterStatus.LEARNED.getValue()).equals(tmp.getStatus())) {
if (DoubleUtil.isEqual(chapter.getAllHour(), learnLog.getLearnHour())) {
tmp.setStatus(String.valueOf(Enums.ChapterStatus.LEARNED.getValue()));
} else {
tmp.setStatus(String.valueOf(Enums.ChapterStatus.LEARNING.getValue()));
}
}
tmp.setLearnHour(learnLog.getLearnHour());
beforeUpdate(tmp);
if (learnLog.getPer() == null) {
getChapterPer(tmp, chapter.getAllHour());
} else {
tmp.setPer(learnLog.getPer());
if (DoubleUtil.isEqual(learnLog.getPer(), Double.valueOf(100))) {
learnLog.setStatus(String.valueOf(Enums.ChapterStatus.LEARNED.getValue()));
}
}
learnLogMapper.updateByPrimaryKeySelective(tmp);
//异步更新课程学习进度
executorCourse.executorUpdateCourseStatus(course, learnLog.getUserId());
return RestfulResult.success(learnLog);
}
先暂时这么处理,本地3个人50个并发没有问题,等待线上的反应