一、一致性Hash演算法
Hash演算法,雜湊函式,顧名思義,它是一個函式。如果把它定義成 hash(key) ,其中 key 表示元素的鍵值,則 hash(key) 的值表示經過雜湊函式計算得到的雜湊值。
常見的Hash演算法如:MD5、SHA-1
Hash演算法在分散式場景中的應用,主要分為兩類:
- 請求的負載均衡
如nginx的ip_hash策略,可以讓同一個客戶端,每次都路由到同一個目標伺服器,可以實現會話粘滯,避免處理session共享問題。
具體步驟就是使用hash演算法根據ip計算hash值,然後根據目標伺服器數量取模。
2. 分散式儲存
以分散式儲存redis叢集為例,有redis1、redis2、redis3三臺伺服器,那麼對某一個key進行儲存呢?就需要針對key進行hash處理,index=hash(key)%3,使用index鎖定儲存伺服器的具體節點。
普通Hash演算法存在的問題
普通的Hash演算法存在一個問題,以ip_hash為例,如果說後臺有3臺tomcat,tomcat2當機了,那麼hash(ip)%3變成了hash(ip)%2,這樣就造成了所有的使用者都需要重新計算。原本路由到tomcat1和tomcat3的那部分ip使用者也會受影響。
一致性Hash演算法
首先我們想象有一個環,這個環起始點是0,結束點是2的32次方-1。這樣我們把伺服器的ip求hash取得一個值,就能對應到環上某一個位置。針對客戶端使用者也是一樣,根據客戶端ip進行hash求值,也能對應到環上某一個位置,然後如何確定一個客戶端路由到哪個伺服器呢?
就按照順時針放行找到最近的伺服器節點。
如果還是上面的場景,3臺tomcat應用,tomcat2當機了。那麼原來tomcat1和tomcat3的使用者不會受影響,而原本應該落到tomat2上的應用會全部落到tomcat1或者tomcat3上。
那這個演算法就沒有問題了嗎?
如果服務端節點比較少,如上圖所示,那麼就會出現資料傾斜問題,大量的請求會路由到節點1,只有少部分能路由到節點2.
為了解決這個問題,一致性hash演算法引入了虛擬節點機制。可以對每個伺服器節點計算多個hash。具體做法可以在每個伺服器ip或主機名後面增加編號來實現。
簡易的一致性hash演算法程式碼如下(僅供學習使用,不能用於生產):
/**
* ⼀致性Hash演算法實現(含虛擬節點)
*/
public class ConsistentHashWithVirtual {
public static void main(String[] args) {
String[] clients=new String[]{"10.177.2.1","10.192.2.1","10.98.45.4"};
String[] tomcatServers = new String[]
{"123.111.0.0","123.101.3.1","111.20.35.2","123.98.26.3"};
//虛擬節點數
int virtualCount=20;
TreeMap<Integer,String> serverMap=new TreeMap<>();
for (String server:tomcatServers){
int serverHash = Math.abs(server.hashCode());
serverMap.put(serverHash,server);
for (int i = 0; i < virtualCount; i++) {
int virtualHash=Math.abs((server+"#"+virtualCount).hashCode());
serverMap.put(virtualHash,"虛擬"+server);
}
}
for (String client:clients){
int clientHash = Math.abs(client.hashCode());
//獲取一個子集。其所有物件的 key 的值大於等於 fromKey
SortedMap<Integer, String> sortedMap =serverMap.tailMap(clientHash);
if(sortedMap.isEmpty()){
Integer firstKey = serverMap.firstKey();
String server = serverMap.get(firstKey);
System.out.println("客戶端:"+client+" 路由到:"+server);
}else {
Integer firstKey = sortedMap.firstKey();
String server = sortedMap.get(firstKey);
System.out.println("客戶端:"+client+" 路由到:"+server);
}
}
}
}
二、叢集時鐘同步
叢集時鐘不同步,指的是叢集裡各個伺服器的時間不一致。因為系統時鐘不一致,資料就會混亂。
叢集時鐘同步思路:
- 伺服器都能聯網的情況
使用linux的定時任務,每隔一段時間執行一次ntpdate命令同步時間。
#使⽤ ntpdate ⽹絡時間同步命令
ntpdate -u ntp.api.bz #從⼀個時間伺服器同步時間
如果ntpdate 命令不存在,可以用如下命令安裝
yum install -y ntp
-
選取叢集中的一個伺服器節點A作為時間伺服器(如果這臺伺服器能夠訪問網際網路,可以讓這臺伺服器和網路時間保持同步,如果不能就手動設定一個時間。)
2.1 設定好伺服器A的時間
2.2 把伺服器A配置為時間伺服器(修改/etc/ntp.conf檔案)1、如果有 restrict default ignore,註釋掉它 2、新增如下⼏⾏內容 restrict 172.17.0.0 mask 255.255.255.0 nomodify notrap # 放開局 域⽹同步功能,172.17.0.0是你的局域⽹⽹段 server 127.127.1.0 # local clock fudge 127.127.1.0 stratum 10 3、重啟⽣效並配置ntpd服務開機⾃啟動 service ntpd restart chkconfig ntpd on
2.2 叢集中其他節點就可以從A伺服器同步時間了
ntpdate 172.17.0.17
三、分散式ID
- 資料庫方式
- redis方式
- UUID方式
- 雪花演算法
雪花演算法程式碼:
/**
* 官方推出,Scala程式語言來實現的
* Java前輩用Java語言實現了雪花演算法
*/
public class IdWorker{
//下面兩個每個5位,加起來就是10位的工作機器id
private long workerId; //工作id
private long datacenterId; //資料id
//12位的序列號
private long sequence;
public IdWorker(long workerId, long datacenterId, long sequence){
// sanity check for workerId
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0",maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0",maxDatacenterId));
}
System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);
this.workerId = workerId;
this.datacenterId = datacenterId;
this.sequence = sequence;
}
//初始時間戳
private long twepoch = 1288834974657L;
//長度為5位
private long workerIdBits = 5L;
private long datacenterIdBits = 5L;
//最大值
private long maxWorkerId = -1L ^ (-1L << workerIdBits);
private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
//序列號id長度
private long sequenceBits = 12L;
//序列號最大值
private long sequenceMask = -1L ^ (-1L << sequenceBits);
//工作id需要左移的位數,12位
private long workerIdShift = sequenceBits;
//資料id需要左移位數 12+5=17位
private long datacenterIdShift = sequenceBits + workerIdBits;
//時間戳需要左移位數 12+5+5=22位
private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
//上次時間戳,初始值為負數
private long lastTimestamp = -1L;
public long getWorkerId(){
return workerId;
}
public long getDatacenterId(){
return datacenterId;
}
public long getTimestamp(){
return System.currentTimeMillis();
}
//下一個ID生成演算法
public synchronized long nextId() {
long timestamp = timeGen();
//獲取當前時間戳如果小於上次時間戳,則表示時間戳獲取出現異常
if (timestamp < lastTimestamp) {
System.err.printf("clock is moving backwards. Rejecting requests until %d.", lastTimestamp);
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
lastTimestamp - timestamp));
}
//獲取當前時間戳如果等於上次時間戳
//說明:還處在同一毫秒內,則在序列號加1;否則序列號賦值為0,從0開始。
if (lastTimestamp == timestamp) { // 0 - 4095
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
//將上次時間戳值重新整理
lastTimestamp = timestamp;
/**
* 返回結果:
* (timestamp - twepoch) << timestampLeftShift) 表示將時間戳減去初始時間戳,再左移相應位數
* (datacenterId << datacenterIdShift) 表示將資料id左移相應位數
* (workerId << workerIdShift) 表示將工作id左移相應位數
* | 是按位或運算子,例如:x | y,只有當x,y都為0的時候結果才為0,其它情況結果都為1。
* 因為個部分只有相應位上的值有意義,其它位上都是0,所以將各部分的值進行 | 運算就能得到最終拼接好的id
*/
return ((timestamp - twepoch) << timestampLeftShift) |
(datacenterId << datacenterIdShift) |
(workerId << workerIdShift) |
sequence;
}
//獲取時間戳,並與上次時間戳比較
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
//獲取系統時間戳
private long timeGen(){
return System.currentTimeMillis();
}
public static void main(String[] args) {
IdWorker worker = new IdWorker(21,10,0);
for (int i = 0; i < 100; i++) {
System.out.println(worker.nextId());
}
}
}
關於分散式ID的更多詳細內容,可以看我的另一篇部落格:分散式主鍵
四、分散式排程
排程,也就是我們所說的定時任務。定時任務的使用場景很多,如訂單超時取消,定時備份資料等等。
那麼分散式排程是什麼意思呢?
有兩層含義:
- 執行在分散式叢集環境下的排程任務(同一個定時任務程式部署多份,只應該有一個定時任務在執行)
- 同一個大的定時任務可以拆分為多個小任務在多個機器上同時執行
在介紹分散式排程框架之前,我們先來回顧一下普通的定時任務框架Quartz.
任務排程框架Quartz回顧
- 引入pom依賴
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>2.3.2</version>
</dependency>
- 程式碼編寫
public class QuartzMain {
public static void main(String[] args) throws SchedulerException {
Scheduler scheduler = createScheduler();
JobDetail job = createJob();
Trigger trigger = createTrigger();
scheduler.scheduleJob(job,trigger);
scheduler.start();
}
/**
* 建立任務排程器
*/
public static Scheduler createScheduler() throws SchedulerException {
SchedulerFactory schedulerFactory=new StdSchedulerFactory();
return schedulerFactory.getScheduler();
}
/**
* 建立一個任務
* @return
*/
public static JobDetail createJob(){
JobBuilder jobBuilder=JobBuilder.newJob(DemoJob.class);
jobBuilder.withIdentity("jobName","myJob");
return jobBuilder.build();
}
/**
* 建立一個作業任務時間觸發器
* @return
*/
public static Trigger createTrigger(){
CronTrigger trigger=TriggerBuilder.newTrigger()
.withIdentity("triggerName","myTrigger")
.startNow()
.withSchedule(CronScheduleBuilder.cronSchedule("0/2 * * * * ?")).build();
return trigger;
}
}
public class DemoJob implements Job {
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
System.out.println("任務執行");
System.out.println(new Date().toLocaleString());
System.out.println(Thread.currentThread().getName());
try {
Thread.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
分散式排程框架Elastic-Job
Elastic-Job介紹
Elastic-Job是噹噹網開源的一個分散式排程解決方案,是基於Quartz二次開發的,由兩個相互獨立的子專案Elastic-Job-Lite和Elastic-Job-Cloud組成。我們要學習的是 Elastic-Job-Lite,它定位為輕量級⽆中⼼
化解決⽅案,使⽤Jar包的形式提供分散式任務的協調服務,⽽Elastic-Job-Cloud⼦項⽬需要結合Mesos以及Docker在雲環境下使⽤
Elastic-Job的github地址:https://github.com/elasticjob
主要功能介紹:
- 分散式排程協調
在分散式環境中,任務能夠按照指定的排程策略執行,並且能夠避免同一個任務多例項的重複執行。
- 豐富的排程策略
- 彈性擴容縮容
當叢集中增加一個例項,塔應當也能夠被選舉並執行任務;當叢集中減少一個例項時,它所執行的任務能被轉移到別的例項來執行。
- 失效轉移
某例項在任務執行失敗後,會被轉移到其他例項執行
- 錯過執行作業重觸發
若因某種原因導致作業錯過執行,自動記錄錯過執行的作業,並在上次作業完成後自動觸發。
- 支援並行排程
支援任務分片,任務分片是指將一個任務分為多個小任務在多個例項中執行
- 作業分片一致性
當任務被分片後,保證同一分片在分散式環境中僅一個執行例項
Elastic-Job-Lite應用
Elastic-Job依賴於Zookeeper進行分散式協調,需要安裝Zookeeper軟體(3.4.6版本以上)。
引入pom
<dependency>
<groupId>com.dangdang</groupId>
<artifactId>elastic-job-lite-core</artifactId>
<version>2.1.5</version>
</dependency>
- 只有一個分片任務的場景
public class ElasticJobMain {
public static void main(String[] args) throws SQLException {
ZookeeperConfiguration zookeeperConfiguration=new ZookeeperConfiguration("localhost:2181","data-archive-job");
CoordinatorRegistryCenter coordinatorRegistryCenter=new ZookeeperRegistryCenter(zookeeperConfiguration);
coordinatorRegistryCenter.init();
//shardingTotalCount設定為1時,啟動多個例項,只能有一個例項執行
JobCoreConfiguration jobCoreConfiguration=JobCoreConfiguration.newBuilder("jobName",
"0/2 * * * * ?",1).build();
SimpleJobConfiguration simpleJobConfiguration=new SimpleJobConfiguration(jobCoreConfiguration,BackupJob.class.getName());
JobScheduler jobScheduler = new JobScheduler(coordinatorRegistryCenter, LiteJobConfiguration.newBuilder(simpleJobConfiguration).build());
jobScheduler.init();
}
}
public class BackupJob implements SimpleJob {
@Override
public void execute(ShardingContext shardingContext) {
String sql="select * from t_order limit 1";
try {
List<Map<String, Object>> list = JdbcUtils.executeQuery(InitData.dataSource, sql);
if(!list.isEmpty()){
Map<String, Object> objectMap = list.get(0);
System.out.println(objectMap);
String insertSql="insert into t_order_bak (id,code,amt,create_time,user_id) values(?,?,?,?,?)";
Collection<Object> values = objectMap.values();
List<Object> params=new ArrayList<>();
params.addAll(values);
JdbcUtils.execute(InitData.dataSource,insertSql,params);
//刪除原來的
String deleteSql="delete from t_order where id=?";
Object id = objectMap.get("id");
JdbcUtils.execute(InitData.dataSource,deleteSql, Arrays.asList(id));
System.out.println("資料:"+id+"備份完成");
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
- 任務分片的情況
public class ElasticJobMain {
public static void main(String[] args) throws SQLException {
ZookeeperConfiguration zookeeperConfiguration=new ZookeeperConfiguration("localhost:2181","data-archive-job");
CoordinatorRegistryCenter coordinatorRegistryCenter=new ZookeeperRegistryCenter(zookeeperConfiguration);
coordinatorRegistryCenter.init();
//shardingTotalCount設定為3,shardingItemParameters為傳入的分片引數,0=後面的值就是0分片將會取到的引數。如0=abc,那麼0分片
//對應shardingContext.getShardingParameter()取到的就是abc
JobCoreConfiguration jobCoreConfiguration=JobCoreConfiguration.newBuilder("jobName2",
"0/2 * * * * ?",3).shardingItemParameters("0=0,1=1,2=2")
.build();
SimpleJobConfiguration simpleJobConfiguration=new SimpleJobConfiguration(jobCoreConfiguration,BackupJob.class.getName());
JobScheduler jobScheduler = new JobScheduler(coordinatorRegistryCenter, LiteJobConfiguration.newBuilder(simpleJobConfiguration).build());
jobScheduler.init();
}
}
public class BackupJob implements SimpleJob {
@Override
public void execute(ShardingContext shardingContext) {
int shardingItem = shardingContext.getShardingItem();
System.out.println("當前分片:"+shardingItem);
String shardingParameter = shardingContext.getShardingParameter();
System.out.println("獲取分片引數:"+shardingParameter);
String sql="select * from t_order where user_id = ? limit 1";
try {
List<Map<String, Object>> list = JdbcUtils.executeQuery(InitData.dataSource, sql,shardingParameter);
if(!list.isEmpty()){
Map<String, Object> objectMap = list.get(0);
System.out.println(objectMap);
String insertSql="insert into t_order_bak (id,code,amt,create_time,user_id) values(?,?,?,?,?)";
Collection<Object> values = objectMap.values();
List<Object> params=new ArrayList<>();
params.addAll(values);
JdbcUtils.execute(InitData.dataSource,insertSql,params);
//刪除原來的
String deleteSql="delete from t_order where id=?";
Object id = objectMap.get("id");
JdbcUtils.execute(InitData.dataSource,deleteSql, Arrays.asList(id));
System.out.println("資料:"+id+"備份完成");
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
五、Session共享
Session問題原因分析
出現這個問題的原因,從根本上來說是因為HTTP協議是無狀態的協議。客戶端和服務端在某次會話中產生的資料不會被保留下來,所以第二次請求服務端無法認識到你曾經來過。後來出現了兩種用於保持Http狀態的技術,就是Cookie和Session。
當叢集中有多臺伺服器時,你在伺服器1上登入了,伺服器1的session裡有了你的資料,下一次請求如果nginx把你路由到其他伺服器,那你又需要登入了,因為其他伺服器上沒有存得有你的資料。
解決Session一致性方案
- 方案一:Nginx的ip_hash策略
同一個客戶端ip的請求都會被路由到同一個目標伺服器
- 優點:配置簡單,不入侵應用
- 缺點:伺服器重啟Session丟失,單點故障問題
- 方案二:Session複製(不推薦)
多個Tomcat之間通過修改配置檔案,達到Session之間的複製
- 優點:不入侵應用,便於擴充套件,伺服器重啟不會造成Session丟失
- 缺點:效能低,記憶體消耗,延遲性
- 方案三:Session集中儲存(推薦)
- 優點:能適應各種負載均衡策略,伺服器重啟不會造成Session丟失
- 缺點:對應用有入侵,引入了和Redis的互動程式碼
Redis Session共享
Spring Session使得基於Redis的Session共享非常簡單。
- 引入依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
- 配置redis
spring.redis.database=0
spring.redis.host=127.0.0.1
spring.redis.port=6379
- 新增註解
在啟動類上增加@EnableRedisHttpSession