国产av日韩一区二区三区精品,成人性爱视频在线观看,国产,欧美,日韩,一区,www.成色av久久成人,2222eeee成人天堂

首頁(yè) Java java教程 pg-index-health – PostgreSQL 數(shù)據(jù)庫(kù)的靜態(tài)分析工具

pg-index-health – PostgreSQL 數(shù)據(jù)庫(kù)的靜態(tài)分析工具

Jan 06, 2025 pm 06:20 PM

你好!

自 2019 年以來(lái),我一直在開(kāi)發(fā)一個(gè)名為 pg-index-health 的開(kāi)源工具,它可以分析數(shù)據(jù)庫(kù)結(jié)構(gòu)并識(shí)別潛在問(wèn)題。在我之前的一篇文章中,我分享了這個(gè)工具如何誕生的故事。

多年來(lái),pg-index-health 不斷發(fā)展和改進(jìn)。 2024 年,在幾位貢獻(xiàn)者的支持下,我成功解決了大部分剩余的“成長(zhǎng)的煩惱”,并使該項(xiàng)目達(dá)到了可以大規(guī)模擴(kuò)展的狀態(tài)。

數(shù)據(jù)庫(kù)隨著微服務(wù)的興起而增長(zhǎng)

自 2015 年以來(lái),我一直在使用 PostgreSQL,這段迷人的旅程始于位于雅羅斯拉夫爾的 Tensor 公司。

早在2015年,那還是一個(gè)擁有海量數(shù)據(jù)庫(kù)和大量表的單體時(shí)代。通常,對(duì)此類數(shù)據(jù)庫(kù)結(jié)構(gòu)的任何更改都需要獲得作為關(guān)鍵知識(shí)持有者的架構(gòu)師或開(kāi)發(fā)主管的強(qiáng)制批準(zhǔn)。雖然這可以防止大多數(shù)錯(cuò)誤,但它減慢了更改的過(guò)程并且完全無(wú)法擴(kuò)展。

漸漸地,人們開(kāi)始轉(zhuǎn)向微服務(wù)。
數(shù)據(jù)庫(kù)的數(shù)量顯著增加,但每個(gè)數(shù)據(jù)庫(kù)中的表數(shù)量卻相反減少?,F(xiàn)在,每個(gè)團(tuán)隊(duì)開(kāi)始獨(dú)立管理自己的數(shù)據(jù)庫(kù)結(jié)構(gòu)。集中的專業(yè)知識(shí)來(lái)源消失了,數(shù)據(jù)庫(kù)設(shè)計(jì)錯(cuò)誤開(kāi)始成倍增加并從一項(xiàng)服務(wù)傳播到另一項(xiàng)服務(wù)。

測(cè)試金字塔及其形狀

你們中的大多數(shù)人可能都聽(tīng)說(shuō)過(guò)測(cè)試金字塔。對(duì)于整體而言,它具有相當(dāng)?shù)湫偷男螤詈蛷V泛的單元測(cè)試基礎(chǔ)。欲了解更多詳情,我推薦 Martin Fowler 的文章。

pg-index-health – a static analysis tool for you PostgreSQL database

微服務(wù)不僅改變了開(kāi)發(fā)方法,還改變了測(cè)試金字塔的外觀。這種轉(zhuǎn)變很大程度上是由容器化技術(shù)(Docker、Testcontainers)的興起推動(dòng)的。如今,測(cè)試金字塔根本不再是金字塔。它可以有一個(gè)非常奇怪的形狀。最著名的例子是蜂巢和測(cè)試獎(jiǎng)杯。

pg-index-health – a static analysis tool for you PostgreSQL database

現(xiàn)代趨勢(shì)是編寫盡可能少的單元測(cè)試,重點(diǎn)關(guān)注實(shí)現(xiàn)細(xì)節(jié),并優(yōu)先考慮驗(yàn)證服務(wù)提供的實(shí)際功能的組件和集成測(cè)試。

我個(gè)人最喜歡的是測(cè)試獎(jiǎng)杯。其基礎(chǔ)是靜態(tài)代碼分析,旨在防止常見(jiàn)錯(cuò)誤。

靜態(tài)代碼分析的重要性

Java 和 Kotlin 代碼的靜態(tài)分析現(xiàn)在是常見(jiàn)的做法。對(duì)于 Kotlin 服務(wù),選擇的工具通常是 detekt。對(duì)于 Java 應(yīng)用程序,可用工具(通常稱為 linter)的范圍更廣。主要工具包括CheckstylePMD、SpotBugsError Prone。您可以在我的上一篇文章中閱讀有關(guān)它們的更多信息。

值得注意的是,detektCheckstyle 也可以處理代碼格式化,有效地充當(dāng)格式化程序。

數(shù)據(jù)庫(kù)遷移的靜態(tài)分析

現(xiàn)代微服務(wù)通常包括數(shù)據(jù)庫(kù)遷移,用于創(chuàng)建和更新數(shù)據(jù)庫(kù)結(jié)構(gòu)以及應(yīng)用程序代碼。

在 Java 生態(tài)系統(tǒng)中,管理遷移的主要工具是 LiquibaseFlyway。對(duì)數(shù)據(jù)庫(kù)結(jié)構(gòu)的任何更改都必須始終記錄在遷移中。即使在生產(chǎn)中發(fā)生事件期間手動(dòng)進(jìn)行更改,也必須稍后創(chuàng)建遷移以在所有環(huán)境中應(yīng)用這些更改。

用純 SQL 編寫遷移是最佳實(shí)踐,因?yàn)榕c學(xué)習(xí) Liquibase 等工具的 XML 方言相比,它提供了最大的靈活性并節(jié)省時(shí)間。我在我的文章“在功能測(cè)試中使用 PostgreSQL 的六個(gè)技巧”中談到了這一點(diǎn)。

驗(yàn)證SQL遷移代碼

要驗(yàn)證遷移中的 SQL 代碼,我建議使用 SQLFluff,它本質(zhì)上是 SQL 的 Checkstyle 等效項(xiàng)。此 linter 支持多種數(shù)據(jù)庫(kù)和方言(包括 PostgreSQL),并且可以集成到您的 CI 管道中。它提供了 60 多種可自定義規(guī)則,使您能夠管理表和列別名、SQL 命令大小寫、縮進(jìn)、查詢中的列排序等等。

