在前面的代码中我们已经基本的实现了配置中心的最基本功能,接下来我们就可以思考一下如何对我们的配置中心代码进行优化了。
本地缓存与读写锁
本地文件缓存与读写锁 本地缓存有两种做法,第一种是用ConcurrentHashMap。 第二种是用File的方式随便缓存到本地的一个地方。 先用第一种做,在项目第一次启动的时候,连接到配置中心之后,拉取配置之后, 使用 namespace-group-configid的方式来设定为key,这样子保证了唯一,然后使用value来存储配置文件的content。但是这个缓存的内容只能说帮助我们更好的去比对配置中心的配置是否发生了变更,但是项目一旦重启,这个内存级的缓存内的数据也就都消失了,那么我们重新启动项目的时候依旧需要发送请求去配置中心拉取配置。 所以,我们是不是可以考虑,将配置中心的配置缓存一份作为file的格式,存储在当前机器的某个指定地址,这样子,机器启动的时候,首先可以先先查本地特定路径是否存在有配置文件的file,如果有,进行加载即可,就不需要去配置中心拉取配置了,如果没有,就继续去配置中心拉去配置。 但是这样子做有一个数据一致性需要去考虑,因为我们的配置可能是一直变化的,我们需要考虑如果配置发生了变更我们应该如何解决这个问题。 这里我们可以将解决方法思路整合到上面的监听器中去,同步的修改一下本地的配置文件即可。
- 当从配置中心获取到新的配置更新时,除了更新内存中的配置,也需要更新本地缓存文件。
- 当本地缓存文件更新时,需要确保操作的原子性和一致性,避免文件损坏或数据不一致。
从本地缓存拉取配置的机制是为了提高配置获取的效率和可靠性。这个机制主要基于以下几点考虑:
- 减少网络请求:每次从配置中心直接获取配置都需要进行网络请求,这可能会引入网络延迟,并增加配置中心的负载。通过使用本地缓存,应用可以快速访问最近获取的配置。
- 容错和可靠性:如果因为网络问题或配置中心的服务不可用,应用仍然可以从本地缓存获取配置,保证应用的稳定运行。
- 配置更新机制:客户端会定期或在某些事件触发时与服务端进行通信,检查配置是否有更新。如果配置更新了,客户端会刷新本地缓存。这样,应用即使在配置中心暂时不可用的情况下,也可以使用最新的配置。
- 启动时的配置加载:在应用启动时,会从配置中心获取配置,并将其存储在本地缓存中。这样,在后续的运行中,即使是重启应用,也可以先从本地缓存加载配置,再检查远程配置中心是否有更新,这样可以加快应用启动速度。 这里注意一点,为了考虑代码在不同平台的通用性,我们可以需要先判断当前系统的操作系统,因为windows和linux的路径表示方法还是略有不同的。 代码比较简单,我们直接看配置文件的写入和读取即可。
public class FileUtil {
/**
* 当前方法用于将配置信息写入到磁盘文件
*
* @param key
* @param content
* @throws IOException
*/
public static void writeConfigToFile(String key, String content) throws IOException {
String configFilePath = OSUtil.getConfigFilePath() + File.separator + key.replace(SEPARATOR, "_") + ".txt";
File configFile = new File(configFilePath);
try (BufferedWriter writer = new BufferedWriter(new FileWriter(configFile))) {
writer.write(content);
} catch (IOException e) {
throw new IOException("Failed to write config to file: " + configFilePath, e);
}
}
/**
* 当前方法用于读取配置文件
*
* @param key
* @return
* @throws IOException
*/
public static String readConfigFromFile(String key) throws IOException {
String configFilePath = OSUtil.getConfigFilePath() + File.separator + key.replace(SEPARATOR, "_") + ".txt";
File configFile = new File(configFilePath);
if (!configFile.exists()) {
throw new FileNotFoundException("Config file not found: " + configFilePath);
}
StringBuilder contentBuilder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new FileReader(configFile))) {
String line;
while ((line = reader.readLine()) != null) {
contentBuilder.append(line).append(System.lineSeparator());
}
} catch (IOException e) {
throw new IOException("Failed to read config from file: " + configFilePath, e);
}
return contentBuilder.toString();
}
}
之后,我们在调用getConfig方法试图获取配置的时候,我们就可以先判断本地是否有配置文件了。
@Override
public String getConfig(String configId, String group, String fileExtension) throws BlossomException {
String key = namespace + SEPARATOR + group + SEPARATOR + configId;
try {
String content;
// 尝试从本地文件中读取配置
try {
content = FileUtil.readConfigFromFile(key);
} catch (FileNotFoundException e) {
// 本地文件不存在,从配置中心获取配置
String url = buildGetConfigUrl(group, configId);
content = HttpUtils.sendGetRequest(url);
// 将获取到的配置信息写入本地文件
FileUtil.writeConfigToFile(key, content);
}
// 更新本地内存缓存
ConfigCache cache =
ConfigCache.builder().content(content).key(key).lastCallMd5(MD5Util.toMD5(content))
.type(fileExtension).modifyTimestamp(new Date()).build();
cacheMap.put(key, cache);
return content;
} catch (Exception e) {
throw new BlossomException(BlossomException.GET_CONFIG_ERROR, "Failed to get or write config", e);
}
}
但是,在并发的情况下,可能会出现并发读写文件的问题,因此,我们还需要考虑用到读写锁。 下面的代码中,我的读写锁这样子用会有一些问题,我说的是性能问题。 加读写锁解决并发问题的方式比较简单,写一个简单的工具类。
public class ReadWriteLockUtil {
private static final ConcurrentHashMap<String, ReadWriteLock> lockMap = new ConcurrentHashMap<>();
private static ReadWriteLock getLock(String configId) {
return lockMap.computeIfAbsent(configId, k -> new ReentrantReadWriteLock());
}
/**
* 获取写锁 写锁内容自定义
* @param key
* @param configWriter
*/
public static void withWriteLock(String key, Consumer<String> configWriter) {
ReadWriteLock lock = getLock(key);
lock.writeLock().lock();
try {
configWriter.accept(key);
} finally {
lock.writeLock().unlock();
}
}
/**
* 获取读锁 读锁内容自定义
* @param key
* @param configReader
* @return
* @param <T>
*/
public static <T> T withReadLock(String key, Supplier<T> configReader) {
ReadWriteLock lock = getLock(key);
lock.readLock().lock();
try {
return configReader.get();
} finally {
lock.readLock().unlock();
}
}
}
然后再文件会发生变更的时候,使用读写锁的方式去获取文件的锁。
ReadWriteLockUtil.withWriteLock(key, id -> {
try {
FileUtil.writeConfigToFile(key, finalContent);
} catch (IOException e) {
throw new RuntimeException("Failed to write config to file for key: " + key, e);
}
});
到此,我们就已经简单的基于读写锁并发安全的方式完成了本地文件缓存和配置更新。 其次,对于读写锁,其实后来我又想了一下,其实并不一定要使用Map的方式去存储key和锁,因为其实我们的 内存cache操作和我们的file文件写入操作都需要同时获取锁。 我们可以将对他们两个的锁操作放入到一起。 优化方案就是:在ConfigCache类中添加一个锁属性,因为我们知道,配置文件的缓存key,在项目已启动的时候就已经存在,也就是这个map中的cache中的lock在项目启动的时候就已经初始化了。 之后,当我们需要进行读写锁操作的时候,我们就不需要说一定要去判断那个存放锁的map了,而是可以依赖对于configcache和file的update都是一起进行的这个特性,我们可以考虑用configcache中的这个锁字段,来获取读写锁,然后获取成功在执行接下来的操作。 这样子做的好处就是完全基于了我们自定义的场景来实现读写锁,大大提升了性能。
public class ConfigReadWriteLock {
/**
* 用数字就可以判断读写锁状态了
* 0表示无锁
* 大于0表示读锁
* -1表示写锁
* 读写锁互斥
*/
private int status = 0;
/**
* 获取一个读锁
* @return
*/
public synchronized boolean tryReadLock() {
if (isWriteLocked()) {
return false;
} else {
status++;
return true;
}
}
/**
* 释放一个读锁
*/
public synchronized void releaseReadLock() {
status--;
}
/**
* 只要当前存在读锁就失败
*/
public synchronized boolean tryWriteLock() {
if (!isFree()) {
return false;
} else {
status = -1;
return true;
}
}
public synchronized void releaseWriteLock() {
status = 0;
}
private boolean isWriteLocked() {
return status < 0;
}
private boolean isFree() {
return status == 0;
}
}