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 + "&timestamptype=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]


Reply via email to