比較帶格式和不帶格式的查詢:

-- well-formatted SQL
select
    pc.oid::regclass::text as table_name,
    pg_table_size(pc.oid) as table_size
from
    pg_catalog.pg_class pc
    inner join pg_catalog.pg_namespace nsp on nsp.oid = pc.relnamespace
where
    pc.relkind = 'r' and
    pc.oid not in (
        select c.conrelid as table_oid
        from pg_catalog.pg_constraint c
        where c.contype = 'p'
    ) and
    nsp.nspname = :schema_name_param::text
order by table_name;
-- poorly formatted SQL
SELECT pc.oid::regclass::text AS table_name, pg_table_size(pc.oid) AS table_size
FROM pg_catalog.pg_class  pc
JOIN pg_catalog.pg_namespace AS nsp
ON nsp.oid =  pc.relnamespace
WHERE pc.relkind = 'r’
and pc.oid NOT in (
  select c.conrelid as table_oid
  from pg_catalog.pg_constraint   c
  where    c.contype = 'p’
)
and nsp.nspname  = :schema_name_param::text
ORDER BY  table_name;

格式良好的 SQL 代碼更容易閱讀和理解。最重要的是,代碼審查將不再因有關(guān)格式首選項(xiàng)的討論而陷入困境。 SQLFluff 強(qiáng)制執(zhí)行一致的樣式,節(jié)省時(shí)間。

SQLFluff 的實(shí)際應(yīng)用

這就是真實(shí)拉取請(qǐng)求中的樣子:

pg-index-health – a static analysis tool for you PostgreSQL database

這里SQLFluff發(fā)現(xiàn)select語(yǔ)句中返回值格式化有問(wèn)題:當(dāng)只返回一列時(shí),我們不會(huì)將其放在單獨(dú)的行。第二點(diǎn)是選擇結(jié)果中的列順序不正確:首先我們返回簡(jiǎn)單列,然后才返回計(jì)算結(jié)果。第三個(gè)是 join 語(yǔ)句中 的大小寫不正確:我更喜歡用小寫形式編寫所有查詢。

有關(guān)使用 SQLFluff 的更多示例,請(qǐng)查看我的開(kāi)源項(xiàng)目:一、二。

使用元數(shù)據(jù)分析數(shù)據(jù)庫(kù)結(jié)構(gòu)

還可以檢查數(shù)據(jù)庫(kù)本身的結(jié)構(gòu)。然而,處理遷移非常不方便:遷移數(shù)量可能很多;新的遷移可能會(huì)修復(fù)先前遷移中的錯(cuò)誤,等等。通常,我們對(duì)數(shù)據(jù)庫(kù)的最終結(jié)構(gòu)比其中間狀態(tài)更感興趣。

利用信息模式

PostgreSQL(像許多其他關(guān)系數(shù)據(jù)庫(kù)一樣)存儲(chǔ)有關(guān)所有對(duì)象及其之間關(guān)系的元數(shù)據(jù),并以 information_schema 的形式向外部提供。我們可以使用對(duì)information_schema的查詢來(lái)識(shí)別任何偏差、問(wèn)題或常見(jiàn)錯(cuò)誤(這正是SchemaCrawler所做的)。

由于我們僅使用 PostgreSQL,因此我們可以使用系統(tǒng)目錄(pg_catalog 架構(gòu)),而不是 information_schema,它提供有關(guān)特定數(shù)據(jù)庫(kù)內(nèi)部結(jié)構(gòu)的更多信息。

累計(jì)統(tǒng)計(jì)系統(tǒng)

除了元數(shù)據(jù)之外,PostgreSQL還收集每個(gè)數(shù)據(jù)庫(kù)的運(yùn)行信息:執(zhí)行了哪些查詢、如何執(zhí)行、使用了哪些訪問(wèn)方法等。累積統(tǒng)計(jì)系統(tǒng)負(fù)責(zé)收集這個(gè)數(shù)據(jù)。

通過(guò)系統(tǒng)視圖查詢這些統(tǒng)計(jì)數(shù)據(jù)并將其與系統(tǒng)目錄中的數(shù)據(jù)相結(jié)合,我們可以:

  • 識(shí)別未使用的索引;
  • 檢測(cè)缺乏足夠索引的表。

統(tǒng)計(jì)數(shù)據(jù)可以手動(dòng)重置。上次重置的日期和時(shí)間記錄在系統(tǒng)中。考慮這一點(diǎn)對(duì)于了解統(tǒng)計(jì)數(shù)據(jù)是否可信非常重要。例如,如果您有一些業(yè)務(wù)邏輯每月/每季度/每半年執(zhí)行一次,則需要收集至少上述間隔時(shí)間的統(tǒng)計(jì)信息。

如果使用數(shù)據(jù)庫(kù)集群,則統(tǒng)計(jì)信息將在每個(gè)主機(jī)上獨(dú)立收集,并且不會(huì)在集群內(nèi)復(fù)制。

pg-index-health 及其結(jié)構(gòu)

如上所述,基于數(shù)據(jù)庫(kù)本身內(nèi)的元數(shù)據(jù)分析數(shù)據(jù)庫(kù)結(jié)構(gòu)的想法已由我以名為 pg-index-health 的工具的形式實(shí)現(xiàn)。

我的解決方案包括以下組件:

  • 一組 SQL 查詢形式的檢查,放置在單獨(dú)的存儲(chǔ)庫(kù)中(當(dāng)前包含 25 個(gè)檢查)。這些查詢與 Java 代碼庫(kù)解耦,可以在用其他編程語(yǔ)言編寫的項(xiàng)目中重用。
  • 領(lǐng)域模型 - 將檢查結(jié)果表示為對(duì)象的最小類集。
  • HighAvailabilityPgConnection 抽象,用于連接到由多個(gè)主機(jī)組成的數(shù)據(jù)庫(kù)集群。
  • 用于執(zhí)行 SQL 查詢并將結(jié)果序列化為域模型對(duì)象的實(shí)用程序。
  • Spring Boot 啟動(dòng)器,用于方便快速地將檢查集成到單元/組件/集成測(cè)試中。
  • 遷移生成器,可以為已識(shí)別的問(wèn)題創(chuàng)建糾正性 SQL 遷移。

