课程中提到一个需求就是对学生(假定学生姓名不同)的学习数据进行总结,通过分布式实现分别计算学习天数、学习总时长、学习节数、代码行数,原理是逐个接收Student实例,通过流节点清洗后传入四个消费者线程分别计算四个数据,再分别写入map,键是学生名,值是总结数据。如果map中的对应学生姓名的总结数据为null就创建一个新的返回,这里就会引发线程安全的问题,可能负责处理同一条学生学习记录的四个线程拿到了四个实例,再各自写入。
由于是在课程提供的代码基础上修改的,就请别吐槽这个需求为啥要这么麻烦的解决了。重在体会从流到不同消费者的过程。
前置技能:Kafka消费者;Kafka Stream;Java线程安全;单例模式(废话);Redisson中的分布式锁
源码长这样:
学生类:
public class Student implements Serializable {
private static final long serialVersionUID = 8650900359291182992L;
// 姓名
private String name;
// 学习日期
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate stdudyDate;
// 当天学习时长,单位:秒
private int duration;
// 当天学习节数
private int sections;
// 当天代码行数
private int codeRows;
/**
* getter & settrt
*/
}
总结数据类:
public class SummaryData implements Serializable {
private static final long serialVersionUID = 3046479452874028065L;
// 姓名
private String name;
// 学习天数
private int dateCount;
// 学习总时长,单位:秒
private int totalTime;
// 当天学习节数
private int totalSections;
// 当天代码行数
private int totalCodeRows;
/**
* getter & setter
*/
}
学习数据存储的服务实现:
@Service
public class StudyDataLocalServiceImpl implements StudyDataService {
// 采用线程安全的本地存储,模拟数据库
private static final Map<String, SummaryData> LOCAL_STORE = new ConcurrentHashMap<>();
@Override
public void saveStudyData(SummaryData data) {
if (data == null || StringUtils.isBlank(data.getName())) {
return;
}
LOCAL_STORE.put(data.getName(), data);
}
@Override
public SummaryData findStudyData(String name) {
SummaryData sd = LOCAL_STORE.get(name);
if (sd == null) {
sd = new SummaryData();
sd.setName(name);
}
return sd;
}
}
学生数据的发送就不展示了,和主题不太相关
Kafka Stream流处理,主要是做脏数据清除并发送给不同主题的消费者:
@Configuration
@EnableKafkaStreams
public class KafkaStreamsConfiguration {
private static final Logger LOG = LoggerFactory.getLogger(KafkaStreamsConfiguration.class);
@Autowired
private StudyDataService studyDataService;
@Bean
public KStream<String, Student> kStream(StreamsBuilder streamsBuilder) {
KStream<String, String> stream = streamsBuilder.stream("studyData");
KStream<String, Student> stream2 = stream.map((key, value) -> {
// 消息字符串反序列化为对象
Student s;
try {
s = JSON.parseObject(value, Student.class);
} catch (Exception e) {
LOG.error("receive error data. key=" + key + ", value=" + value);
// 数据异常等未知错误,无法反序列化
s = new Student();
}
return new KeyValue<>(key, s);
}).filter((key, value) -> {
//过滤掉错误数据
return StringUtils.isNotBlank(value.getName()) && value.getStdudyDate() != null;
});
// 学习天数
stream2.to("studyDateCount", Produced.with(Serdes.String(), new JsonSerde<>()));
// 学习总时长
stream2.to("studyDuration", Produced.with(Serdes.String(), new JsonSerde<>()));
// 学习节数
stream2.to("studySections", Produced.with(Serdes.String(), new JsonSerde<>()));
// 代码行数
stream2.to("studyCodeRows", Produced.with(Serdes.String(), new JsonSerde<>()));
return stream2;
}
}
Kafka 消费者类:
@Component
public class KafkaStreamListener {
private static final Logger LOG = LoggerFactory.getLogger(KafkaStreamListener.class);
@Autowired
private StudyDataService studyDataService;
/**
* 统计 学习天数
*
* @param record
*/
@KafkaListener(topics = {"studyDateCount"})
public void studyDateCount(ConsumerRecord<String, String> record) {
Optional<?> message = Optional.ofNullable(record);
if (message.isPresent()) {
ConsumerRecord<String, String> consumerRecord = (ConsumerRecord) message.get();
Student student = JSON.parseObject(consumerRecord.value(), Student.class);
SummaryData summaryData = studyDataService.findStudyData(student.getName());
summaryData.setDateCount(summaryData.getDateCount() + 1);
studyDataService.saveStudyData(summaryData);
} else {
LOG.error("---- " + record.key());
}
}
/**
* 学习总时长
*
* @param record
*/
@KafkaListener(topics = {"studyDuration"})
public void studyDuration(ConsumerRecord<String, String> record) {
Optional<?> message = Optional.ofNullable(record);
if (message.isPresent()) {
ConsumerRecord<String, String> consumerRecord = (ConsumerRecord) message.get();
Student student = JSON.parseObject(consumerRecord.value(), Student.class);
SummaryData summaryData = studyDataService.findStudyData(student.getName());
summaryData.setTotalTime(summaryData.getTotalTime() + student.getDuration());
studyDataService.saveStudyData(summaryData);
} else {
LOG.error("---- " + record.key());
}
}
/**
* 学习节数
*
* @param record
*/
@KafkaListener(topics = {"studySections"})
public void studySections(ConsumerRecord<String, String> record) {
Optional<?> message = Optional.ofNullable(record);
if (message.isPresent()) {
ConsumerRecord<String, String> consumerRecord = (ConsumerRecord) message.get();
Student student = JSON.parseObject(consumerRecord.value(), Student.class);
SummaryData summaryData = studyDataService.findStudyData(student.getName());
summaryData.setTotalSections(summaryData.getTotalSections() + student.getSections());
studyDataService.saveStudyData(summaryData);
} else {
LOG.error(">>>> " + record.key());
}
}
/**
* 代码行数
*
* @param record
*/
@KafkaListener(topics = {"studyCodeRows"})
public void studyCodeRows(ConsumerRecord<String, String> record) {
Optional<?> message = Optional.ofNullable(record);
if (message.isPresent()) {
ConsumerRecord<String, String> consumerRecord = (ConsumerRecord) message.get();
Student student = JSON.parseObject(consumerRecord.value(), Student.class);
SummaryData summaryData = studyDataService.findStudyData(student.getName());
summaryData.setTotalCodeRows(summaryData.getTotalCodeRows() + student.getCodeRows());
studyDataService.saveStudyData(summaryData);
} else {
LOG.error(".... " + record.key());
}
}
}
创建了四个消费者,分别接收四种主题的消息,并解析其中的一个字段,重写map对应的学生名键值对。
提到分布式,就要想到并发加锁。例子中,所有数据最后会归并到一个键为学生名,值为上面学习数据总结的map中,即StudyDataLocalService中的ConcurrentHashMap,确保一定的并发安全——使用乐观锁
一个同学的消息会同时发送四次,四个消费程序运行在四个线程上,到这就隐约察觉到问题了:
四个消费者都会调用一次该同学在map中的实例,如果没有,就创建一个,如果同时调用,同时创建四个。因为一个线程只处理其中一个字段,往回写的时候,会替换旧实例。而替换时可能别的线程已写入一个实例,从而导致数据丢失。此处要采用分布式锁,我没有用简单的synchronized关键字,毕竟都分布式了,不能仅考虑在一个程序中实现同步。
举个简单的例子:假设张三同学的一个Student实例发送过来,四个消费者线程同时收到,一并进入studyDataService的findStudyData方法,对于每个线程来说,map中都是不存在姓名为张三的人对应的总结数据实例的,那根据findStudyData方法的逻辑,会创建四个SummaryData实例并分别返回给四个线程,每个线程分别解析一条学生数据存入summaryData实例,但map中只会保存最后写入的消费者线程的summaryData实例,因此实例中仅有一个总结字段是有效的。
我想到的第一个解决办法是在每个消费者线程获取学生实例并写入时上锁,确保整个过程都是单线程进行,这绝对不会出问题,但很慢:
@KafkaListener(topics = {"studySections"})
public void studySections(ConsumerRecord<String, String> record) {
Optional<?> message = Optional.ofNullable(record);
if (message.isPresent()) {
ConsumerRecord<String, String> consumerRecord = (ConsumerRecord) message.get();
Student student = JSON.parseObject(consumerRecord.value(), Student.class);
// 加分布式锁
RLock lock = redissonClient.getLock("saveLock");
lock.lock();
try {
SummaryData summaryData = studyDataService.findStudyData(student.getName());
summaryData.setTotalSections(summaryData.getTotalSections() + student.getSections());
studyDataService.saveStudyData(summaryData);
} catch (Exception e) {
} finally {
lock.unlock();
}
} else {
LOG.error(">>>> " + record.key());
}
}
这里只展示了一个消费者线程,其余的都差不多。选择Redisson的分布式锁、但这样同一时间仅能处理一个学生的一个总结字段,效率非常低。
我重新捋清需求:我希望对于同一学生来说,四个消费者线程获取的总结数据实例是一致的,这与单例模式非常像。
然后我就问gpt有没有更好地解决办法:Java的单例模式是指一个类在整个程序的运行阶段都只能创建一个实例。现在有个需求,就是可能多个线程都会创建学生类,线程先通过学生姓名获取该实例,如果有…
写到这里我就明白了,锁要加在读取并创建实例的地方,确保一个线程创建完了,另一个线程不再创建。看了线程安全的懒汉单例模式,修改了读取算法:
@Override
public SummaryData findStudyData(String name) {
SummaryData sd = LOCAL_STORE.get(name);
if (sd == null) {
// 读取时若未创建实例则加锁
RLock lock = redissonClient.getLock("readStudent");
lock.lock();
try {
if (sd == null) {
sd = new SummaryData();
sd.setName(name);
}
} catch (Exception e) {
} finally {
lock.unlock();
}
}
return sd;
}
如果读取到的总结数据为null,立即上锁,将所有读取该名字对应总结数据的线程卡在这。当第一个线程通过,实例已经创建,其他线程进入同步区还要再检查一次总结数据实例是否为null,不为null就创建,其实还是有问题的。如果恰好多个线程同时来读同一个名字的数据,又都为空,或者如果最先获取实例的线程在没写回之前另一个线程也要读这个实例,那都功亏一篑——对于后续的线程来说,只要map里没有,都会重新创建。所以线程安全中,时间点很重要,确实没考虑清楚,修改后就没问题了:
@Override
public SummaryData findStudyData(String name) {
SummaryData sd = LOCAL_STORE.get(name);
if (sd == null) {
// 读取时若未创建实例则加锁
RLock lock = redissonClient.getLock("readStudent");
lock.lock();
try {
sd = LOCAL_STORE.get(name);
if (sd == null) {
sd = new SummaryData();
sd.setName(name);
LOCAL_STORE.put(name,sd);
}
} catch (Exception e) {
} finally {
lock.unlock();
}
}
return sd;
}
第一个进入同步区的线程,即使重读一次实例还是null,创建新实例并写进map,对于多个想要读取该名字学生数据的线程来说,这个过程是绝对同步的,其他线程都还卡在同步区外,也写不了。新实例写回map后解锁,其他线程进入同步区,读map取到相同实例就不再创建了。
我拿着想了半个多小时的代码兴冲冲地去问助教,会不会有更好的解决办法,助教说:这个除了加锁,没有什么比较好的方式了。但是在分布式里面,就算用了锁,也不能保证没有并发,只能尽可能地去解决这个并发。
随后给我的分布式锁加上了超时检查:
@Override
public SummaryData findStudyData(String name) {
SummaryData sd = LOCAL_STORE.get(name);
if (sd == null) {
// 读取时若未创建实例则加锁
RLock lock = redissonClient.getLock("readStudent");
try {
// 尝试获取锁,设置等待时间
boolean acquired = lock.tryLock(10, 10, TimeUnit.SECONDS);
if (acquired) {
// 再次检查,但在锁保护下
sd = LOCAL_STORE.get(name);
if (sd == null) {
sd = new SummaryData();
sd.setName(name);
LOCAL_STORE.put(name, sd);
}
}
} catch (InterruptedException e) {
} finally {
lock.unlock();
}
}
return sd;
}
确实是没想到,Redisson的RLock是没有超时检查的。如果采用默认的lock( )方法,当一个线程死活不解锁,那所有线程最终都会被卡在这,引发死锁。虽然加入超时检查后,10秒还未获取锁的线程可能会返回空值,但也算比较折中的解决办法了。
到这里,其实还可以优化——关于Redisson的锁名称问题。现在,任意学生的消费者线程都共用一把锁,这没道理的。举个例子:张三同学和李四同学的summaryData实例都没创建,他俩各有一个消费者线程过来,张三同学的线程先拿到锁,那有必要卡着李四同学创建属于他的summaryData实例吗?锁的名称可以变成"readStudent"+学生名,但这样锁会很多,要控制好过期时间。我想到的折中方案是取学生名的第一个字来分段,这样不同学生创建新实例不会太挤,redis的空间开销也减小很多。
个人一点浅显的学习经验吧,希望能帮到大家。如果有大佬愿意指导怎么更优雅地处理这种问题,那就太好了!