This is an automated email from the ASF dual-hosted git repository.
zhaoqingran pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new 780710f22 Integration QuestDB as Time Series Database Storage (#3731)
780710f22 is described below
commit 780710f2264aebded446726beeb1637223ba0d08
Author: 铁甲小宝 <[email protected]>
AuthorDate: Tue Sep 2 14:42:51 2025 +0800
Integration QuestDB as Time Series Database Storage (#3731)
Co-authored-by: Calvin <[email protected]>
---
.../src/main/resources/application.yml | 5 +
hertzbeat-warehouse/pom.xml | 6 +
.../warehouse/constants/WarehouseConstants.java | 2 +
.../history/tsdb/questdb/QuestdbDataStorage.java | 412 +++++++++++++++++++++
.../history/tsdb/questdb/QuestdbProperties.java | 39 ++
home/docs/start/questdb-init.md | 136 +++++++
.../current/start/questdb-init.md | 132 +++++++
material/licenses/LICENSE | 1 +
pom.xml | 1 +
9 files changed, 734 insertions(+)
diff --git a/hertzbeat-manager/src/main/resources/application.yml
b/hertzbeat-manager/src/main/resources/application.yml
index 2666eec9a..7060aca09 100644
--- a/hertzbeat-manager/src/main/resources/application.yml
+++ b/hertzbeat-manager/src/main/resources/application.yml
@@ -208,6 +208,11 @@ warehouse:
database: public
username: greptime
password: greptime
+ questdb:
+ enabled: false
+ url: localhost:9000
+ username: admin
+ password: quest
iot-db:
enabled: false
host: 127.0.0.1
diff --git a/hertzbeat-warehouse/pom.xml b/hertzbeat-warehouse/pom.xml
index d9aa5fcbf..95a32a501 100644
--- a/hertzbeat-warehouse/pom.xml
+++ b/hertzbeat-warehouse/pom.xml
@@ -83,6 +83,12 @@
</exclusion>
</exclusions>
</dependency>
+ <!-- QuestDB -->
+ <dependency>
+ <groupId>org.questdb</groupId>
+ <artifactId>questdb</artifactId>
+ <version>${questdb.version}</version>
+ </dependency>
<!-- influxdb Here, support for version 1.7, use influxdb-java, Full
support for version 2.x requires influxdb-client-java -->
<dependency>
<groupId>org.influxdb</groupId>
diff --git
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java
index c90436cc1..b646b5f9e 100644
---
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java
+++
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java
@@ -44,6 +44,8 @@ public interface WarehouseConstants {
String VM = "victoria-metrics";
String VM_CLUSTER = "victoria-metrics.cluster";
+
+ String QUEST_DB = "questdb";
}
/**
diff --git
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java
new file mode 100644
index 000000000..b6afd7c33
--- /dev/null
+++
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java
@@ -0,0 +1,412 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.warehouse.store.history.tsdb.questdb;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+
+import com.google.common.collect.Maps;
+import io.questdb.client.Sender;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.ConnectionPool;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.apache.hertzbeat.common.constants.CommonConstants;
+import org.apache.hertzbeat.common.constants.MetricDataConstants;
+import org.apache.hertzbeat.common.constants.NetworkConstants;
+import org.apache.hertzbeat.common.entity.arrow.RowWrapper;
+import org.apache.hertzbeat.common.entity.dto.Value;
+import org.apache.hertzbeat.common.entity.message.CollectRep;
+import org.apache.hertzbeat.common.util.JsonUtil;
+import
org.apache.hertzbeat.warehouse.store.history.tsdb.AbstractHistoryDataStorage;
+import org.apache.http.ssl.SSLContexts;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+/**
+ * HistoryQuestdbDataStorage class
+ */
+@Component
+@ConditionalOnProperty(prefix = "warehouse.store.questdb", name = "enabled",
havingValue = "true")
+@Slf4j
+public class QuestdbDataStorage extends AbstractHistoryDataStorage {
+
+ private static final String QUERY_HISTORY_SQL = "SELECT timestamp AS ts,
instance, %s AS value FROM \"%s\" WHERE timestamp >= %s ORDER BY timestamp
DESC";
+
+ private static final String QUERY_HISTORY_SQL_WITH_INSTANCE = "SELECT
timestamp AS ts, instance, %s AS value FROM \"%s\" WHERE instance = '%s' AND
timestamp >= %s ORDER BY timestamp DESC";
+
+ private static final String QUERY_HISTORY_INTERVAL_WITH_INSTANCE_SQL =
+ "SELECT timestamp AS ts, first(%s) AS origin, avg(%s) AS mean,
max(%s) AS max, min(%s) AS min FROM \"%s\" WHERE instance = '%s' AND timestamp
>= %s SAMPLE BY 4h";
+
+ private static final String QUERY_INSTANCE_SQL = "SELECT DISTINCT instance
FROM \"%s\"";
+
+ private Sender sender;
+
+ private OkHttpClient client;
+
+ private String queryBaseUrl;
+
+ private QuestdbProperties questdbProperties;
+
+ public QuestdbDataStorage(QuestdbProperties questdbProperties) {
+ this.questdbProperties = questdbProperties;
+ this.initQuestDb(questdbProperties);
+ }
+
+ public void initQuestDb(QuestdbProperties questdbProperties) {
+ String ilpAddress = questdbProperties.url(); // e.g., "localhost:9009"
+ this.sender = Sender.builder(Sender.Transport.HTTP)
+ .address(ilpAddress)
+ .httpUsernamePassword(questdbProperties.username(),
questdbProperties.password())
+ .build();
+
+ // Parse host and set query base URL (assuming HTTP port is 9000)
+ String[] parts = ilpAddress.split(":");
+ String host = parts[0];
+ String queryPort = "9000"; // Default QuestDB HTTP port
+ this.queryBaseUrl = "http://" + host + ":" + queryPort +
"/exec?query=";
+
+ this.client = new OkHttpClient.Builder()
+
.readTimeout(NetworkConstants.HttpClientConstants.READ_TIME_OUT,
TimeUnit.SECONDS)
+
.writeTimeout(NetworkConstants.HttpClientConstants.WRITE_TIME_OUT,
TimeUnit.SECONDS)
+
.connectTimeout(NetworkConstants.HttpClientConstants.CONNECT_TIME_OUT,
TimeUnit.SECONDS)
+ .connectionPool(new ConnectionPool(
+
NetworkConstants.HttpClientConstants.MAX_IDLE_CONNECTIONS,
+
NetworkConstants.HttpClientConstants.KEEP_ALIVE_TIMEOUT,
+ TimeUnit.SECONDS)
+ ).sslSocketFactory(defaultSslSocketFactory(),
defaultTrustManager())
+ .hostnameVerifier(noopHostnameVerifier())
+ .retryOnConnectionFailure(true)
+ .build();
+
+ this.serverAvailable = this.checkConnection();
+ }
+
+ private boolean checkConnection() {
+ // Test query to check if server is available
+ Map<String, Object> result = executeQuery("SELECT 1");
+ return result != null && result.containsKey("dataset");
+ }
+
+
+ @Override
+ public void saveData(CollectRep.MetricsData metricsData) {
+ if (!isServerAvailable() || metricsData.getCode() !=
CollectRep.Code.SUCCESS || metricsData.getValues().isEmpty()) {
+ return;
+ }
+ String table = this.generateTable(metricsData.getApp(),
metricsData.getMetrics(), metricsData.getId());
+
+ try {
+ RowWrapper rowWrapper = metricsData.readRow();
+
+ while (rowWrapper.hasNextRow()) {
+ rowWrapper = rowWrapper.nextRow();
+ // Wrap the construction of each row in a try-catch block
+ // to prevent one bad row from corrupting the sender's state.
+ try {
+ // 1. Set the table for the new row.
+ sender.table(table);
+ // 2. Process and write all symbols FIRST.
+ Map<String, String> labels =
Maps.newHashMapWithExpectedSize(8);
+ rowWrapper.cellStream()
+ .filter(cell ->
cell.getMetadataAsBoolean(MetricDataConstants.LABEL))
+ .forEach(cell ->
labels.put(cell.getField().getName(), cell.getValue()));
+ if (!labels.isEmpty()) {
+ sender.symbol("instance", JsonUtil.toJson(labels));
+ } else {
+ sender.symbol("instance", metricsData.getApp()
+ + "_" + metricsData.getMetrics());
+ }
+
+ // 3. Now, process and write all other columns (fields).
+ rowWrapper.cellStream().forEach(cell -> {
+ if
(CommonConstants.NULL_VALUE.equals(cell.getValue())) {
+ return;
+ }
+ String fieldName = cell.getField().getName();
+ String fieldValue = cell.getValue();
+ Byte type =
cell.getMetadataAsByte(MetricDataConstants.TYPE);
+
+ if (type == CommonConstants.TYPE_NUMBER) {
+ sender.doubleColumn(fieldName,
Double.parseDouble(fieldValue));
+ } else if (type == CommonConstants.TYPE_STRING) {
+ sender.stringColumn(fieldName, fieldValue);
+ }
+ });
+
+ // 4. Finally, set the timestamp to commit the row.
+ sender.atNow();
+
+ } catch (Exception e) {
+ log.error("[warehouse questdb]--Could not process a row,
cancelling it. Error: {}", e.getMessage());
+ // IMPORTANT: Cancel the partially built row to reset the
sender's state.
+ sender.cancelRow();
+ }
+ }
+ sender.flush();
+ } catch (Exception e) {
+ log.error("[warehouse questdb]--Error during batch save: {}",
e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public Map<String, List<Value>> getHistoryMetricData(Long monitorId,
String app, String metrics, String metric, String label, String history) {
+ String table = this.generateTable(app, metrics, monitorId);
+ String dateAdd = getDateAdd(history);
+ String selectSql = label == null ? String.format(QUERY_HISTORY_SQL,
metric, table, dateAdd)
+ : String.format(QUERY_HISTORY_SQL_WITH_INSTANCE, metric,
table, label.replace("'", "\\'"), dateAdd);
+ Map<String, List<Value>> instanceValueMap = new HashMap<>(8);
+ try {
+ Map<String, Object> selectResult = executeQuery(selectSql);
+ if (selectResult == null || !selectResult.containsKey("dataset")) {
+ return instanceValueMap;
+ }
+ List<Map<String, String>> columns = (List<Map<String, String>>)
selectResult.get("columns");
+ List<List<Object>> dataset = (List<List<Object>>)
selectResult.get("dataset");
+
+ Map<String, Integer> colMap = new HashMap<>();
+ for (int i = 0; i < columns.size(); i++) {
+ colMap.put(columns.get(i).get("name"), i);
+ }
+ int tsIdx = colMap.get("ts");
+ int instanceIdx = colMap.get("instance");
+ int valueIdx = colMap.get("value");
+
+ for (List<Object> row : dataset) {
+ String tsStr = (String) row.get(tsIdx);
+ long time = Instant.parse(tsStr).toEpochMilli();
+ String instanceValue = row.get(instanceIdx) == null ? "" :
(String) row.get(instanceIdx);
+ Object valObj = row.get(valueIdx);
+ String strValue = valObj == null ? null :
this.parseDoubleValue(valObj.toString());
+ if (strValue == null) {
+ continue;
+ }
+ List<Value> valueList =
instanceValueMap.computeIfAbsent(instanceValue, k -> new LinkedList<>());
+ valueList.add(new Value(strValue, time));
+ }
+ } catch (Exception e) {
+ log.error("select history metric data in questdb error, sql:{},
msg: {}", selectSql, e.getMessage());
+ }
+ return instanceValueMap;
+ }
+
+ @Override
+ public Map<String, List<Value>> getHistoryIntervalMetricData(Long
monitorId, String app, String metrics, String metric, String label, String
history) {
+ String table = this.generateTable(app, metrics, monitorId);
+ String dateAdd = getDateAdd(history);
+ Map<String, List<Value>> instanceValueMap = new HashMap<>(8);
+ Set<String> instances = new HashSet<>(8);
+ if (label != null) {
+ instances.add(label);
+ }
+ if (instances.isEmpty()) {
+ // query the instance
+ String queryInstanceSql = String.format(QUERY_INSTANCE_SQL, table);
+ Map<String, Object> instanceQueryResult =
executeQuery(queryInstanceSql);
+ if (instanceQueryResult != null &&
instanceQueryResult.containsKey("dataset")) {
+ List<List<Object>> dataset = (List<List<Object>>)
instanceQueryResult.get("dataset");
+ for (List<Object> row : dataset) {
+ if (!row.isEmpty() && row.get(0) != null) {
+ instances.add(row.get(0).toString());
+ }
+ }
+ }
+ }
+
+ try {
+ if (instances.isEmpty()) {
+ instances.add("");
+ }
+ for (String instanceValue : instances) {
+ String selectSql =
String.format(QUERY_HISTORY_INTERVAL_WITH_INSTANCE_SQL, metric, metric, metric,
metric, table, instanceValue.replace("'", "\\'"), dateAdd);
+ Map<String, Object> selectResult = executeQuery(selectSql);
+ if (selectResult == null ||
!selectResult.containsKey("dataset")) {
+ continue;
+ }
+ List<Map<String, String>> columns = (List<Map<String,
String>>) selectResult.get("columns");
+ List<List<Object>> dataset = (List<List<Object>>)
selectResult.get("dataset");
+
+ Map<String, Integer> colMap = new HashMap<>();
+ for (int i = 0; i < columns.size(); i++) {
+ colMap.put(columns.get(i).get("name"), i);
+ }
+ int tsIdx = colMap.get("ts");
+ int originIdx = colMap.get("origin");
+ int meanIdx = colMap.get("mean");
+ int maxIdx = colMap.get("max");
+ int minIdx = colMap.get("min");
+
+ for (List<Object> row : dataset) {
+ String tsStr = (String) row.get(tsIdx);
+ long time = Instant.parse(tsStr).toEpochMilli();
+ Value.ValueBuilder valueBuilder =
Value.builder().time(time);
+
+ Object originObj = row.get(originIdx);
+ if (originObj != null) {
+
valueBuilder.origin(this.parseDoubleValue(originObj.toString()));
+ } else {
+ continue;
+ }
+ Object meanObj = row.get(meanIdx);
+ if (meanObj != null) {
+
valueBuilder.mean(this.parseDoubleValue(meanObj.toString()));
+ } else {
+ continue;
+ }
+ Object maxObj = row.get(maxIdx);
+ if (maxObj != null) {
+
valueBuilder.max(this.parseDoubleValue(maxObj.toString()));
+ } else {
+ continue;
+ }
+ Object minObj = row.get(minIdx);
+ if (minObj != null) {
+
valueBuilder.min(this.parseDoubleValue(minObj.toString()));
+ } else {
+ continue;
+ }
+ List<Value> valueList =
instanceValueMap.computeIfAbsent(instanceValue, k -> new LinkedList<>());
+ valueList.add(valueBuilder.build());
+ }
+ }
+ } catch (Exception e) {
+ log.error("select history interval metric data in questdb error,
msg: {}", e.getMessage());
+ }
+ return instanceValueMap;
+ }
+
+ private Map<String, Object> executeQuery(String sql) {
+ try {
+ String encodedSql = URLEncoder.encode(sql, StandardCharsets.UTF_8);
+ String url = queryBaseUrl + encodedSql + "×tamptype=rfc3339";
+ String authHeader = "Basic " + Base64.getEncoder().encodeToString(
+ (questdbProperties.username() + ":" +
questdbProperties.password())
+ .getBytes(StandardCharsets.UTF_8)
+ );
+ Request request = new Request.Builder()
+ .url(url)
+ .addHeader("Authorization", authHeader)
+ .get()
+ .build();
+ try (Response response = client.newCall(request).execute()) {
+ if (!response.isSuccessful()) {
+ log.error("QuestDB query failed: {} - {}",
response.code(), response.message());
+ return null;
+ }
+ String body = response.body().string();
+ return JsonUtil.fromJson(body, Map.class);
+ }
+ } catch (Exception e) {
+ log.error("Error executing QuestDB query: {} - {}", sql,
e.getMessage());
+ return null;
+ }
+ }
+
+ private String getDateAdd(String history) {
+ history = history.toLowerCase();
+ char unitChar = history.charAt(history.length() - 1);
+ int count = Integer.parseInt(history.substring(0, history.length() -
1));
+ String unit;
+ switch (unitChar) {
+ case 'd':
+ unit = "d";
+ break;
+ case 'h':
+ unit = "h";
+ break;
+ case 'm': // minute
+ unit = "m";
+ break;
+ case 's':
+ unit = "s";
+ break;
+ default: throw new IllegalArgumentException("Invalid history unit:
" + unitChar);
+ }
+ return String.format("dateadd('%s', %d, now())", unit, -count);
+ }
+
+ private String generateTable(String app, String metrics, Long monitorId) {
+ return app + "_" + metrics + "_" + monitorId;
+ }
+
+ private String parseDoubleValue(String value) {
+ return (new BigDecimal(value)).setScale(4,
RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
+ }
+
+ private static X509TrustManager defaultTrustManager() {
+ return new X509TrustManager() {
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] certs, String
authType) {
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] certs, String
authType) {
+ }
+ };
+ }
+
+ private static SSLSocketFactory defaultSslSocketFactory() {
+ try {
+ SSLContext sslContext = SSLContexts.createDefault();
+ sslContext.init(null, new TrustManager[]{
+ defaultTrustManager()
+ }, new SecureRandom());
+ return sslContext.getSocketFactory();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static HostnameVerifier noopHostnameVerifier() {
+ return (s, sslSession) -> true;
+ }
+
+ @Override
+ public void destroy() throws Exception {
+ if (this.sender != null) {
+ this.sender.close();
+ }
+ if (this.client != null) {
+ this.client.dispatcher().executorService().shutdown();
+ }
+ }
+}
\ No newline at end of file
diff --git
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbProperties.java
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbProperties.java
new file mode 100644
index 000000000..6c2a2cca5
--- /dev/null
+++
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbProperties.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.warehouse.store.history.tsdb.questdb;
+
+import org.apache.hertzbeat.common.constants.ConfigConstants;
+import org.apache.hertzbeat.common.constants.SignConstants;
+import org.apache.hertzbeat.warehouse.constants.WarehouseConstants;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.bind.DefaultValue;
+
+/**
+ * QuestDB configuration information
+ */
+
+@ConfigurationProperties(prefix =
ConfigConstants.FunctionModuleConstants.WAREHOUSE
+ + SignConstants.DOT
+ + WarehouseConstants.STORE
+ + SignConstants.DOT
+ + WarehouseConstants.HistoryName.QUEST_DB)
+public record QuestdbProperties(@DefaultValue("false") boolean enabled,
+ String url,
+ String username,
+ String password) {
+}
\ No newline at end of file
diff --git a/home/docs/start/questdb-init.md b/home/docs/start/questdb-init.md
new file mode 100644
index 000000000..440bb8a00
--- /dev/null
+++ b/home/docs/start/questdb-init.md
@@ -0,0 +1,136 @@
+---
+
+id: questdb-init
+
+title: Installation and Initialization of Time-Series Database Service QuestDB
(Optional)
+
+sidebar_label: Metric Data Storage - QuestDB
+
+---
+
+
+
+The historical data storage of Apache HertzBeat™ relies on a time-series
database. You can choose to install and initialize **one** of the supported
databases, or skip the installation (⚠️ However, it is strongly recommended to
configure one in the production environment).
+
+> We recommend using and providing long-term support for VictoriaMetrics as
the storage solution.
+
+QuestDB is an open-source time-series database that stands out in the field of
time-series data processing due to its high performance and low latency. We use
it to store and analyze the collected historical monitoring metric data.
+
+**⚠️ Note: Configuring a time-series database is optional, but it is strongly
recommended for production environments to ensure more comprehensive historical
chart functions, high performance, and stability.**
+
+**⚠️ If no time-series database is configured, only the historical data of the
last hour will be retained.**
+
+> If you already have an existing QuestDB environment, you can skip directly
to the YML configuration step.
+
+### Install QuestDB
+
+1. Download the installation package
+
+ Download the latest version for your operating system from the official
GitHub repository:
+
+```shell
+# For Linux/macOS (taking v7.3.9 as an example; replace with the latest
version number)
+wget
https://github.com/questdb/questdb/releases/download/7.3.9/questdb-7.3.9-no-jre-bin.tar.gz
+
+# Extract the package
+tar -zxvf questdb-7.3.9-no-jre-bin.tar.gz
+mv questdb-7.3.9 /opt/questdb # Move to a common directory
+```
+
+ For Windows users:
+
+ Download the zip package and extract it to C:\questdb or a custom
directory.
+
+2. Start QuestDB
+
+```shell
+# For Linux/macOS: Navigate to the installation directory and start the service
+cd /opt/questdb/bin
+./questdb start
+
+# For Windows (Command Prompt):
+cd C:\questdb\bin
+questdb.exe start
+```
+
+3. Set up access password
+
+ QuestDB enables authentication through a configuration file, which
needs to be modified manually.
+
+ Edit the configuration file:
+
+```shell
+# For Linux/macOS
+vi /opt/questdb/conf/server.conf
+
+# For Windows
+notepad C:\questdb\conf\server.conf
+```
+
+ Enable authentication and configure the password:
+
+```shell
+# Enable authentication (disabled by default)
+http.security.enabled=true
+pg.security.enabled=true # PostgreSQL protocol authentication
+
+# Set admin account and password (customize as needed)
+http.security.admin.username=admin
+http.security.admin.password=YourStrongPassword123!
+
+# Optional: Restrict Web Console access to specific IPs (e.g., local access
only)
+http.bind.to=127.0.0.1:9000
+```
+
+ Restart QuestDB for the changes to take effect:
+
+```shell
+# For Linux/macOS
+./questdb stop
+./questdb start
+
+# For Windows
+questdb.exe stop
+questdb.exe start
+```
+
+4. Configure QuestDB connection in HertzBeat's application.yml file
+
+ Modify HertzBeat's configuration file
+
+ Locate and edit the configuration file at
hertzbeat/config/application.yml
+
+ ⚠️ Note: For Docker container deployment, you need to mount the
application.yml file to the host machine. For the installation package
deployment, simply extract the package and modify the file at
hertzbeat/config/application.yml.
+
+ **Set the** **warehouse.store.jpa.enabled** **parameter to**
**false****, configure the** **warehouse.store.questdb** **data source
parameters (HOST, username, password, etc.), and set** **enabled** **to**
**true** **to enable QuestDB.**
+
+```yaml
+warehouse:
+ store:
+ # Disable the default JPA
+ jpa:
+ enabled: false
+ # Enable QuestDB
+ questdb:
+ enabled: true
+ url: localhost:9000
+ username: admin
+ password: quest
+```
+
+Parameter Description:
+
+| Parameter Name | Description |
+| -------------- | ------------------------------ |
+| enabled | Whether to enable QuestDB |
+| url | QuestDB server URL (host:port) |
+| username | QuestDB database account |
+| password | QuestDB database password |
+
+> **Note:** Due to QuestDB's architectural design, if you need to set an
expiration time for data, you can configure it in QuestDB's configuration file
server.conf:
+
+```shell
+cairo.default.ttl=30d
+```
+
+5. Restart HertzBeat
\ No newline at end of file
diff --git
a/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/start/questdb-init.md
b/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/start/questdb-init.md
new file mode 100644
index 000000000..89948f7e3
--- /dev/null
+++
b/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/start/questdb-init.md
@@ -0,0 +1,132 @@
+---
+id: questdb-init
+title: 依赖时序数据库服务QuestDB安装初始化(可选)
+sidebar_label: 指标数据存储QuestDB
+---
+
+Apache HertzBeat™ 的历史数据存储依赖时序数据库,任选其一安装初始化即可,也可不安装(注意⚠️但强烈建议生产环境配置)
+
+> 我们推荐使用并长期支持 VictoriaMetrics 作为存储。
+
+QuestDB 是一款开源的时序数据库,因其高性能和低延迟等特点,在时序数据处理领域备受关注,我们使用其存储分析采集到的监控指标历史数据。
+
+**注意⚠️ 时序数据库安装配置为可选项,但强烈建议生产环境配置,以提供更完善的历史图表功能,高性能和稳定性**
+
+**⚠️ 若不配置时序数据库,则只会留最近一小时历史数据**
+
+> 如果您已有QuestDB环境,可直接跳到YML配置那一步。
+
+### 安装QuestDB
+
+1. 下载安装包
+
+ 从官方 GitHub 下载对应系统的最新版本:
+
+ ```shell
+ # Linux/macOS(以v7.3.9为例,可替换为最新版本号)
+ wget
https://github.com/questdb/questdb/releases/download/7.3.9/questdb-7.3.9-no-jre-bin.tar.gz
+
+ # 解压
+ tar -zxvf questdb-7.3.9-no-jre-bin.tar.gz
+ mv questdb-7.3.9 /opt/questdb # 移动到常用目录
+ ```
+
+ Windows 用户:
+ 下载 zip 包后解压到`C:\questdb`或自定义目录。
+
+2. 启动 QuestDB
+
+ ```shell
+ # Linux/macOS:进入安装目录,启动服务
+ cd /opt/questdb/bin
+ ./questdb start
+
+ # Windows(命令提示符):
+ cd C:\questdb\bin
+ questdb.exe start
+ ```
+
+3. 设置访问密码
+
+ QuestDB 通过配置文件启用认证,需手动修改配置。
+
+ 编辑配置文件:
+
+ ```shell
+ # Linux/macOS
+ vi /opt/questdb/conf/server.conf
+
+ # Windows
+ notepad C:\questdb\conf\server.conf
+ ```
+
+ 启用认证并配置密码:
+
+ ```shell
+ # 启用认证(默认关闭)
+ http.security.enabled=true
+ pg.security.enabled=true # PostgreSQL协议认证
+
+ # 设置管理员账号密码(自定义)
+ http.security.admin.username=admin
+ http.security.admin.password=YourStrongPassword123!
+
+ # 可选:限制Web控制台访问IP(如只允许本地)
+ http.bind.to=127.0.0.1:9000
+ ```
+
+ 重启后生效:
+
+ ```shell
+ # Linux/macOS
+ ./questdb stop
+ ./questdb start
+
+ # Windows
+ questdb.exe stop
+ questdb.exe start
+ ```
+
+
+
+4. 在hertzbeat的`application.yml`配置文件配置QuestDB数据库连接
+
+ 配置HertzBeat的配置文件
+ 修改位于 `hertzbeat/config/application.yml` 的配置文件
+ 注意⚠️docker容器方式需要将application.yml文件挂载到主机本地,安装包方式解压修改位于
`hertzbeat/config/application.yml` 即可
+
+ **修改里面的`warehouse.store.jpa.enabled`参数为`false`,
配置`warehouse.store.questdb`数据源参数,HOST账户密码等,并启用`enabled`为`true`**
+
+ ```yaml
+ warehouse:
+ store:
+ # 关闭默认JPA
+ jpa:
+ enabled: false
+ # 启用IotDB
+ questdb:
+ enabled: true
+ url: localhost:9000
+ username: admin
+ password: quest
+ ```
+
+
+
+ 参数说明:
+
+ | 参数名称 | 参数说明 |
+ | -------- | ---------------- |
+ | enabled | 是否启用 |
+ | url | QuestDB的URL地址 |
+ | username | QuestDB据库账户 |
+ | password | QuestDB据库密码 |
+
+ > **注意:** 因为 QuestDB 架构设计原因,如果对数据的过期时间有要求可以前往 QuestDB 的配置文件 `server.conf`
中配置:
+ >
+ > ```ini
+ > cairo.default.ttl=30d
+ > ```
+
+5. 重启 HertzBeat
+
diff --git a/material/licenses/LICENSE b/material/licenses/LICENSE
index 216ef39fd..adcff2464 100644
--- a/material/licenses/LICENSE
+++ b/material/licenses/LICENSE
@@ -438,6 +438,7 @@ The text of each license is the standard Apache 2.0 license.
https://mvnrepository.com/artifact/io.opentelemetry.instrumentation/opentelemetry-spring-boot-starter-2.15.0
Apache-2.0
https://mvnrepository.com/artifact/io.opentelemetry.instrumentation/opentelemetry-logback-appender-1.0
Apache-2.0
https://mvnrepository.com/artifact/org.apache.zookeeper/zookeeper/3.9.3
Apache-2.0
+ https://mvnrepository.com/artifact/org.questdb/questdb Apache-2.0
========================================================================
BSD-2-Clause licenses
diff --git a/pom.xml b/pom.xml
index 91620b414..cdd78114b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -142,6 +142,7 @@
<h2.version>2.2.224</h2.version>
<taos-jdbcdriver.version>3.0.0</taos-jdbcdriver.version>
<iotdb-session.version>0.13.3</iotdb-session.version>
+ <questdb.version>9.0.2</questdb.version>
<flyway.version>10.11.1</flyway.version>
<commons-collections4.version>4.4</commons-collections4.version>
<commons-jexl3>3.2.1</commons-jexl3>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]