支票類型

所有檢查(也稱為診斷)分為兩組:

  • 運(yùn)行時(shí)檢查(需要統(tǒng)計(jì))。
  • 靜態(tài)檢查(不需要統(tǒng)計(jì))。

運(yùn)行時(shí)檢查

運(yùn)行時(shí)檢查僅在生產(chǎn)中的實(shí)時(shí)數(shù)據(jù)庫(kù)實(shí)例上執(zhí)行時(shí)才有意義。這些檢查需要累積統(tǒng)計(jì)數(shù)據(jù)并聚合來(lái)自集群中所有主機(jī)的數(shù)據(jù)。

讓我們考慮一個(gè)由三個(gè)主機(jī)組成的數(shù)據(jù)庫(kù)集群:主主機(jī)、輔助主機(jī)和異步副本。某些服務(wù)使用具有類似拓?fù)涞募?,并且僅在異步副本上執(zhí)行大量讀取查詢以平衡負(fù)載。此類查詢通常不會(huì)在主主機(jī)上執(zhí)行,因?yàn)樗鼈儠?huì)產(chǎn)生額外的負(fù)載并對(duì)其他查詢的延遲產(chǎn)生負(fù)面影響。

pg-index-health – a static analysis tool for you PostgreSQL database

如前所述,在 PostgreSQL 中,統(tǒng)計(jì)信息是在每個(gè)主機(jī)上單獨(dú)收集的,并且不會(huì)在集群內(nèi)復(fù)制。因此,您很容易遇到僅在異步副本上使用和需要某些索引的情況。為了可靠地確定是否需要索引,需要在集群中的每個(gè)主機(jī)上運(yùn)行檢查并聚合結(jié)果。

靜態(tài)檢查

靜態(tài)檢查不需要累積統(tǒng)計(jì)數(shù)據(jù),可以在應(yīng)用遷移后立即在主主機(jī)上執(zhí)行。當(dāng)然,它們也可以用于生產(chǎn)數(shù)據(jù)庫(kù)來(lái)實(shí)時(shí)獲取數(shù)據(jù)。然而,大多數(shù)檢查都是靜態(tài)的,它們?cè)跍y(cè)試中特別有用,因?yàn)樗鼈冇兄诓东@和防止開(kāi)發(fā)階段的常見(jiàn)錯(cuò)誤。

pg-index-health – a static analysis tool for you PostgreSQL database

如何使用 pg-index-health

pg-index-health 的主要用例是添加測(cè)試來(lái)驗(yàn)證測(cè)試管道中的數(shù)據(jù)庫(kù)結(jié)構(gòu)。

對(duì)于 Spring Boot 應(yīng)用程序,您需要將啟動(dòng)器添加到測(cè)試依賴項(xiàng)中:

-- well-formatted SQL
select
    pc.oid::regclass::text as table_name,
    pg_table_size(pc.oid) as table_size
from
    pg_catalog.pg_class pc
    inner join pg_catalog.pg_namespace nsp on nsp.oid = pc.relnamespace
where
    pc.relkind = 'r' and
    pc.oid not in (
        select c.conrelid as table_oid
        from pg_catalog.pg_constraint c
        where c.contype = 'p'
    ) and
    nsp.nspname = :schema_name_param::text
order by table_name;

然后添加標(biāo)準(zhǔn)測(cè)試

-- poorly formatted SQL
SELECT pc.oid::regclass::text AS table_name, pg_table_size(pc.oid) AS table_size
FROM pg_catalog.pg_class  pc
JOIN pg_catalog.pg_namespace AS nsp
ON nsp.oid =  pc.relnamespace
WHERE pc.relkind = 'r’
and pc.oid NOT in (
  select c.conrelid as table_oid
  from pg_catalog.pg_constraint   c
  where    c.contype = 'p’
)
and nsp.nspname  = :schema_name_param::text
ORDER BY  table_name;

在此測(cè)試中,所有可用的檢查都作為列表注入。然后,只有靜態(tài)檢查會(huì)在部署在應(yīng)用了遷移的容器中的真實(shí)數(shù)據(jù)庫(kù)上過(guò)濾和執(zhí)行。

理想情況下,每次檢查都應(yīng)返回一個(gè)空列表。如果添加下一個(gè)遷移時(shí)有任何偏差,測(cè)試將會(huì)失敗。開(kāi)發(fā)人員將被迫關(guān)注這一點(diǎn)并以任何方式解決問(wèn)題:要么在遷移中修復(fù)它,要么明確忽略它。

誤報(bào)和添加排除

重要的是要了解 pg-index-health 與任何其他靜態(tài)分析器一樣,可能會(huì)產(chǎn)生誤報(bào)。此外,某些檢查可能與您的項(xiàng)目無(wú)關(guān)。例如,記錄數(shù)據(jù)庫(kù)結(jié)構(gòu)被認(rèn)為是很好的做法。 PostgreSQL 允許向幾乎所有數(shù)據(jù)庫(kù)對(duì)象添加注釋。在遷移中,這可能如下所示:

dependencies {
    testImplementation("io.github.mfvanek:pg-index-health-test-starter:0.14.4")
}

在您的團(tuán)隊(duì)中,您可能同意不這樣做。在這種情況下,相應(yīng)檢查的結(jié)果 (TABLES_WITHOUT_DESCRIPTION、COLUMNS_WITHOUT_DESCRIPTION、FUNCTIONS_WITHOUT_DESCRIPTION)對(duì)您來(lái)說(shuō)變得無(wú)關(guān)緊要。

您可以完全排除這些檢查:

import io.github.mfvanek.pg.core.checks.common.DatabaseCheckOnHost;
import io.github.mfvanek.pg.core.checks.common.Diagnostic;
import io.github.mfvanek.pg.model.dbobject.DbObject;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@ActiveProfiles("test")
class DatabaseStructureStaticAnalysisTest {

    @Autowired
    private List<DatabaseCheckOnHost<? extends DbObject>> checks;

    @Test
    void checksShouldWork() {
        assertThat(checks)
            .hasSameSizeAs(Diagnostic.values());

        checks.stream()
            .filter(DatabaseCheckOnHost::isStatic)
            .forEach(c -> assertThat(c.check())
                .as(c.getDiagnostic().name())
                .isEmpty());
    }
}

