使用NTP完成对主机的时钟同步
项目简介
网络时间协议,英文名称:Network Time Protocol(NTP)是用来使计算机时间同步化的一种协议,它可以使计算机对其服务器或时钟源(如石英钟,GPS 等等)做同步化。它建立在 UDP 协议上,端口号为123,在无序的 Internet 环境中提供了精确和健壮的时间服务。
NTP 的实现原理并不是本文章讨论的主要问题。读者有兴趣的话,可以自行搜索其原理实现。
理论上来讲,只要在目标服务器上安装 NTP 服务,任何接入互联网的其他主机都能够直接与该服务器进行时钟同步。
而笔者最近介入的项目中,甲方想要通过该协议,完成其客户的服务器主机定时与甲方服务器主机的时钟同步。

该方案中,后端采用 Vert.x 框架,提供强大的异步事件驱动功能,核心功能是前端发起的手工同步(客户服务器向甲方服务器发起的时钟同步请求),而在此基础上的自动同步则直接使用线程池的定时功能(ScheduledExecutorService)调用该接口。为了保证该功能的安全性,搭配一套登录验证功能以及 Vert.x 框架自带的抵御 CSRF 攻击的 CSRFHandler。
值得一提的是,由于NTP会修改本机服务器的时钟,而 Quartz 的定时任务严重依赖于本地时间,因此并不适合用 Quartz 来为 NTP 同步设置定时任务。事实上,用 ScheduledExecutorService 足矣。
这篇文章,主要是简单描述该核心功能在 Linux 服务器上的 Java 实现。
功能实现
1. 系统命令调用
系统命令调用是实现该功能最简单的方法。直接调用 java.lang.Runtime 类中的 exec() 方法即可:
Process process = Runtime.getruntime().exec(cmd);
在 Linux 服务器上直接执行的命令 cmd 可以直接放入 exec() 方法参数中。比如,想要查询 Linux 服务器的本地时间,可以通过以下命令实现:

而是用 exec() 方法执行的 Java demo 的演示如下:
Process process = Runtime.getRuntime().exec("date");
// 等待该子进程运行结束。returnValue为0表示该进程正常结束
int returnValue = process.waitFor();
System.out.println("returnValue = " + returnValue);
InputStream inputStream = process.getInputStream();
// 通过字符流读取缓冲池的内容
BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
String line = null;
while ((line = br.readLine()) != null) {
System.out.println("line = " + line);
}
将该 demo 打成 jar 包丢到服务器上运行,可以得到如下结果:


关于打 jar 包的方法,有一篇实践简单但操作性比较强的博客供读者参考:https://blog.csdn.net/kelekele111/article/details/123047189。
有了以上的实践基础,我们可以拓展出我们所需要的功能实现。
注意到 Linux 上执行 NTP 同步的命令为(注意,这里只是使用到了其最基础的功能,读者可以自行搜索 ”ntpdate“ 命令,或者在命令行中输入”man ntpdate“,或者”info ntpdate“,来获取更多参数设置方法。推荐阅读:https://www.tutorialspoint.com/unix_commands/ntpdate.htm):
# ntpdate "IP"
ntpdate asia.pool.ntp.org
ntpdate "17.253.84.253"
该命令的返回结果如下所示:

这里的 offset 是通过一定的公式推导得到的结果,表示的是本地服务器时钟与目标服务器时钟之间的时间差,单位为秒(如下图所示)。想要了解该公式推导过程的读者,可以自行搜索了解(推荐阅读:https://www.eecis.udel.edu/~mills/time.html)。

值得注意的是,NTP 只会给请求发起的主机返回 offset,而不会直接提供同步之后的时间戳。因此,如果想要手动通过相关系统调用来修改本地时钟,设置的目标时间戳应该是当前时间戳加上 offset:
Long goalDate = System.currentTimeMillis() + offset;
Date date = new Date(goalDate);
而 ”ntpdate“ 命令这个 Linux 的内核调用,已经将上述步骤囊括其中,不需要再进行额外操作。也就是说,只要执行 ”ntpdate“ 命令,便可直接完成时钟同步。
实际生产中,一般可以将 offset 单独提取出来,用来作为是否显示同步日志的判断根据。这个 offset 的提取,通过解析该命令的返回值实现(后面有代码展示)。
因为,如果每次同步都要给前端返回同步日志,显然会导致同步日志次数过多而导致用户产生错误的判断,认为程序运行存在问题。可以设置一个阈值,比如 20 ms。当 offset 大于该阈值时,同步日志才会返回给前端;否则则不返回。注意:该阈值的设置,与是否同步是没有任何关系的。
结合以上的知识,可以将该程序代码展示如下:
package com.max.runtime.solution;
import java.io.IOException;
import java.io.InputStream;
/**
* @author siyuan
* @description 执行本地Linux命令的工具类
*/
public class LinuxCommandUtil {
/**
* @description 默认执行本地Linux命令
*/
public static String executeLocalLinuxCommand(String[] command) {
Runtime runtime = Runtime.getRuntime();
StringBuilder result = new StringBuilder();
try {
Process process = runtime.exec(command);
try {
// 等待,在该线程上阻塞,直至该线程执行完毕
// 也可以获取该方法的返回值的int型变量,该变量为0时表示正常结束
process.waitFor();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 获取子进程的输入流
InputStream inputStream = process.getInputStream();
byte[] data = new byte[1024];
while (inputStream.read(data) != -1) {
result.append(new String(data, "UTF-8"));
}
if (result.toString().equals("")) {
// 获取子进程的错误流
InputStream errorStream = process.getErrorStream();
while (errorStream.read(data) != -1) {
result.append(new String(data, "UTF-8"));
}
}
// 子进程运行结束,将它摧毁
process.destroy();
return result.toString();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
package com.max.runtime.solution;
import org.apache.commons.lang3.StringUtils;
import java.util.Arrays;
import java.util.Date;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class Main {
private final static int CORE_POOL_SIZE = 2;
private final static long INITIAL_DELAY = 0;
private final static long PERIODIC = 10;
// 以向域名为"asia.pool.ntp.org"的服务器进行同步为例
private final static String[] COMMAND = {"ntpdate", "asia.pool.ntp.org"};
public static void main(String[] args) {
final ScheduledExecutorService scheduledExecutorService = Executors
.newScheduledThreadPool(CORE_POOL_SIZE);
if (StringUtils.containsIgnoreCase(System.getProperty("os.name"), "Linux")) {
System.out.println("该程序在Linux操作系统上运行");
// 调用scheduledAtFixedRate()方法执行定时任务,该定时任务每10 s进行一次时钟同步
scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
public void run() {
String res = LinuxCommandUtil.executeLocalLinuxCommand(COMMAND);
System.out.println(res);
if (res == null) {
throw new RuntimeException("Linux执行命令: " + Arrays.toString(COMMAND) + "失败.");
}
Long gap = ntpGetGap(res);
if (gap == null) {
throw new RuntimeException("偏移量offset解析失败.");
}
System.out.println("服务器时钟与本地时钟之间时间差为: " + (gap >= 0 ? gap : -gap) + "ms");
Date serverTime = new Date(System.currentTimeMillis() + gap);
System.out.println("本地时间为: " + new Date());
System.out.println("服务器时间为: " + serverTime);
}
}, INITIAL_DELAY, PERIODIC, TimeUnit.SECONDS);
} else {
throw new RuntimeException("该程序暂不支持在Windows操作系统上运行!");
}
}