halo是基于java开发的一款现代化的开源博客/CMS系统,各方面都做得非常不错,但是截止1.5.0-beta.1版本为止,还没有做对分布式部署相关的支持,集群部署可能会频繁的需要重新登陆,所以我这边研究了一下halo的代码,做了一些改造以支持分布式部署。
前言
怎么部署集群halo,这个完全是由你自己的负载均衡方式来决定的。
如果你是用的是nginx之类的软件负载均衡方式,那么很简单,并不需要做什么代码改造,只需要将nginx中halo服务配置为ip_hash模式即可,配置好之后,所有请求都有nginx分发,nginx服务会自动负载均衡使同一个ip的请求每次都指向同一个机器的服务,这样就不存在session会话不一致的问题了。
但是这个方式有个缺点,你的nginx服务必须在负载均衡链路的最前端(也就是第一层负载均衡),相当于所有请求、流量都必须从nginx所在机器进出。所以nginx所在机器的带宽即你这个网站的能容纳的带宽上限,不能把所有请求比较均匀的分布在多台机器上,那我们部署分布式halo系统还有啥意义。
所以我这边采用的是dns负载均衡的方式,由域名的dns解析服务商来给我们做负载均衡,用户访问域名的时候由dns解析服务轮询分配不同机器的ip给客户,这样我们网站能容纳的带宽就是两台机器的带宽相加了(严格来说不是,但是起码能同时接受更多个用户同时访问了)
分布式部署
-
第一台机器的部署很简单,官网有非常详细的教程,按照步骤一步一步来就ok了。地址:在 Linux 环境部署halo ,部署完成之后启动服务。
-
第二台机器的部署就稍微复杂一点点。先按照官网教程走完第4步
创建 工作目录
,之后不要急着去创建配置文件,我们要保证两台机器的数据一致,所以直接把第一台机器的工作目录
挂载到第二台机器的工作目录
。 这样所有的配置文件、上传文件、主题文件、数据库文件(使用h2数据库的情况下)就全部都同步了起来,具体的挂载办法请看我之前的文章 mount挂载远程目录(centos7.6)。 ps: halo的工作目录为~/.halo
-
mount成功之后就可以启动服务了,如果还有第三台第四台机器的话,重复上面的第2步即可。 ps: 请使用内网ip来操作mount,不然延迟会很大。
-
接下来就可以配置dns解析了,只需要把不同机器的ip都配置到A记录即可,不同dns解析提供商的配置界面不一样,比如腾讯云的dns配置如下图
- dns生效之后通过域名就能访问到不同机器的halo服务了
分布式改造思路
虽然负载均衡已经实现了,但是这种方式在后台操作的时候经常会报未登录,这是因为访问的域名虽然没变,但是访问的机器已经变了,本地存的token新的机器并不认识,所以经常会报未登录。
思路一
最开始我想着这个应该是集群session共享的问题,只要把多台机器的session想办法共享一下就ok了,在github的issues里面也有一个这样的issue 多机部署后台管理登录Session混乱 #1505 也是描述了类似的问题。不过我下载代码后发现halo的用户验证相关逻辑并不是依赖于session操作的,而是单独保存了一个token来做校验,所以session共享这个方案就pass了
思路二
halo的官网文档上有写,halo支持两种存储策略
memory
将数据缓存至内存,重启服务缓存将清空。
level
将数据缓存至本地,重启服务不会清空缓存。
默认的方式是保存在内存中,也可以切换成本地文件存储,所以这时候我又想着既然是文件存储,那直接mount共享不就ok了么。看代码之后发现本地文件就是保存在 ~/.halo
目录的,好家伙,都不需要额外mount了,直接改下cache方式就完事了。
然而现实并没有那么美好,本地存储使用的是leveldb,而leveldb操作的文件有文件锁,两个机器共享直接报错,无法读取。
而这个leveldb我以前也没用过,也不太熟,直接改原本地存储的逻辑也不太合适,所以这个方案也pass了。
思路三
既然本地存储不行,那就直接用第三方存储呗,所以我马上就敲定了使用redis来做缓存的方案,多台实例访问同一个redis,缓存数据就实现了共享,就再也不需要重新登陆了。
分布式改造实施
研究源码发现,缓存这一块的封装、继承都做的很好,不同的存储方案基本上只需要新建不同的实现类即可
如图黄框内是原有的内存、文件存储两种方式的实现类,蓝框内是我新增的redis缓存的实现类
改造的逻辑就是新增一种存储方式(redis),做到可配置,不使用此种方式时可以不配redis。
核心代码如下:
package run.halo.app.cache;
import java.util.LinkedHashMap;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
/**
* redis cache store.
*
* @author luoxx
*/
@Slf4j
public class RedisCacheStore extends AbstractStringCacheStore {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String REDIS_PREFIX = "halo.redis.";
@Override
@NonNull
Optional<CacheWrapper<String>> getInternal(@NonNull String key) {
Assert.hasText(REDIS_PREFIX + key, "Cache key must not be blank");
String value = redisTemplate.opsForValue().get(REDIS_PREFIX + key);
CacheWrapper<String> cacheStore = new CacheWrapper<>();
cacheStore.setData(value);
return Optional.of(cacheStore);
}
@Override
void putInternal(@NonNull String key, @NonNull CacheWrapper<String> cacheWrapper) {
Assert.hasText(key, "Cache key must not be blank");
Assert.notNull(cacheWrapper, "Cache wrapper must not be null");
if (cacheWrapper.getExpireAt() != null) {
long expire = cacheWrapper.getExpireAt().getTime() - System.currentTimeMillis();
redisTemplate.opsForValue().set(
REDIS_PREFIX + key, cacheWrapper.getData(), expire, TimeUnit.MILLISECONDS);
} else {
redisTemplate.opsForValue().set(REDIS_PREFIX + key, cacheWrapper.getData());
}
log.debug("Put [{}] cache : [{}]", key, cacheWrapper);
}
@Override
Boolean putInternalIfAbsent(@NonNull String key, @NonNull CacheWrapper<String> cacheWrapper) {
Assert.hasText(key, "Cache key must not be blank");
Assert.notNull(cacheWrapper, "Cache wrapper must not be null");
log.debug("Preparing to put key: [{}], value: [{}]", key, cacheWrapper);
if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
log.warn("Failed to put the cache, the key: [{}] has been present already", key);
return false;
}
putInternal(key, cacheWrapper);
log.debug("Put successfully");
return true;
}
@Override
public Optional<String> get(String key) {
Assert.notNull(key, "Cache key must not be blank");
return getInternal(key).map(CacheWrapper::getData);
}
@Override
public void delete(@NonNull String key) {
Assert.hasText(key, "Cache key must not be blank");
if (Boolean.TRUE.equals(redisTemplate.hasKey(REDIS_PREFIX + key))) {
redisTemplate.delete(REDIS_PREFIX + key);
log.debug("Removed key: [{}]", key);
}
}
@Override
public LinkedHashMap<String, String> toMap() {
LinkedHashMap<String, String> map = new LinkedHashMap<>();
Set<String> keys = redisTemplate.keys(REDIS_PREFIX + "*");
if (keys != null && !keys.isEmpty()) {
for (String key : keys) {
map.put(key, redisTemplate.opsForValue().get(key));
}
}
return map;
}
@PreDestroy
public void preDestroy() {
//do nothing
}
}
实测,经过改造后,确实不会再弹未登录提示了,博客各项功能暂时也正常。
改动的所有代码我都提交到了github,可以直接去github查看。 地址: 提交记录
改动也发了pr到halo的主分支,不过不确定能不能通过,要是能通过的话,以后的官方版本就能支持redis缓存了。没通过的话也没关系,我这边会把打好的包发布在这里,供需要的朋友下载使用。教程如下:
(更新:pr已通过,已经在1.5.0正式版本中集成了redis配置相关代码,直接下载官方包即可)
使用教程
-
下载地址:
halo-1.5.0-luoxx.jar -
如何安装redis(安装一台即可,缓存很少,没必要redis也搞集群了)
#安装
yum install redis
#启动
service redis start
修改redis密码:只需修改 /etc/redis.conf
文件,将 #requirepass foobared
修改为 requirepass 你的密码
放开访问权限:在/etc/redis.conf
文件中找到 bind 127.0.0.1
这一行,并注释或删除
修改之后执行service redis restart重启redis
-
如何更新halo
请参照官网的教程 版本升级 -
配置修改
修改~/.halo
目录下的application.yaml
配置文件, 缓存方式修改为redis (修改前请先备份)
cache: redis
新增redis数据库相关配置,注意层级是在spring模块下,若redis未设置密码则删除password项
redis:
port: 6379
database: 0
host: 127.0.0.1
password: 123456
配置完成,并且更新jar包后,分别重启多台机器的halo服务即可
完整的示例配置如下
server:
port: 8090
# Response data gzip.
compression:
enabled: false
spring:
datasource:
# MySQL database configuration.
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/halodb?characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 123456
redis:
port: 6379
database: 0
host: 127.0.0.1
password: 123456
halo:
# Your admin client path is https://your-domain/{admin-path}
admin-path: admin
# memory or level or redis
cache: redis
评论区