或者干脆忽略他們的結(jié)果:

create table if not exists demo.warehouse
(
    id bigint primary key generated always as identity,
    name varchar(255) not null
);

comment on table demo.warehouse is 'Information about the warehouses';
comment on column demo.warehouse.id is 'Unique identifier of the warehouse';
comment on column demo.warehouse.name is 'Human readable name of the warehouse';

在引入pg-index-health時(shí),你可能經(jīng)常會(huì)遇到數(shù)據(jù)庫(kù)結(jié)構(gòu)已經(jīng)存在一些偏差的情況,但你又不想立即解決它們。同時(shí),該檢查是相關(guān)的,禁用它不是一個(gè)選項(xiàng)。在這種情況下,最好修復(fù)代碼中的所有偏差

@Test
void checksShouldWork() {
    assertThat(checks)
        .hasSameSizeAs(Diagnostic.values());

    checks.stream()
        .filter(DatabaseCheckOnHost::isStatic)
        .filter(c -> c.getDiagnostic() != Diagnostic.TABLES_WITHOUT_DESCRIPTION &&
            c.getDiagnostic() != Diagnostic.COLUMNS_WITHOUT_DESCRIPTION)
        .forEach(c -> assertThat(c.check())
            .as(c.getDiagnostic().name())
            .isEmpty());
}

現(xiàn)在,我想更詳細(xì)地關(guān)注最常遇到的問(wèn)題。

沒(méi)有主鍵的表

由于 PostgreSQL 中 MVCC 機(jī)制的特殊性,可能會(huì)發(fā)生諸如膨脹之類的情況,即表(或索引)的大小由于大量死元組而快速增長(zhǎng)。例如,由于長(zhǎng)時(shí)間運(yùn)行的事務(wù)或一次性更新大量行,可能會(huì)發(fā)生這種情況。

數(shù)據(jù)庫(kù)內(nèi)的垃圾收集由autovacuum進(jìn)程處理,但它不會(huì)釋放占用的物理磁盤空間。有效減少表物理大小的唯一方法是使用 VACUUM FULL 命令,該命令在操作期間需要獨(dú)占鎖。對(duì)于大桌子,這可能需要幾個(gè)小時(shí),使得完全吸塵對(duì)于大多數(shù)現(xiàn)代服務(wù)來(lái)說(shuō)是不切實(shí)際的。

為了在不停機(jī)的情況下解決表膨脹問(wèn)題,經(jīng)常使用像pg_repack這樣的第三方擴(kuò)展。 pg_repack 的強(qiáng)制要求之一是目標(biāo)表上存在主鍵或其他一些唯一性約束。 TABLES_WITHOUT_PRIMARY_KEY 診斷有助于檢測(cè)沒(méi)有主鍵的表并防止將來(lái)出現(xiàn)維護(hù)問(wèn)題。

下面是一個(gè)沒(méi)有主鍵的表的示例。如果此表中出現(xiàn) bloat,pg_repack 將無(wú)法處理它并返回錯(cuò)誤。

-- well-formatted SQL
select
    pc.oid::regclass::text as table_name,
    pg_table_size(pc.oid) as table_size
from
    pg_catalog.pg_class pc
    inner join pg_catalog.pg_namespace nsp on nsp.oid = pc.relnamespace
where
    pc.relkind = 'r' and
    pc.oid not in (
        select c.conrelid as table_oid
        from pg_catalog.pg_constraint c
        where c.contype = 'p'
    ) and
    nsp.nspname = :schema_name_param::text
order by table_name;

重復(fù)索引

我們的數(shù)據(jù)庫(kù)運(yùn)行在資源有限的主機(jī)上,磁盤空間就是其中之一。使用數(shù)據(jù)庫(kù)即服務(wù)解決方案時(shí),最大數(shù)據(jù)庫(kù)大小通常存在無(wú)法更改的物理限制。

表中的每個(gè)索引都是磁盤上的一個(gè)單獨(dú)的實(shí)體。它占用空間并且需要資源進(jìn)行維護(hù),這會(huì)減慢數(shù)據(jù)插入和更新的速度。我們創(chuàng)建索引是為了加快搜索速度或確保某些值的唯一性。然而,索引使用不當(dāng)可能會(huì)導(dǎo)致它們的總大小超過(guò)表本身有用數(shù)據(jù)的大小。因此,表中的索引數(shù)量應(yīng)該盡可能少,但足以滿足其功能。

我遇到過(guò)很多在遷移中創(chuàng)建不必要索引的情況。例如,主鍵的索引是自動(dòng)創(chuàng)建的。雖然技術(shù)上可以手動(dòng)索引 id 列,但這樣做完全沒(méi)有意義。

-- poorly formatted SQL
SELECT pc.oid::regclass::text AS table_name, pg_table_size(pc.oid) AS table_size
FROM pg_catalog.pg_class  pc
JOIN pg_catalog.pg_namespace AS nsp
ON nsp.oid =  pc.relnamespace
WHERE pc.relkind = 'r’
and pc.oid NOT in (
  select c.conrelid as table_oid
  from pg_catalog.pg_constraint   c
  where    c.contype = 'p’
)
and nsp.nspname  = :schema_name_param::text
ORDER BY  table_name;

獨(dú)特約束也會(huì)出現(xiàn)類似的情況。當(dāng)您使用 unique 關(guān)鍵字標(biāo)記一列(或一組列)時(shí),PostgreSQL 會(huì)自動(dòng)為該列(或一組列)創(chuàng)建唯一索引。無(wú)需手動(dòng)創(chuàng)建額外的索引。如果這樣做,這會(huì)導(dǎo)致重復(fù)的索引。此類冗余索引可以而且應(yīng)該被刪除,DUPLICATED_INDEXES 診斷可以幫助識(shí)別它們。

-- well-formatted SQL
select
    pc.oid::regclass::text as table_name,
    pg_table_size(pc.oid) as table_size
from
    pg_catalog.pg_class pc
    inner join pg_catalog.pg_namespace nsp on nsp.oid = pc.relnamespace
where
    pc.relkind = 'r' and
    pc.oid not in (
        select c.conrelid as table_oid
        from pg_catalog.pg_constraint c
        where c.contype = 'p'
    ) and
    nsp.nspname = :schema_name_param::text
