一、问题背景:压测时的“时间差坑”
做接口压测的人都有过这种经历:用k6跑完压测,报告里显示平均延迟只有50ms,可实际用户用的时候却卡得不行;或者明明压测时的错误率只有1%,生产却报了一堆超时。排除代码、网络的问题后,大概率是踩了“时间差”的坑——k6跑压测的机器(压测机)和被压的服务、数据库这些被压端的时间对不上,差个几十毫秒甚至几秒,导致k6统计的延迟完全不准。
举个最常见的例子:压测机的时间比服务端快了100ms。k6发请求时,压测机的时间戳是1000ms,请求到服务端后,服务端记录的接收时间是900ms(因为服务端慢);k6收到响应时,压测机的时间戳是1050ms,服务端记录的响应时间是950ms。那k6自己算的延迟是1050-1000=50ms,但实际服务端的处理时间是950-900=50ms?不对,更坑的是如果压测机慢:比如压测机时间比服务端慢100ms,k6发请求时压测机时间是900ms,服务端接收时间是1000ms;k6收响应时压测机时间是950ms,服务端响应时间是1050ms。k6算的延迟是50ms,实际服务端处理时间是50ms?哦不,要是服务端处理了200ms呢?比如服务端接收是1000ms,响应是1200ms,那k6收响应时压测机时间是900+200=1100ms(因为压测机慢,所以k6自己加的200ms对应服务端的200ms),k6算的延迟是200ms,但实际服务端的时间差是200ms?不对,换个极端场景:压测机和服务端差了1秒。k6发请求时压测机时间是1000ms,服务端接收是2000ms;k6收响应时压测机时间是1100ms,服务端响应是2100ms。k6算的延迟是100ms,但服务端的处理时间是100ms?哦,原来如果两边的时间差是固定的,k6自己算的延迟(压测机的收时间减发时间)是准的?不对不对,我之前搞错了——k6的延迟统计有两种:一种是“端到端延迟”,是k6自己记录的发请求时间和收响应时间的差,这个只和压测机的时间有关,只要压测机自己的时间是准的,这个差是对的?那什么时候会不准?哦!是当你需要把k6的请求和服务端的日志对应起来的时候!比如你想统计“k6发的某一批请求,服务端处理的平均延迟是多少”,这时候你需要把k6的发时间、收时间和服务端的日志时间做关联,这时候两边的时间差就会导致关联错误,统计出来的服务端延迟完全不准。
哦对,我之前混淆了两个概念:k6自身统计的端到端延迟(只和压测机时间有关),和“服务端处理延迟”(需要结合压测机和服务端的时间统计)。很多时候我们需要后者,比如压测时要定位“是网络延迟高还是服务端处理慢”,这时候就需要把k6的请求和服务端的日志对应起来,这时候时间差就会出问题。
那问题就明确了:当需要跨机器(压测机、服务端、数据库、缓存等)关联压测数据时,各机器的时间差会导致延迟统计失真。那怎么解决?分两步:先校准时间,再把多源的时间数据统一成一个标准时间。
二、第一步:校准各机器的时间差
要解决时间差,首先得把所有参与压测的机器的时间对齐,或者算出它们之间的时间差。最常用的方法是用NTP(网络时间协议),但有时候NTP会有问题,比如机器不在同一个局域网,或者NTP服务器不稳定,导致时间差还是存在。这时候我们需要手动算出各机器之间的时间差。
2.1 用NTP校准时间(基础方案)
NTP是专门用来同步网络中各设备时间的协议,只要所有机器都连到同一个可靠的NTP服务器,就能把时间差控制在毫秒级甚至微秒级。比如国内常用的NTP服务器有:ntp.aliyun.com、ntp1.aliyun.com,或者用公共的ntp.org。
示例:给Linux机器配置NTP同步
技术栈:Linux(CentOS 7)、ntpdate、systemd-timesyncd 首先安装NTP相关工具:
# 安装ntpdate工具,用来手动同步时间
yum install -y ntpdate
# 安装systemd-timesyncd,用来自动同步时间(CentOS 7默认可能有)
yum install -y systemd-timesyncd
然后手动同步一次时间:
# 同步阿里云的NTP服务器
ntpdate ntp.aliyun.com
然后配置自动同步,让时间一直对齐:
# 编辑systemd-timesyncd的配置文件,把NTP服务器改成阿里云的
sed -i 's/^#NTP=.*/NTP=ntp.aliyun.com ntp1.aliyun.com/' /etc/systemd/timesyncd.conf
# 重启服务并设置开机自启
systemctl restart systemd-timesyncd
systemctl enable systemd-timesyncd
最后验证时间是否同步:
# 查看时间同步状态,显示"Sync state: synchronized"就是同步成功
timedatectl status
技术优缺点
优点:配置简单,能自动同步时间,适合大部分场景; 缺点:如果机器不在同一个网络,或者NTP服务器不可用,会导致同步失败;同步精度受网络延迟影响,跨地域的机器同步精度可能在几十毫秒。
注意事项
- 所有机器要连到同一个NTP服务器,避免不同NTP服务器之间的时间差;
- 不要把NTP服务器配置成本地机器,否则会导致同步死循环;
- 对于精度要求极高的场景(比如微秒级),需要用硬件NTP服务器,比如GPS NTP服务器。
2.2 手动计算时间差(精准方案)
如果NTP不可用,或者需要更精准的时间差(比如微秒级),可以手动计算各机器之间的时间差。原理很简单:从一个机器(比如压测机)向另一个机器(比如服务端)发一个请求,同时记录发请求的时间(T1)和收响应的时间(T2),服务端收到请求时记录时间(T3),发响应时记录时间(T4)。那么压测机和服务端的时间差ΔT = (T3 + T4)/2 - (T1 + T2)/2。这个公式的原理是假设网络延迟是对称的(发和收的延迟一样),这样就能算出两边的时间差。
示例:用Python脚本计算时间差
技术栈:Python 3、requests 首先,服务端需要写一个简单的接口,用来返回收到请求的时间:
# 服务端脚本(比如运行在服务端机器上,监听8080端口)
from flask import Flask, jsonify
import time
app = Flask(__name__)
@app.route('/time')
def get_time():
# 服务端收到请求的时间,用微秒级的时间戳
server_time = time.time() * 1000000 # 转换成微秒
return jsonify({'server_time': server_time})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
然后,压测机上写一个脚本,计算和服务端的时间差:
# 压测机脚本,用来计算和服务端的时间差
import requests
import time
# 服务端的接口地址
server_url = 'http://服务端IP:8080/time'
# 发送10次请求,取平均时间差,减少网络波动的影响
delta_ts = []
for _ in range(10):
# 压测机发请求的时间(T1)
t1 = time.time() * 1000000
# 发请求,获取服务端的时间(T3)
response = requests.get(server_url)
# 压测机收响应的时间(T2)
t2 = time.time() * 1000000
# 服务端的时间(T3)
t3 = response.json()['server_time']
# 计算本次的时间差:ΔT = T3 - (T1 + T2)/2
delta_t = t3 - (t1 + t2) / 2
delta_ts.append(delta_t)
# 稍微等一下,避免连续请求导致网络拥堵
time.sleep(0.1)
# 计算平均时间差
avg_delta_t = sum(delta_ts) / len(delta_ts)
print(f'压测机和服务端的平均时间差(微秒):{avg_delta_t}')
# 转换成毫秒,方便后续使用
avg_delta_t_ms = avg_delta_t / 1000
print(f'平均时间差(毫秒):{avg_delta_t_ms}')
技术优缺点
优点:不需要NTP服务器,能精准计算时间差,适合NTP不可用的场景; 缺点:需要手动编写脚本,网络延迟不对称时会有误差;如果网络波动大,需要多次请求取平均。
注意事项
- 发送请求的次数要足够多(比如10次以上),减少网络波动的影响;
- 网络延迟不对称时(比如发的延迟是10ms,收的延迟是20ms),误差会变大,这时候可以用多次测量取中位数的方法;
- 时间差的单位要统一,比如都用微秒或者毫秒。
三、第二步:统一多源时间数据(校准延迟统计)
算出各机器的时间差后,接下来就是把所有机器的时间转换成一个标准时间(比如压测机的时间,或者服务端的时间),这样就能准确统计延迟了。
3.1 场景1:用压测机的时间作为标准
比如我们想统计服务端的处理延迟,服务端的时间是T_server,压测机和服务端的时间差是ΔT(ΔT = T_server - T_client,即服务端时间比压测机快ΔT),那么服务端的时间转换成压测机的时间就是T_client = T_server - ΔT。
举个例子:服务端收到请求的时间是1000ms(服务端时间),压测机和服务端的时间差是ΔT=100ms(服务端比压测机快100ms),那么转换成压测机的时间就是1000 - 100 = 900ms。
示例:用k6脚本统计服务端处理延迟
技术栈:k6、JavaScript 首先,服务端需要在响应头里返回收到请求的时间(服务端时间):
# 服务端修改后的脚本,在响应头里返回收到请求的时间
from flask import Flask, jsonify, make_response
import time
app = Flask(__name__)
@app.route('/test')
def test_api():
# 服务端收到请求的时间(毫秒)
server_receive_time = int(time.time() * 1000)
# 模拟服务端处理时间,比如200ms
time.sleep(0.2)
# 服务端发响应的时间(毫秒)
server_send_time = int(time.time() * 1000)
# 构造响应,把服务端的收、发时间放到响应头里
response = make_response(jsonify({'code': 200}))
response.headers['X-Server-Receive-Time'] = str(server_receive_time)
response.headers['X-Server-Send-Time'] = str(server_send_time)
return response
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
然后,k6脚本里先算出和服务端的时间差,再把服务端的时间转换成压测机的时间,统计服务端处理延迟:
// k6脚本,用来统计服务端处理延迟
import http from 'k6/http';
import { sleep, check } from 'k6';
// 配置压测参数
export const options = {
vus: 10, // 并发用户数
duration: '30s', // 压测时间
};
// 全局变量:压测机和服务端的时间差(毫秒),提前用之前的Python脚本算出
const DELTA_T = 100; // 假设服务端比压测机快100ms
export default function () {
// 压测机发请求的时间(毫秒)
const clientSendTime = Date.now();
// 发请求到服务端
const response = http.get('http://服务端IP:8080/test');
// 压测机收响应的时间(毫秒)
const clientReceiveTime = Date.now();
// 检查请求是否成功
check(response, {
'status is 200': (r) => r.status === 200,
});
// 获取服务端的收、发时间(服务端时间,毫秒)
const serverReceiveTime = parseInt(response.headers['X-Server-Receive-Time']);
const serverSendTime = parseInt(response.headers['X-Server-Send-Time']);
// 把服务端的收、发时间转换成压测机的时间
// 公式:压测机时间 = 服务端时间 - 时间差(因为服务端比压测机快ΔT,所以减ΔT)
const clientReceiveTimeFromServer = serverReceiveTime - DELTA_T;
const clientSendTimeFromServer = serverSendTime - DELTA_T;
// 统计服务端处理延迟(压测机时间下的服务端处理时间)
const serverProcessDelay = clientSendTimeFromServer - clientReceiveTimeFromServer;
// 统计网络延迟(压测机时间下的网络往返时间)
const networkDelay = clientReceiveTime - clientSendTime - serverProcessDelay;
// 把统计结果放到k6的metrics里,方便后续查看
http.cookies['server_process_delay'] = serverProcessDelay;
http.cookies['network_delay'] = networkDelay;
// 每个用户休息1秒
sleep(1);
}
// 自定义metrics,用来记录服务端处理延迟和网络延迟
export function handleSummary(data) {
const serverProcessDelays = data.metrics['server_process_delay'] ? data.metrics['server_process_delay'].values : [];
const networkDelays = data.metrics['network_delay'] ? data.metrics['network_delay'].values : [];
// 计算平均服务端处理延迟
const avgServerProcessDelay = serverProcessDelays.length > 0 ? serverProcessDelays.reduce((a, b) => a + b, 0) / serverProcessDelays.length : 0;
// 计算平均网络延迟
const avgNetworkDelay = networkDelays.length > 0 ? networkDelays.reduce((a, b) => a + b, 0) / networkDelays.length : 0;
// 输出结果
console.log(`平均服务端处理延迟:${avgServerProcessDelay}ms`);
console.log(`平均网络延迟:${avgNetworkDelay}ms`);
return {};
}
技术优缺点
优点:能准确统计服务端处理延迟和网络延迟,适合需要拆分延迟的场景; 缺点:需要修改服务端代码,返回时间戳;如果时间差是动态变化的(比如机器时间漂移),需要定期重新计算时间差。
注意事项
- 时间差的公式要搞对,比如如果压测机比服务端快ΔT,那么服务端时间转换成压测机时间就是T_client = T_server + ΔT;
- 服务端返回的时间戳要足够精确,比如用毫秒级或者微秒级;
- 对于动态时间差的场景,需要定期重新计算时间差,比如每5分钟算一次。
3.2 场景2:用服务端的时间作为标准
比如我们想统计k6发的请求的延迟,或者想把k6的请求和服务端的日志关联起来,这时候可以把压测机的时间转换成服务端的时间。
示例:把k6的请求时间转换成服务端时间
技术栈:k6、JavaScript 还是用之前的k6脚本,假设压测机和服务端的时间差是ΔT=100ms(服务端比压测机快100ms),那么压测机的时间转换成服务端的时间就是T_server = T_client + ΔT。
比如k6发请求的时间是900ms(压测机时间),转换成服务端时间就是900 + 100 = 1000ms,和服务端收到请求的时间一致。
技术优缺点
优点:适合把压测数据和服务端日志关联的场景; 缺点:同样需要修改服务端代码,返回时间戳。
注意事项
- 公式要搞对,避免搞反时间差的方向;
- 对于多源数据(比如服务端、数据库、缓存),需要把所有机器的时间都转换成同一个标准时间。
四、多源时间数据的汇聚(进阶场景)
如果压测涉及到多个被压端,比如服务端、数据库、缓存、消息队列等,需要把所有机器的时间都转换成同一个标准时间,然后汇聚起来统计延迟。
4.1 多源时间差的计算
首先,需要计算压测机和每个被压端的时间差,比如:
- 压测机和服务端的时间差ΔT1;
- 压测机和数据库的时间差ΔT2;
- 压测机和缓存的时间差ΔT3;
然后,把每个被压端的时间都转换成标准时间(比如压测机的时间)。
4.2 多源数据的汇聚
比如我们想统计一个请求的总延迟,包括:
- 网络延迟(压测机到服务端);
- 服务端处理延迟;
- 数据库查询延迟;
- 缓存查询延迟;
这时候需要把每个环节的时间都转换成标准时间,然后计算每个环节的延迟。
示例:多源延迟统计
技术栈:k6、JavaScript、Python 首先,服务端需要返回数据库查询的时间(数据库时间):
# 服务端脚本,返回数据库查询的时间
from flask import Flask, jsonify, make_response
import time
import pymysql
app = Flask(__name__)
# 连接数据库
db = pymysql.connect(host='数据库IP', user='root', password='xxx', db='test')
cursor = db.cursor()
@app.route('/test')
def test_api():
# 服务端收到请求的时间(服务端时间)
server_receive_time = int(time.time() * 1000)
# 模拟服务端处理
time.sleep(0.1)
# 数据库查询,记录数据库收到请求的时间(数据库时间)
db_receive_time = int(time.time() * 1000)
cursor.execute('SELECT * FROM test_table LIMIT 1')
cursor.fetchone()
# 数据库发响应的时间(数据库时间)
db_send_time = int(time.time() * 1000)
# 服务端发响应的时间(服务端时间)
server_send_time = int(time.time() * 1000)
# 构造响应,返回所有环节的时间
response = make_response(jsonify({'code': 200}))
response.headers['X-Server-Receive-Time'] = str(server_receive_time)
response.headers['X-Server-Send-Time'] = str(server_send_time)
response.headers['X-DB-Receive-Time'] = str(db_receive_time)
response.headers['X-DB-Send-Time'] = str(db_send_time)
return response
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
然后,k6脚本里先算出和服务端、数据库的时间差,再把所有时间转换成压测机的时间,统计每个环节的延迟:
// k6脚本,多源延迟统计
import http from 'k6/http';
import { sleep, check } from 'k6';
export const options = {
vus: 10,
duration: '30s',
};
// 提前算出的时间差(毫秒)
const DELTA_T_SERVER = 100; // 服务端比压测机快100ms
const DELTA_T_DB = 150; // 数据库比压测机快150ms
export default function () {
const clientSendTime = Date.now();
const response = http.get('http://服务端IP:8080/test');
const clientReceiveTime = Date.now();
check(response, {
'status is 200': (r) => r.status === 200,
});
// 获取各环节的时间(被压端时间)
const serverReceiveTime = parseInt(response.headers['X-Server-Receive-Time']);
const serverSendTime = parseInt(response.headers['X-Server-Send-Time']);
const dbReceiveTime = parseInt(response.headers['X-DB-Receive-Time']);
const dbSendTime = parseInt(response.headers['X-DB-Send-Time']);
// 转换成压测机时间
const clientServerReceiveTime = serverReceiveTime - DELTA_T_SERVER;
const clientServerSendTime = serverSendTime - DELTA_T_SERVER;
const clientDBReceiveTime = dbReceiveTime - DELTA_T_DB;
const clientDBSendTime = dbSendTime - DELTA_T_DB;
// 统计各环节延迟
const networkDelay = clientReceiveTime - clientSendTime; // 总网络延迟
const serverProcessDelay = clientServerSendTime - clientServerReceiveTime; // 服务端处理延迟
const dbProcessDelay = clientDBSendTime - clientDBReceiveTime; // 数据库处理延迟
const otherDelay = networkDelay - serverProcessDelay - dbProcessDelay; // 其他延迟
// 输出结果
console.log(`总网络延迟:${networkDelay}ms`);
console.log(`服务端处理延迟:${serverProcessDelay}ms`);
console.log(`数据库处理延迟:${dbProcessDelay}ms`);
console.log(`其他延迟:${otherDelay}ms`);
sleep(1);
}
技术优缺点
优点:能精准拆分每个环节的延迟,方便定位性能瓶颈; 缺点:需要修改每个被压端的代码,返回时间戳;时间差的计算和维护比较复杂。
注意事项
- 每个被压端的时间差都要准确计算;
- 时间戳的单位要统一;
- 对于复杂的系统,需要用专门的分布式追踪系统(比如Jaeger、Zipkin)来统计延迟,但分布式追踪的原理也是统一时间。
五、应用场景总结
时间校准和多源时间汇聚的方法,适合以下场景:
- 跨机器的压测延迟统计,比如需要拆分网络延迟和服务端处理延迟;
- 压测数据和服务端日志的关联分析,比如定位某一批请求的性能问题;
- 分布式系统的压测,需要统计每个环节的延迟;
- NTP不可用的场景,比如内网没有NTP服务器,或者机器不在同一个网络。
六、文章总结
k6压测时的时间差问题,本质是不同机器的时间不同步导致的延迟统计失真。解决这个问题分两步:首先校准各机器的时间差,要么用NTP自动同步,要么手动计算时间差;然后把多源的时间数据转换成同一个标准时间,就能准确统计延迟了。
对于大部分场景,用NTP同步时间就足够了;如果需要更精准的延迟统计,或者NTP不可用,就需要手动计算时间差,然后统一时间。对于复杂的分布式系统,还可以结合分布式追踪系统来统计延迟,但原理都是统一时间。
最后要注意的是,时间差的计算和统一要仔细,避免搞反公式,否则会导致延迟统计完全错误。
评论
围绕“k6输出结果与压测机时间偏差导致延迟统计失真,如何校准并汇聚多源时间数据?”参与讨论