order by table_name;

重疊(相交)索引

大多數(shù)索引都是為單個(gè)列創(chuàng)建的。當(dāng)查詢優(yōu)化開(kāi)始時(shí),可??能會(huì)添加更復(fù)雜的索引,涉及多個(gè)列。這導(dǎo)致了為 A、A B 和 A B C 等列創(chuàng)建索引的情況。本系列中的前兩個(gè)索引通??梢詠G棄,因?yàn)樗鼈兪堑谌齻€(gè)索引的 前綴(我建議觀看此視頻) 。刪除這些冗余索引可以節(jié)省大量磁盤空間,INTERSECTED_INDEXES 診斷旨在檢測(cè)此類情況。

-- poorly formatted SQL
SELECT pc.oid::regclass::text AS table_name, pg_table_size(pc.oid) AS table_size
FROM pg_catalog.pg_class  pc
JOIN pg_catalog.pg_namespace AS nsp
ON nsp.oid =  pc.relnamespace
WHERE pc.relkind = 'r’
and pc.oid NOT in (
  select c.conrelid as table_oid
  from pg_catalog.pg_constraint   c
  where    c.contype = 'p’
)
and nsp.nspname  = :schema_name_param::text
ORDER BY  table_name;

沒(méi)有索引的外鍵

PostgreSQL 允許創(chuàng)建外鍵約束而不指定支持索引,這意味著引用另一個(gè)表不需要也不會(huì)自動(dòng)創(chuàng)建索引。在某些情況下,這可能不是問(wèn)題,并且可能根本不會(huì)顯現(xiàn)出來(lái)。然而,有時(shí)它可能會(huì)導(dǎo)致生產(chǎn)中發(fā)生事故。

讓我們看一個(gè)小例子(我使用的是 PostgreSQL 16.6):

dependencies {
    testImplementation("io.github.mfvanek:pg-index-health-test-starter:0.14.4")
}

我們有一個(gè) orders 表和一個(gè) order_item 表。它們通過(guò) order_id 列上的外鍵鏈接。外鍵應(yīng)始終引用主鍵或某些唯一約束,這在我們的示例中得到滿足。

讓我們用數(shù)據(jù)填充表格并收集統(tǒng)計(jì)數(shù)據(jù)。我們將添加 100,000 個(gè)訂單,其中一半有兩件商品,另一半有一件商品。

import io.github.mfvanek.pg.core.checks.common.DatabaseCheckOnHost;
import io.github.mfvanek.pg.core.checks.common.Diagnostic;
import io.github.mfvanek.pg.model.dbobject.DbObject;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@ActiveProfiles("test")
class DatabaseStructureStaticAnalysisTest {

    @Autowired
    private List<DatabaseCheckOnHost<? extends DbObject>> checks;

    @Test
    void checksShouldWork() {
        assertThat(checks)
            .hasSameSizeAs(Diagnostic.values());

        checks.stream()
            .filter(DatabaseCheckOnHost::isStatic)
            .forEach(c -> assertThat(c.check())
                .as(c.getDiagnostic().name())
                .isEmpty());
    }
}

如果我們嘗試檢索 ID=100 的訂單的商品,我們應(yīng)該成功返回 2 行。由于訂單表中的 id 列上有索引,因此該查詢似乎應(yīng)該很快。

create table if not exists demo.warehouse
(
    id bigint primary key generated always as identity,
    name varchar(255) not null
);

comment on table demo.warehouse is 'Information about the warehouses';
comment on column demo.warehouse.id is 'Unique identifier of the warehouse';
comment on column demo.warehouse.name is 'Human readable name of the warehouse';

但是,如果我們嘗試分析此查詢,我們將在執(zhí)行計(jì)劃中看到對(duì)表的順序掃描。我們還應(yīng)該關(guān)注需要讀取的大量頁(yè)面(Buffers 參數(shù))。

@Test
void checksShouldWork() {
    assertThat(checks)
        .hasSameSizeAs(Diagnostic.values());

    checks.stream()
        .filter(DatabaseCheckOnHost::isStatic)
        .filter(c -> c.getDiagnostic() != Diagnostic.TABLES_WITHOUT_DESCRIPTION &&
            c.getDiagnostic() != Diagnostic.COLUMNS_WITHOUT_DESCRIPTION)
        .forEach(c -> assertThat(c.check())
            .as(c.getDiagnostic().name())
            .isEmpty());
}
@Test
void checksShouldWork() {
    assertThat(checks)
        .hasSameSizeAs(Diagnostic.values());

    checks.stream()
        .filter(DatabaseCheckOnHost::isStatic)
        .forEach(c -> {
            final ListAssert<? extends DbObject> listAssert = assertThat(c.check())
                .as(c.getDiagnostic().name());
            switch (c.getDiagnostic()) {
                case TABLES_WITHOUT_DESCRIPTION, COLUMNS_WITHOUT_DESCRIPTION -> listAssert.hasSizeGreaterThanOrEqualTo(0); // ignored

                default -> listAssert.isEmpty();
            }
        });
}

如果我們?yōu)閹в型怄I的列創(chuàng)建索引,情況就會(huì)恢復(fù)正常:

@Test
void checksShouldWorkForAdditionalSchema() {
    final PgContext ctx = PgContext.of("additional_schema");
    checks.stream()
        .filter(DatabaseCheckOnHost::isStatic)
        .forEach(c -> {
            final ListAssert<? extends DbObject> listAssert = assertThat(c.check(ctx))
                .as(c.getDiagnostic().name());

            switch (c.getDiagnostic()) {
                case TABLES_WITHOUT_DESCRIPTION, TABLES_NOT_LINKED_TO_OTHERS ->
                    listAssert.hasSize(1)
                        .asInstanceOf(list(Table.class))
                        .containsExactly(
                            Table.of(ctx, "additional_table")
                        );

                default -> listAssert.isEmpty();
            }
        });
}

順序掃描將從查詢計(jì)劃中消失,讀取的頁(yè)數(shù)將顯著減少:

create table if not exists demo.payment
(
    id bigint not null, -- column is not marked as primary key
    order_id bigint references demo.orders (id),
    status int not null,
    created_at timestamp not null,
    payment_total decimal(22, 2) not null
);

FOREIGN_KEYS_WITHOUT_INDEX 診斷將使您能夠在開(kāi)發(fā)過(guò)程中及早發(fā)現(xiàn)此類情況,從而防止出現(xiàn)性能問(wèn)題。

我是否應(yīng)該創(chuàng)建索引?

記住誤報(bào)問(wèn)題很重要:并非所有外鍵列都需要索引。嘗試估算生產(chǎn)中大概的工作臺(tái)尺寸;檢查您的代碼以在外鍵列上進(jìn)行過(guò)濾、搜索或連接。如果您 100% 確定不需要該索引,則只需將其添加到排除項(xiàng)即可。如果您不確定,最好創(chuàng)建索引(以后隨時(shí)可以將其刪除)。

我經(jīng)常遇到由于外鍵上沒(méi)有索引而導(dǎo)致數(shù)據(jù)庫(kù)“變慢”的事件,但我還沒(méi)有看到任何由于存在此類索引而導(dǎo)致數(shù)據(jù)庫(kù)“變慢”的事件。因此,我不同意 Percona 博客文章中提出的觀點(diǎn),即從一開(kāi)始就不應(yīng)該創(chuàng)建外鍵索引。這是一種DBA方法。您的團(tuán)隊(duì)中有專門的DBA嗎?

索引中的空值

默認(rèn)情況下,PostgreSQL 在 btree 索引中包含 空值,但通常不需要它們。所有空值都是唯一的,您不能簡(jiǎn)單地檢索列值為空的記錄。大多數(shù)時(shí)候,最好通過(guò)在 nullable 列上創(chuàng)建部分索引來(lái)從索引中排除空值,例如 where ;不為空。診斷INDEXES_WITH_NULL_VALUES有助于檢測(cè)此類情況。

讓我們考慮一個(gè) ordersorder_items 的示例。 order_item 表有一個(gè) nullablewarehouse_id,代表倉(cāng)庫(kù) ID。

-- well-formatted SQL
select
    pc.oid::regclass::text as table_name,
    pg_table_size(pc.oid) as table_size
from
    pg_catalog.pg_class pc
    inner join pg_catalog.pg_namespace nsp on nsp.oid = pc.relnamespace
where
    pc.relkind = 'r' and
    pc.oid not in (
        select c.conrelid as table_oid
        from pg_catalog.pg_constraint c
        where c.contype = 'p'
    ) and
    nsp.nspname = :schema_name_param::text
order by table_name;

假設(shè)我們有幾個(gè)倉(cāng)庫(kù)。訂單付款后,我們開(kāi)始組裝。我們將更新部分訂單的狀態(tài)并將其標(biāo)記為已付款。

-- poorly formatted SQL
SELECT pc.oid::regclass::text AS table_name, pg_table_size(pc.oid) AS table_size
FROM pg_catalog.pg_class  pc
JOIN pg_catalog.pg_namespace AS nsp
ON nsp.oid =  pc.relnamespace
WHERE pc.relkind = 'r’
and pc.oid NOT in (
  select c.conrelid as table_oid
  from pg_catalog.pg_constraint   c
  where    c.contype = 'p’
)
and nsp.nspname  = :schema_name_param::text
ORDER BY  table_name;

訂單中的單個(gè)商品可能會(huì)根據(jù)內(nèi)部算法從不同倉(cāng)庫(kù)發(fā)貨,考慮物流、庫(kù)存、倉(cāng)庫(kù)負(fù)載等。分配倉(cāng)庫(kù)并更新庫(kù)存后,我們更新warehouse_id 訂單中每個(gè)商品的字段(最初為空)。

dependencies {
    testImplementation("io.github.mfvanek:pg-index-health-test-starter:0.14.4")
}

我們需要通過(guò)特定的倉(cāng)庫(kù) ID 進(jìn)行搜索,以了解哪些物品需要完成并發(fā)貨。我們只接受特定時(shí)間范圍內(nèi)的付費(fèi)訂單。

import io.github.mfvanek.pg.core.checks.common.DatabaseCheckOnHost;
import io.github.mfvanek.pg.core.checks.common.Diagnostic;
import io.github.mfvanek.pg.model.dbobject.DbObject;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@ActiveProfiles("test")
class DatabaseStructureStaticAnalysisTest {

    @Autowired
    private List<DatabaseCheckOnHost<? extends DbObject>> checks;

    @Test
    void checksShouldWork() {
        assertThat(checks)
            .hasSameSizeAs(Diagnostic.values());

        checks.stream()
            .filter(DatabaseCheckOnHost::isStatic)
            .forEach(c -> assertThat(c.check())
                .as(c.getDiagnostic().name())
                .isEmpty());
    }
}

第一個(gè)解決方案可能是 warehouse_id 列上的常規(guī)索引:

-- well-formatted SQL
select
    pc.oid::regclass::text as table_name,
    pg_table_size(pc.oid) as table_size
from
    pg_catalog.pg_class pc
    inner join pg_catalog.pg_namespace nsp on nsp.oid = pc.relnamespace
where
    pc.relkind = 'r' and
    pc.oid not in (
        select c.conrelid as table_oid
        from pg_catalog.pg_constraint c
        where c.contype = 'p'
    ) and
    nsp.nspname = :schema_name_param::text
order by table_name;

如果我們創(chuàng)建這樣的索引,那么在搜索特定倉(cāng)庫(kù)的項(xiàng)目時(shí)將不會(huì)出現(xiàn)問(wèn)題??雌饋?lái)這個(gè)索引應(yīng)該允許有效地查找尚未分配倉(cāng)庫(kù)的所有項(xiàng)目,過(guò)濾條件為warehouse_id為null的記錄。

-- poorly formatted SQL
SELECT pc.oid::regclass::text AS table_name, pg_table_size(pc.oid) AS table_size
FROM pg_catalog.pg_class  pc
JOIN pg_catalog.pg_namespace AS nsp
ON nsp.oid =  pc.relnamespace
WHERE pc.relkind = 'r’
and pc.oid NOT in (
  select c.conrelid as table_oid
  from pg_catalog.pg_constraint   c
  where    c.contype = 'p’
)
and nsp.nspname  = :schema_name_param::text
ORDER BY  table_name;

但是,如果我們查看查詢執(zhí)行計(jì)劃,我們將看到那里的順序訪問(wèn) - 未使用索引。

dependencies {
    testImplementation("io.github.mfvanek:pg-index-health-test-starter:0.14.4")
}

當(dāng)然,這與測(cè)試數(shù)據(jù)庫(kù)中數(shù)據(jù)的具體分布有關(guān)。 warehouse_id 列的基數(shù)較低,這意味著其中唯一值的數(shù)量較少。該列上的索引選擇性較低。索引選擇性是指不同索引值的數(shù)量(即基數(shù))與表中總行數(shù)的比率distinct / count()。例如,唯一索引的選擇性為一。

我們可以通過(guò)刪除空值并在 warehouse_id 列上創(chuàng)建部分索引來(lái)提高索引的選擇性。

import io.github.mfvanek.pg.core.checks.common.DatabaseCheckOnHost;
import io.github.mfvanek.pg.core.checks.common.Diagnostic;
import io.github.mfvanek.pg.model.dbobject.DbObject;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@ActiveProfiles("test")
class DatabaseStructureStaticAnalysisTest {

    @Autowired
    private List<DatabaseCheckOnHost<? extends DbObject>> checks;

    @Test
    void checksShouldWork() {
        assertThat(checks)
            .hasSameSizeAs(Diagnostic.values());

        checks.stream()
            .filter(DatabaseCheckOnHost::isStatic)
            .forEach(c -> assertThat(c.check())
                .as(c.getDiagnostic().name())
                .isEmpty());
    }
}

我們將立即在查詢計(jì)劃中看到該索引:

create table if not exists demo.warehouse
(
    id bigint primary key generated always as identity,
    name varchar(255) not null
);

comment on table demo.warehouse is 'Information about the warehouses';
comment on column demo.warehouse.id is 'Unique identifier of the warehouse';
comment on column demo.warehouse.name is 'Human readable name of the warehouse';

如果我們比較索引的大小,我們會(huì)看到顯著的差異。部分索引要小得多,更新頻率也較低。使用此索引,我們可以節(jié)省磁盤空間并提高性能。

查詢獲取索引的大小
@Test
void checksShouldWork() {
    assertThat(checks)
        .hasSameSizeAs(Diagnostic.values());

    checks.stream()
        .filter(DatabaseCheckOnHost::isStatic)
        .filter(c -> c.getDiagnostic() != Diagnostic.TABLES_WITHOUT_DESCRIPTION &&
            c.getDiagnostic() != Diagnostic.COLUMNS_WITHOUT_DESCRIPTION)
        .forEach(c -> assertThat(c.check())
            .as(c.getDiagnostic().name())
            .isEmpty());
}

table_name index_name index_size_bytes
demo.order_item demo.idx_order_item_warehouse_id 1056768
demo.order_item demo.idx_order_item_warehouse_id_without_nulls 16384

未來(lái)計(jì)劃

這些遠(yuǎn)不是 pg-index-health 可以檢測(cè)到的所有問(wèn)題。完整的診斷列表可在 GitHub 上的項(xiàng)目自述文件中找到,并且會(huì)定期擴(kuò)展。

pg-index-health 集成到 Spring Boot 應(yīng)用程序中非常簡(jiǎn)單。運(yùn)行檢查的開(kāi)銷很小。因此,您將免受常見(jiàn)錯(cuò)誤和問(wèn)題的影響。我鼓勵(lì)您嘗試實(shí)施它!

在不久的將來(lái),我計(jì)劃在所有檢查中添加對(duì)分區(qū)表的全面支持。目前,僅對(duì) 25 項(xiàng)檢查中的 11 項(xiàng)實(shí)施了此措施。我還想擴(kuò)大檢查數(shù)量:已經(jīng)有實(shí)施至少 5 項(xiàng)新檢查的票證。此外,我計(jì)劃在 2025 年切換到 Java 17 和 Spring Boot 3。

存儲(chǔ)庫(kù)鏈接

  • pg-index-health
  • 用于檢查的原始 SQL 查詢
  • 演示應(yīng)用程序

附加材料

  • 我的俄語(yǔ)原始帖子
  • 類似的解決方案 - SchemaCrawler
  • DBA:尋找無(wú)用的索引(俄語(yǔ))
  • Java 開(kāi)發(fā)人員眼中的 PostgreSQL 索引健康狀況(俄語(yǔ))
  • 數(shù)據(jù)庫(kù)結(jié)構(gòu)的靜態(tài)分析(俄語(yǔ))

以上是pg-index-health – PostgreSQL 數(shù)據(jù)庫(kù)的靜態(tài)分析工具的詳細(xì)內(nèi)容。更多信息請(qǐng)關(guān)注PHP中文網(wǎng)其他相關(guān)文章!

本站聲明
本文內(nèi)容由網(wǎng)友自發(fā)貢獻(xiàn),版權(quán)歸原作者所有,本站不承擔(dān)相應(yīng)法律責(zé)任。如您發(fā)現(xiàn)有涉嫌抄襲侵權(quán)的內(nèi)容,請(qǐng)聯(lián)系admin@php.cn

熱AI工具

Undress AI Tool

Undress AI Tool

免費(fèi)脫衣服圖片

Undresser.AI Undress

Undresser.AI Undress

人工智能驅(qū)動(dòng)的應(yīng)用程序,用于創(chuàng)建逼真的裸體照片

AI Clothes Remover

AI Clothes Remover

用于從照片中去除衣服的在線人工智能工具。

Clothoff.io

Clothoff.io

AI脫衣機(jī)

Video Face Swap

Video Face Swap

使用我們完全免費(fèi)的人工智能換臉工具輕松在任何視頻中換臉!

熱工具

記事本++7.3.1

記事本++7.3.1

好用且免費(fèi)的代碼編輯器

SublimeText3漢化版

SublimeText3漢化版

中文版,非常好用

禪工作室 13.0.1

禪工作室 13.0.1

功能強(qiáng)大的PHP集成開(kāi)發(fā)環(huán)境

Dreamweaver CS6

Dreamweaver CS6

視覺(jué)化網(wǎng)頁(yè)開(kāi)發(fā)工具

SublimeText3 Mac版

SublimeText3 Mac版

神級(jí)代碼編輯軟件(SublimeText3)

hashmap和hashtable之間的區(qū)別? hashmap和hashtable之間的區(qū)別? Jun 24, 2025 pm 09:41 PM

HashMap與Hashtable的區(qū)別主要體現(xiàn)在線程安全、null值支持及性能方面。1.線程安全方面,Hashtable是線程安全的,其方法大多為同步方法,而HashMap不做同步處理,非線程安全;2.null值支持上,HashMap允許一個(gè)null鍵和多個(gè)null值,Hashtable則不允許null鍵或值,否則拋出NullPointerException;3.性能方面,HashMap因無(wú)同步機(jī)制效率更高,Hashtable因每次操作加鎖性能較低,推薦使用ConcurrentHashMap替

為什么我們需要包裝紙課? 為什么我們需要包裝紙課? Jun 28, 2025 am 01:01 AM

Java使用包裝類是因?yàn)榛緮?shù)據(jù)類型無(wú)法直接參與面向?qū)ο蟛僮?,而?shí)際需求中常需對(duì)象形式;1.集合類只能存儲(chǔ)對(duì)象,如List利用自動(dòng)裝箱存儲(chǔ)數(shù)值;2.泛型不支持基本類型,必須使用包裝類作為類型參數(shù);3.包裝類可表示null值,用于區(qū)分未設(shè)置或缺失的數(shù)據(jù);4.包裝類提供字符串轉(zhuǎn)換等實(shí)用方法,便于數(shù)據(jù)解析與處理,因此在需要這些特性的場(chǎng)景下,包裝類不可或缺。

什么是接口中的靜態(tài)方法? 什么是接口中的靜態(tài)方法? Jun 24, 2025 pm 10:57 PM

StaticmethodsininterfaceswereintroducedinJava8toallowutilityfunctionswithintheinterfaceitself.BeforeJava8,suchfunctionsrequiredseparatehelperclasses,leadingtodisorganizedcode.Now,staticmethodsprovidethreekeybenefits:1)theyenableutilitymethodsdirectly

JIT編譯器如何優(yōu)化代碼? JIT編譯器如何優(yōu)化代碼? Jun 24, 2025 pm 10:45 PM

JIT編譯器通過(guò)方法內(nèi)聯(lián)、熱點(diǎn)檢測(cè)與編譯、類型推測(cè)與去虛擬化、冗余操作消除四種方式優(yōu)化代碼。1.方法內(nèi)聯(lián)減少調(diào)用開(kāi)銷,將頻繁調(diào)用的小方法直接插入調(diào)用處;2.熱點(diǎn)檢測(cè)識(shí)別高頻執(zhí)行代碼并集中優(yōu)化,節(jié)省資源;3.類型推測(cè)收集運(yùn)行時(shí)類型信息實(shí)現(xiàn)去虛擬化調(diào)用,提升效率;4.冗余操作消除根據(jù)運(yùn)行數(shù)據(jù)刪除無(wú)用計(jì)算和檢查,增強(qiáng)性能。

什么是實(shí)例初始器塊? 什么是實(shí)例初始器塊? Jun 25, 2025 pm 12:21 PM

實(shí)例初始化塊在Java中用于在創(chuàng)建對(duì)象時(shí)運(yùn)行初始化邏輯,其執(zhí)行先于構(gòu)造函數(shù)。它適用于多個(gè)構(gòu)造函數(shù)共享初始化代碼、復(fù)雜字段初始化或匿名類初始化場(chǎng)景,與靜態(tài)初始化塊不同的是它每次實(shí)例化時(shí)都會(huì)執(zhí)行,而靜態(tài)初始化塊僅在類加載時(shí)運(yùn)行一次。

什么是工廠模式? 什么是工廠模式? Jun 24, 2025 pm 11:29 PM

工廠模式用于封裝對(duì)象創(chuàng)建邏輯,使代碼更靈活、易維護(hù)、松耦合。其核心答案是:通過(guò)集中管理對(duì)象創(chuàng)建邏輯,隱藏實(shí)現(xiàn)細(xì)節(jié),支持多種相關(guān)對(duì)象的創(chuàng)建。具體描述如下:工廠模式將對(duì)象創(chuàng)建交給專門的工廠類或方法處理,避免直接使用newClass();適用于多類型相關(guān)對(duì)象創(chuàng)建、創(chuàng)建邏輯可能變化、需隱藏實(shí)現(xiàn)細(xì)節(jié)的場(chǎng)景;例如支付處理器中通過(guò)工廠統(tǒng)一創(chuàng)建Stripe、PayPal等實(shí)例;其實(shí)現(xiàn)包括工廠類根據(jù)輸入?yún)?shù)決定返回的對(duì)象,所有對(duì)象實(shí)現(xiàn)共同接口;常見(jiàn)變體有簡(jiǎn)單工廠、工廠方法和抽象工廠,分別適用于不同復(fù)雜度的需求。

變量的最終關(guān)鍵字是什么? 變量的最終關(guān)鍵字是什么? Jun 24, 2025 pm 07:29 PM

InJava,thefinalkeywordpreventsavariable’svaluefrombeingchangedafterassignment,butitsbehaviordiffersforprimitivesandobjectreferences.Forprimitivevariables,finalmakesthevalueconstant,asinfinalintMAX_SPEED=100;wherereassignmentcausesanerror.Forobjectref

什么是類型鑄造? 什么是類型鑄造? Jun 24, 2025 pm 11:09 PM

類型轉(zhuǎn)換有兩種:隱式和顯式。1.隱式轉(zhuǎn)換自動(dòng)發(fā)生,如將int轉(zhuǎn)為double;2.顯式轉(zhuǎn)換需手動(dòng)操作,如使用(int)myDouble。需要類型轉(zhuǎn)換的情況包括處理用戶輸入、數(shù)學(xué)運(yùn)算或函數(shù)間傳遞不同類型的值時(shí)。需要注意的問(wèn)題有:浮點(diǎn)數(shù)轉(zhuǎn)整數(shù)會(huì)截?cái)嘈?shù)部分、大類型轉(zhuǎn)小類型可能導(dǎo)致數(shù)據(jù)丟失、某些語(yǔ)言不允許直接轉(zhuǎn)換特定類型。正確理解語(yǔ)言的轉(zhuǎn)換規(guī)則有助于避免錯(cuò)誤。

See all articles