KingbaseES数据库设计规范与SQL开发最佳实践

前言

做过后端开发的人大概都有过这样的经历------项目刚上线时一切正常,跑了几个月之后,某个页面突然慢得离谱,一查才发现是一张表涨到了几千万行,索引建得乱七八糟,SQL写得像天书。这种"技术债"越积越多,到最后只能推倒重来。

其实,大部分数据库性能问题的根源,都可以追溯到设计阶段。表结构没规划好、索引策略一团浆糊、SQL随手一写就上线------这些看似"省时间"的做法,最后都会加倍还回来。

这篇文章就是想跟你聊聊,在KingbaseES上做数据库设计和SQL开发时,有哪些坑一定要绕开,有哪些做法能让系统跑得更稳、更快。内容覆盖了从建表到写SQL再到写存储过程的完整链路,每一条都是踩过坑之后总结出来的。

文章目录

  • 前言
    • 一、先说大局:数据库设计的几条铁律
      • [1.1 字符集统一,模式要隔离](#1.1 字符集统一,模式要隔离)
      • [1.2 建表的几条红线](#1.2 建表的几条红线)
      • [1.3 给对象起个好名字](#1.3 给对象起个好名字)
    • 二、把表设计好,后面的事事半功倍
      • [2.1 字段类型怎么选](#2.1 字段类型怎么选)
      • [2.2 主键怎么定](#2.2 主键怎么定)
      • [2.3 外键到底该不该加](#2.3 外键到底该不该加)
      • [2.4 大字段(LOB)怎么处理](#2.4 大字段(LOB)怎么处理)
      • [2.5 临时表怎么选](#2.5 临时表怎么选)
      • [2.6 fillfactor:给高频更新的表留点余地](#2.6 fillfactor:给高频更新的表留点余地)
    • 三、索引:好钢用在刀刃上
      • [3.1 先说几条硬限制](#3.1 先说几条硬限制)
      • [3.2 六种索引类型,各有各的舞台](#3.2 六种索引类型,各有各的舞台)
      • [3.3 复合索引:顺序很重要](#3.3 复合索引:顺序很重要)
      • [3.4 条件索引和覆盖索引:两个进阶技巧](#3.4 条件索引和覆盖索引:两个进阶技巧)
    • 四、分区表:大表的终极解法
      • [4.1 分区前要想清楚的几件事](#4.1 分区前要想清楚的几件事)
      • [4.2 三种分区方式怎么选](#4.2 三种分区方式怎么选)
      • [4.3 分区索引的几个讲究](#4.3 分区索引的几个讲究)
    • 五、写好SQL,性能差距是数量级的
      • [5.1 查询语句:这些坑别踩](#5.1 查询语句:这些坑别踩)
      • [5.2 小心条件表达式里的陷阱](#5.2 小心条件表达式里的陷阱)
      • [5.3 排序和分组怎么做更高效](#5.3 排序和分组怎么做更高效)
      • [5.4 DML操作:写得对和写得快是两回事](#5.4 DML操作:写得对和写得快是两回事)
      • [5.5 事务和并发:别让锁成了绊脚石](#5.5 事务和并发:别让锁成了绊脚石)
    • 六、PL/SQL开发:写出健壮的程序
      • [6.1 变量声明:三种方式怎么选](#6.1 变量声明:三种方式怎么选)
      • [6.2 游标:别再用COUNT(*)判断有没有数据了](#6.2 游标:别再用COUNT(*)判断有没有数据了)
      • [6.3 批量操作:FORALL和BULK COLLECT](#6.3 批量操作:FORALL和BULK COLLECT)
      • [6.4 异常处理:每个程序都该有的安全网](#6.4 异常处理:每个程序都该有的安全网)
      • [6.5 动态SQL:灵活但要注意安全](#6.5 动态SQL:灵活但要注意安全)
      • [6.6 函数的稳定态:选错了会影响查询优化](#6.6 函数的稳定态:选错了会影响查询优化)
    • 七、系统层面的几件大事
      • [7.1 你的系统是OLTP还是OLAP?](#7.1 你的系统是OLTP还是OLAP?)
      • [7.2 连接池:用静态的,别用动态的](#7.2 连接池:用静态的,别用动态的)
      • [7.3 安全:别当耳旁风](#7.3 安全:别当耳旁风)
    • 八、最后聊几句

一、先说大局:数据库设计的几条铁律

在动手建表之前,有几件事必须先想清楚。这些原则看起来简单,但真到了项目里,能坚持做到的团队并不多。

1.1 字符集统一,模式要隔离

整个数据库实例一定要用统一的字符集,别嫌我啰嗦------不统一的话,后面跨库查询、数据迁移的时候,乱码和隐式转换问题能让你怀疑人生。KingbaseES默认的字符集在绝大多数场景下都够用,关键是不要混着来。

另一个容易忽略的问题是模式(SCHEMA)的使用。很多项目图省事,所有表都堆在PUBLIC模式下面,时间一长,不同业务的表混在一起,命名冲突、权限混乱接踵而至。正确的做法是每个业务模块使用自己的模式:

sql 复制代码
-- 给不同业务线各开一个模式
CREATE SCHEMA order_system AUTHORIZATION app_admin;
CREATE SCHEMA user_system  AUTHORIZATION app_admin;
CREATE SCHEMA log_system   AUTHORIZATION app_admin;

-- 顺手把public模式下的建表权限收回
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

这样一来,订单相关的表放order_system,用户相关的表放user_system,互不干扰,权限也好管。

1.2 建表的几条红线

下面这几条不是说"建议遵守",而是"违反了迟早要出事":

  • 表必须要有主键。没有主键的表就像没有身份证的人------数据同步出问题你都不知道该找谁。更别说做数据复制和问题排查了,那叫一个痛苦。
  • OLTP系统单表别超过80列。你可能觉得80列很多了,但真有些系统一张表一两百个字段,SELECT一下整行数据好几KB,内存和I/O都被白白浪费了。
  • 关联字段的类型必须一致 。比如orders.customer_id是INTEGER,那customers.cust_id也必须是INTEGER,不能一个是INTEGER另一个是VARCHAR。类型不一致,JOIN的时候数据库会悄悄做隐式转换------然后你的索引就白建了。
  • 单表超过5000万条或者超过100GB,就该考虑分区或归档了。这不是硬性规定,而是一个经验阈值,超过这个量级之后,查询和维护的成本会急剧上升。
  • 触发器能不用就不用。触发器看起来很方便------改了数据自动触发一段逻辑,但它会让系统的行为变得难以追踪,出了问题排查起来非常头疼。

1.3 给对象起个好名字

命名规范这事儿,说大不大说小不小,但在团队协作中,统一的命名能让所有人都省心。下面这套前缀规则经过大量项目验证,拿去就能用:

对象类型 前缀 命名示例
TB_ TB_SHOP_ORDER
视图 V_ V_SHOP_UNION
序列 SEQ_ SEQ_SHOP_ORDERID
非唯一索引 IDX_ IDX_SHOP_ORDER_ORDERID
唯一索引 UID_ UID_SHOP_ORDER_ORDERID
主键 PK_ PK_SHOP_ORDER_ORDERID
存储过程 P_ P_SHOP_ACTION
函数 FUNC_ FUNC_SHOP_TOTAL
PKG_ PKG_SHOP_TOTAL
触发器 TRI/TRU/TRD TRI_SHOP_ORDER_LOGIN
临时对象 TMP_ TMP_ORDER_20240510_JOHN
备份对象 BAK_ `BAK_ORDER_20240510_JOHN

几个注意事项:

  • 用有意义的英文单词,别用只有自己懂的缩写。
  • 不要用双引号加大写混合的方式命名(比如"MyTable"),否则以后每次访问都得这么写,烦不胜烦。
  • 别用数据库保留字当名字,比如别把表名叫select或者order
  • 名字别超过30个字符,长了看着也累。

二、把表设计好,后面的事事半功倍

表是数据库里最核心的东西,表设计得好不好,直接决定了整个系统的上限。

2.1 字段类型怎么选

选数据类型这件事,说起来简单,做起来很多人会犯糊涂。核心原则就一条:让类型匹配数据的实际含义

举个最简单的例子------出生日期。有的开发者图省事,用VARCHAR来存日期,觉得"反正也能用嘛"。但问题在于:你用VARCHAR存日期,怎么比较大小?怎么算年龄?怎么做范围查询?每一步都得做类型转换,不仅慢,还容易出错。

再就是"够用就好"原则。能用1字节存下的值,就不要占4字节。听起来省不了多少?别忘了,一张表几千万行,每行多浪费3个字节,加起来就是上百MB的无谓开销。

sql 复制代码
-- ✅ 用心设计过的表:类型精准、约束到位
CREATE TABLE TB_HR_EMPLOYEE (
    emp_id      INTEGER         CONSTRAINT PK_HR_EMPLOYEE_EMPID PRIMARY KEY,
    emp_name    VARCHAR(50)     NOT NULL,
    gender      CHAR(1)         CHECK (gender IN ('M', 'F')),
    birth_date  DATE            NOT NULL,
    salary      DECIMAL(10,2)   NOT NULL,
    dept_id     INTEGER         NOT NULL,
    create_time TIMESTAMP       DEFAULT CURRENT_TIMESTAMP
);

-- ❌ 随手建的表:全靠字符串打天下
CREATE TABLE employee (
    emp_id      VARCHAR(20),    -- 数字型ID用VARCHAR?
    emp_name    VARCHAR(50),
    gender      VARCHAR(10),    -- '男'/'女'还是'M'/'F'?直接用CHAR(1)不好吗
    birth_date  VARCHAR(20),    -- 用字符串存日期,后面对标"苦"的是自己
    salary      VARCHAR(20),    -- 工资用字符串?排序、求和全是坑
    dept_id     VARCHAR(20)     -- 关联字段类型不一致,JOIN必出问题
);

关于变长和定长的选择也很简单:长度不固定的用VARCHAR,长度固定的用CHAR 。比如性别字段永远只有1个字符,用CHAR(1)就好;姓名长短不一,那就用VARCHAR(50)。别用CHAR存变长数据,空格补齐那一套既浪费空间又影响比较效率。

2.2 主键怎么定

主键的选择有几个常见的纠结点:

要不要用有业务含义的列做主键? 比如用身份证号、手机号。通常不太建议,因为业务规则是会变的------手机号可以换、身份证号有升位。一旦主键变了,所有关联这个主键的子表都得跟着改,那画面太美不敢想。用Sequence生成的自增ID做主键是最稳妥的选择。

是不是每张表都非得有主键? 有一个例外场景------追求极致写入性能的大流水表。比如运营商的话单表,每天新增30GB数据,如果给这种表加主键约束,每条INSERT都得做唯一性检查,写入性能会明显下降。这时候可以牺牲约束换性能。

sql 复制代码
-- 常规做法:Sequence + 自增ID
CREATE SEQUENCE SEQ_EMPLOYEE_ID
    INCREMENT BY 1
    START WITH 1
    NOMAXVALUE
    NOCYCLE
    CACHE 300;

CREATE TABLE TB_HR_EMPLOYEE (
    emp_id INTEGER DEFAULT NEXTVAL('SEQ_EMPLOYEE_ID') PRIMARY KEY,
    emp_name VARCHAR(50) NOT NULL
    -- ...
);

2.3 外键到底该不该加

外键约束能保证数据完整性------比如订单引用的用户ID必须是存在的。但问题是:每次往子表INSERT或UPDATE,数据库都要去主表查一遍,确认引用的数据确实存在;反过来,删主表的记录时,也得检查子表有没有引用。

DML频繁的OLTP场景下,不建议使用外键约束。数据一致性改由应用层来保证。如果非用不可,记住两件事:

  1. 外键字段必须建索引,否则父表删一条记录会把子表锁个遍。
  2. 尽量别用CASCADE级联删除,用NO ACTION,让应用逻辑来决定怎么处理关联数据。级联删除在关联表很大的时候,一条DELETE可能引发连锁反应,性能雪崩。

2.4 大字段(LOB)怎么处理

LOB字段包括CLOB(大文本)、BLOB(二进制大数据)等,用来存储文章内容、图片、视频等数据。处理LOB最关键的一点就是:跟业务数据分开存

为什么呢?因为LOB数据通常又大、访问频率又低。你把一个几十KB的文档内容和订单基本信息放在同一张表里,每次查订单列表的时候,这些大字段都会被顺带读出来,白白浪费大量I/O。

sql 复制代码
-- ✅ 正确做法:主表和LOB内容分开
CREATE TABLE TB_DOC_INFO (
    doc_id      INTEGER      CONSTRAINT PK_DOC_INFO_DOCID PRIMARY KEY,
    doc_title   VARCHAR(200) NOT NULL,
    doc_type    VARCHAR(20),
    create_time TIMESTAMP    DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE TB_DOC_CONTENT (
    doc_id          INTEGER REFERENCES TB_DOC_INFO(doc_id),
    doc_content     CLOB,          -- 文本内容
    doc_attachment  BLOB           -- 附件
);

-- ❌ 错误做法:LOB和业务数据挤在一起
CREATE TABLE docs (
    id INTEGER PRIMARY KEY,
    title VARCHAR(200),
    content CLOB,       -- 每次查title都把content捎上
    file_data BLOB      -- 几十MB的文件塞在业务表里
);

还有一点:图片、扫描件这种文件,最好存到文件系统或者对象存储里,数据库里只存一个路径。别什么都往数据库里塞,这不是数据库该干的活。

2.5 临时表怎么选

KingbaseES支持会话级临时表,根据你的数据要活多久,选不同的关键字:

sql 复制代码
-- 场景一:数据只在当前事务中有用,事务结束就清空
CREATE GLOBAL TEMPORARY TABLE temp_order_process (
    order_id     INTEGER,
    process_step VARCHAR(50)
) ON COMMIT DELETE ROWS;

-- 场景二:数据在整个会话(连接)期间都要用
CREATE GLOBAL TEMPORARY TABLE temp_session_cache (
    cache_key   VARCHAR(100),
    cache_value TEXT
) ON COMMIT PRESERVE ROWS;

2.6 fillfactor:给高频更新的表留点余地

有个参数叫fillfactor,默认值是100,意思是一个数据页塞满100%的数据。对于频繁更新的表来说,可以把这个值调到80,也就是每页只填80%,留20%的空位。

为什么要留空?因为KingbaseES有一种叫HOT(Heap-Only Tuple)的优化:当你更新一行数据时,如果新数据能塞进同一个数据页的空位里,数据库就不需要更新索引了。这在大并发更新场景下能显著减少I/O。

sql 复制代码
-- 高频更新的表,留出20%空间给HOT更新
CREATE TABLE TB_LOG_OPERATION (
    log_id      BIGSERIAL    PRIMARY KEY,
    operation   VARCHAR(50)  NOT NULL,
    status      INTEGER      DEFAULT 0,
    update_time TIMESTAMP
) WITH (fillfactor=80);

三、索引:好钢用在刀刃上

索引这个东西,加对了是神器,加错了是累赘。每多一个索引,INSERT、UPDATE、DELETE就得多维护一份。所以索引不是越多越好,而是要加在该加的地方。

3.1 先说几条硬限制

  • 单表索引别超过5个(LOB自动创建的不算)。索引多了,DML的负担会直线上升。
  • 通过索引只能过滤掉不到5%的数据时,全表扫描反而更快。别一听"全表扫描"就觉得可怕,有时候它就是最优解。
  • 索引列的数据重复率不能太高。如果一个字段80%的值都是"正常",在上面建索引意义不大,因为不管怎么查,都得扫掉大部分数据。
  • 外键字段必须建索引。这不是可选项------没有索引的外键,在父表删除记录时会把子表锁个遍。

3.2 六种索引类型,各有各的舞台

KingbaseES提供了好几种索引类型,选对了事半功倍:

BTree索引------万金油

不指定索引类型时,默认就是BTree。等值查询、范围查询、排序、IS NULL判断,它都支持。绝大多数场景用它就够了。

sql 复制代码
-- 最常见的索引创建方式
CREATE INDEX IDX_ORDER_DATE ON TB_SHOP_ORDER(order_date);

-- BTree还支持前缀匹配的LIKE查询
-- ✅ col LIKE 'foo%'    能用索引
-- ✅ col ~ '^foo'       能用索引
-- ❌ col LIKE '%foo'    用不了索引

Hash索引------只做精确匹配

如果你有一个很长的字段(比如token、签名),只需要做等值查询(=),用Hash索引比BTree更轻量。但如果数据重复度高就别用了,遍历Hash桶的效率反而不如BTree。

sql 复制代码
-- 长字段的精确匹配场景
CREATE INDEX IDX_USER_TOKEN ON TB_USER_SESSION USING HASH(session_token);

GIN索引------数组、JSONB、全文检索的利器

当一个字段里可能包含多个值(比如数组、JSONB),或者需要做全文检索的时候,GIN索引就派上用场了。它为每个元素单独建索引项,查某个元素是否存在特别快。

sql 复制代码
-- 给数组字段建GIN索引
CREATE TABLE products (
    product_id SERIAL PRIMARY KEY,
    name       VARCHAR(255),
    tags       TEXT[]
);
CREATE INDEX IDX_PRODUCTS_TAGS ON products USING GIN(tags);

-- 查询包含"organic"标签的商品,走索引
SELECT * FROM products WHERE tags @> ARRAY['organic'];

-- GIN还能做前后模糊查询(需安装sys_trgm扩展)
CREATE EXTENSION SYS_TRGM;
CREATE TABLE t1_text(doc TEXT);
CREATE INDEX idx_t1_text ON t1_text USING GIN(doc gin_trgm_ops);

-- 前后模糊查询也能走索引了
SELECT * FROM t1_text WHERE doc LIKE '%关键词%';

GiST索引------空间和范围数据的专家

处理地理空间数据(点、线、面)或者时间范围查询时,GiST索引是好手。跟GIN相比,GiST在数据更新非常频繁的场景下维护成本更低。

sql 复制代码
-- 时间范围查询
CREATE TABLE events (
    id       SERIAL PRIMARY KEY,
    title    VARCHAR(255),
    duration TSRANGE
);
CREATE INDEX IDX_EVENTS_DURATION ON events USING GIST(duration);

-- 查询跟某个时间段重叠的事件
SELECT * FROM events
WHERE duration && '[2023-01-01 12:00, 2023-01-01 15:00]'::tsrange;

-- 最近邻查询(空间搜索)
CREATE TABLE points (id SERIAL PRIMARY KEY, p POINT);
CREATE INDEX idx_points_p ON points USING SPGIST(p);

-- 找离(237,0)最近的两个点
SELECT p, p <-> '(237,0)' AS distance
FROM points
ORDER BY p <-> '(237,0)'
LIMIT 2;

BRIN索引------日志表的轻量级选择

BRIN(Block Range Index)不索引每一行,而是索引每个数据块的摘要信息。它的体积比BTree小得多,维护成本也低。特别适合那种数据量大、且按写入顺序天然有序的表(比如日志表、流水表)。

sql 复制代码
-- 日志表用BRIN索引,又小又省心
CREATE TABLE TB_SYS_LOG (
    id         BIGSERIAL    PRIMARY KEY,
    log_time   TIMESTAMP    NOT NULL,
    log_detail TEXT
);
CREATE INDEX IDX_SYS_LOG_TIME ON TB_SYS_LOG USING BRIN(log_time);

3.3 复合索引:顺序很重要

当你需要在多个字段上建索引时,字段顺序直接决定了索引能不能被用到。核心规则:把区分度最高的字段放在最前面,把最常出现在WHERE条件中的字段放在左边

为什么?因为复合索引是"从左到右"匹配的。你跳过了左边的字段,索引就用不上了。

sql 复制代码
-- 假设status区分度高(比如90%的订单已完成,10%在处理中)
-- 那就把create_time放前面,因为它区分度更高
CREATE INDEX IDX_ORDER_TIME_STATUS ON TB_SHOP_ORDER(create_time, status);

-- ✅ 能用索引
SELECT * FROM TB_SHOP_ORDER WHERE create_time > '2024-01-01';
SELECT * FROM TB_SHOP_ORDER WHERE create_time > '2024-01-01' AND status = 1;

-- ❌ 用不了索引(跳过了前导列create_time)
SELECT * FROM TB_SHOP_ORDER WHERE status = 1;

复合索引包含的字段也别太多,建议不超过5个。过多的字段会让索引变得又大又慢,甚至可能比表本身还大。

3.4 条件索引和覆盖索引:两个进阶技巧

条件索引:有时候你只需要对表中的一部分数据建索引。比如订单表里,90%的查询都只看"有效"订单,已取消的根本不关心。那就只给有效订单建索引:

sql 复制代码
-- 只给有效订单建索引,已取消的不占索引空间
CREATE INDEX IDX_ORDER_ACTIVE ON TB_SHOP_ORDER(create_time)
WHERE status != 'CANCELLED';

这样做的好处很明显------索引体积小了、维护成本降了、查询速度反而更快了。

覆盖索引:如果你查询的字段刚好都在索引里(包括索引列和INCLUDE列),数据库就不需要再去表里取数据了,直接扫描索引就能返回结果。这在OLTP场景下非常有效:

sql 复制代码
-- 创建覆盖索引,把常用查询字段都包含进来
CREATE TABLE t (a INT, b INT, c INT);
CREATE UNIQUE INDEX IDX_T_AB ON t USING BTREE (a, b) INCLUDE (c);

-- 这个查询不需要访问表数据,直接从索引拿结果
SELECT a, b, c FROM t WHERE a = 1 AND b = 2;

四、分区表:大表的终极解法

当一张表的数据量到了千万级甚至亿级,单表就扛不住了。这时候分区表就派上用场------它把大表拆成多个小物理单元,查询的时候只扫相关的分区(这叫"分区剪枝"),性能自然就上来了。

4.1 分区前要想清楚的几件事

  • 每个分区的数据量别超过5000万条,大小别超过100GB。
  • 单表的分区数别超过500个,整个数据库别超过10万个分区。分区太多了,数据字典会变得非常臃肿,数据库整体效率都会下降。
  • Range分区一定要加个MAXVALUE分区,List分区一定要加个DEFAULT分区。不加上会怎样?万一来了个你没预料到的值,INSERT直接报错。
  • 同一张表的不同分区,数据量应该大致均衡。按地区分区要特别注意------如果某个地区的业务量远超其他地区,分区就会严重倾斜。

4.2 三种分区方式怎么选

Range分区------最常用,适合按时间走

绝大多数场景下,按时间做Range分区是最自然的选择。日志表、流水表、订单表,都适合按月或按天分区。

sql 复制代码
-- 订单表按月分区
CREATE TABLE TB_ORDER_LOG (
    order_id    BIGINT       NOT NULL,
    order_date  DATE         NOT NULL,
    customer_id INTEGER,
    amount      DECIMAL(12,2)
) PARTITION BY RANGE (order_date);

-- 每个月一个分区
CREATE TABLE TB_ORDER_LOG_202401 PARTITION OF TB_ORDER_LOG
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE TB_ORDER_LOG_202402 PARTITION OF TB_ORDER_LOG
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
CREATE TABLE TB_ORDER_LOG_202403 PARTITION OF TB_ORDER_LOG
    FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');

-- 兜底分区,防止数据插入失败
CREATE TABLE TB_ORDER_LOG_MAX PARTITION OF TB_ORDER_LOG
    FOR VALUES FROM ('2030-01-01') TO (MAXVALUE);

Range分区的额外好处是数据管理特别方便。过期的数据怎么清理?直接DROP掉旧分区就行,比DELETE快几个数量级:

sql 复制代码
-- 清理2024年1月的数据:一秒钟搞定
DROP TABLE TB_ORDER_LOG_202401;

List分区------按离散值分

适合分区列的取值有限、且不连续的场景,比如按地区、按状态。

sql 复制代码
-- 客户表按地区分区
CREATE TABLE TB_CUSTOMER (
    cust_id   INTEGER     NOT NULL,
    cust_name VARCHAR(50),
    region    VARCHAR(10)
) PARTITION BY LIST (region);

CREATE TABLE TB_CUSTOMER_NORTH PARTITION OF TB_CUSTOMER
    FOR VALUES IN ('BJ', 'TJ', 'HB');
CREATE TABLE TB_CUSTOMER_SOUTH PARTITION OF TB_CUSTOMER
    FOR VALUES IN ('GD', 'FJ', 'GX');
CREATE TABLE TB_CUSTOMER_DEFAULT PARTITION OF TB_CUSTOMER
    DEFAULT;  -- 兜底

Hash分区------分散压力

当分区列的值很多、数据分布比较均匀,你只是想把大表拆小、分散读写压力的时候,用Hash分区就对了。

sql 复制代码
-- 用户活跃表按user_id做Hash分区
CREATE TABLE TB_USER_ACTIVITY (
    activity_id BIGINT      NOT NULL,
    user_id     BIGINT      NOT NULL,
    action      VARCHAR(20),
    act_time    TIMESTAMP
) PARTITION BY HASH (user_id);

CREATE TABLE TB_USER_ACTIVITY_P0 PARTITION OF TB_USER_ACTIVITY
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE TB_USER_ACTIVITY_P1 PARTITION OF TB_USER_ACTIVITY
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE TB_USER_ACTIVITY_P2 PARTITION OF TB_USER_ACTIVITY
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE TB_USER_ACTIVITY_P3 PARTITION OF TB_USER_ACTIVITY
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

4.3 分区索引的几个讲究

  • 能用分区索引就别用全局索引。全局索引维护起来又慢又麻烦,特别是分区维护操作(如DROP分区)的时候。
  • 不同数据特征可以用不同的索引类型------比如某个字段大部分值都是"正常",偶尔有"异常",可以对"异常"值单独用GIN索引,对"正常"值用BTree。
  • 不需要检索的分区数据可以不建索引------比如用户表中只查已激活用户,未激活的分区就不需要建索引。

五、写好SQL,性能差距是数量级的

同样的需求,用不同的SQL写法,性能差个十倍百倍是很正常的事。下面这些实践经验,每一条都能在真实场景中帮你省下大量等待时间。

5.1 查询语句:这些坑别踩

**别写SELECT ***

这大概是说了一万遍但还是有人犯的错。SELECT * 会把所有列都读出来,不仅浪费网络带宽,更致命的是------哪天表结构加了一列LOB字段,之前"正常"的查询就突然变得巨慢了。写出你要的字段名,既安全又高效。

sql 复制代码
-- ✅ 只查需要的字段
SELECT order_id, order_date, customer_name
FROM TB_SHOP_ORDER
WHERE status = 1
ORDER BY order_date DESC
LIMIT 20;

-- ❌ 图省事的写法
SELECT * FROM TB_SHOP_ORDER;

表连接别超过5张

超过5张表的JOIN,执行计划的复杂度会呈指数级增长。如果业务确实需要关联很多表,试试把逻辑拆开,用临时表或者子查询分步处理。实在拆不开的话,用LEADING HINT手动指定连接顺序,让优化器少走弯路。

sql 复制代码
-- 多表连接时用别名,让SQL更清晰
SELECT o.order_id, o.order_date, c.cust_name, d.dept_name
FROM TB_SHOP_ORDER o
INNER JOIN TB_CUSTOMER c ON o.customer_id = c.cust_id
INNER JOIN TB_DEPARTMENT d ON c.dept_id = d.dept_id
WHERE o.status = 1
  AND o.order_date >= '2024-01-01'
ORDER BY o.order_date DESC
LIMIT 20;

IN子查询换成EXISTS

当IN里面是子查询时,性能往往不如EXISTS。NOT IN同理,换成NOT EXISTS会更好。

sql 复制代码
-- ❌ IN 子查询:数据量大时容易慢
SELECT * FROM orders
WHERE customer_id IN (
    SELECT cust_id FROM customers WHERE region = 'NORTH'
);

-- ✅ 改用 EXISTS:通常更快
SELECT o.* FROM orders o
WHERE EXISTS (
    SELECT 1 FROM customers c
    WHERE c.cust_id = o.customer_id AND c.region = 'NORTH'
);

用好绑定变量,减少硬解析

如果你的SQL只是参数值不同、结构完全一样,那就用绑定变量。这样数据库可以复用执行计划,不用每次都重新解析。一条SQL里绑定变量的数量最好别超过512个。

sql 复制代码
-- 绑定变量的用法
PREPARE query_order(INT) AS
    SELECT * FROM TB_SHOP_ORDER WHERE status = $1;

EXECUTE query_order(1);   -- 查状态为1的
EXECUTE query_order(2);   -- 查状态为2的
-- 两次查询共用同一个执行计划,效率高得多

5.2 小心条件表达式里的陷阱

别对索引列做运算。在索引字段上套函数或者做算术运算,索引就失效了。这不是Bug,而是因为索引存的是原始值,你做了运算之后,数据库没法用索引去匹配了。

sql 复制代码
-- ❌ 索引会失效
SELECT * FROM orders WHERE UPPER(customer_name) = 'ZHANG';
SELECT * FROM orders WHERE order_date + 7 > CURRENT_DATE;
SELECT * FROM orders WHERE amount * 1.1 > 1000;
SELECT * FROM orders WHERE SUBSTR(order_no, 1, 3) = 'ORD';

-- ✅ 换个写法,索引就能用了
SELECT * FROM orders WHERE customer_name = 'ZHANG';  -- 确保数据统一大写
SELECT * FROM orders WHERE order_date > CURRENT_DATE - INTERVAL '7 days';
SELECT * FROM orders WHERE amount > 1000 / 1.1;
SELECT * FROM orders WHERE order_no LIKE 'ORD%';     -- 前缀匹配走索引

如果业务确实需要大小写不敏感的搜索,建一个函数索引:

sql 复制代码
-- 函数索引:让UPPER查询也能走索引
CREATE INDEX IDX_ORDER_NAME_UPPER ON orders (UPPER(customer_name));

-- 现在这个查询可以走索引了
SELECT * FROM orders WHERE UPPER(customer_name) = 'ZHANG';

NULL值的坑 :NULL值不能用等号比较,WHERE col = NULL是查不出结果的,必须用IS NULL。绑定变量传入NULL值时也要特别小心,做好特殊处理。

5.3 排序和分组怎么做更高效

  • UNION ALL 替代 UNION:UNION会对结果集做排序去重,UNION ALL不会。如果确定不会有重复数据(或者你就是想要重复数据),用UNION ALL快得多。
  • ORDER BY的字段顺序要跟复合索引一致:不一致的话,数据库还得额外做一次排序。
  • GROUP BY和ORDER BY放到最后面:让WHERE先把数据量降下来,再做分组和排序,处理的行数会少很多。
sql 复制代码
-- 分页查询的正确姿势
-- 第一步:查总数(不需要ORDER BY,排序纯属浪费)
SELECT COUNT(*) FROM TB_SHOP_ORDER WHERE status = 1;

-- 第二步:分页取数据
SELECT order_id, customer_name, order_date
FROM TB_SHOP_ORDER
WHERE status = 1
ORDER BY order_date DESC
LIMIT 20 OFFSET 0;

OLTP系统的查询最好都加分页,返回记录别超过500条。不加限制的话,一旦数据暴增,不光数据库扛不住,中间件也可能被撑爆内存。

5.4 DML操作:写得对和写得快是两回事

插入的讲究

  • 批量插入时,先把索引删了,插完再重建。为什么?因为每插一行数据,所有索引都得同步更新,大量排序操作会严重拖慢插入速度。
  • 批量插入完成后,务必执行ANALYZE更新统计信息。不更新的话,数据库以为表还是空的,可能选出一个糟糕的执行计划。
  • 超大批量插入时,可以暂时禁用触发器和约束,插完再启用。但重新启用约束时要检查是否有非法数据。
sql 复制代码
-- 批量插入的标准流程
DROP INDEX IDX_ORDER_DATE;                   -- 先删索引
INSERT INTO TB_ORDER_LOG SELECT * FROM temp_orders;  -- 批量插入
CREATE INDEX IDX_ORDER_DATE ON TB_ORDER_LOG(order_date);  -- 重建索引
ANALYZE TB_ORDER_LOG;                        -- 更新统计信息

更新的讲究

UPDATE操作需要获取独占锁,事务时间越长,锁持有时间越长,越容易阻塞其他操作。大批量更新时也可以考虑先DROP索引再重建的策略。

删除的讲究

删除全表数据?别犹豫,用TRUNCATEDELETE需要逐行处理并产生大量日志,TRUNCATE直接重建文件,快得不是一个量级。即使只是删除大部分数据,也可以用TRUNCATE + INSERT的"偷梁换柱"法:

sql 复制代码
-- 方法一:清空全表
TRUNCATE TABLE TB_LOG_OPERATION;

-- 方法二:只保留部分数据
CREATE TABLE TB_ORDER_KEEP AS
    SELECT * FROM TB_ORDER WHERE status = 'ACTIVE';
DROP TABLE TB_ORDER;
ALTER TABLE TB_ORDER_KEEP RENAME TO TB_ORDER;

5.5 事务和并发:别让锁成了绊脚石

  • 避免大事务。一个事务里塞太多操作,锁持有时间长,其他请求都得等着。频繁提交大事务是并发性能的头号杀手。
  • 操作顺序保持一致。两个事务分别先锁A再锁B、先锁B再锁A------经典的死锁场景。所有事务对表的访问顺序保持一致,就能避免大部分死锁。
  • 慎用FOR UPDATE 。它会把查到的行锁住,很容易成为瓶颈。如果确实需要,加上NOWAIT或者FOR UPDATE OF 列名,至少别锁整行。

六、PL/SQL开发:写出健壮的程序

当你需要写更复杂的业务逻辑------比如循环处理数据、条件分支、异常捕获------就需要用到PL/SQL了。写PL/SQL跟写应用代码一样,有好的实践也有坏的实践。

6.1 变量声明:三种方式怎么选

KingbaseES给了三种变量声明方式,别纠结,根据场景选就行:

sql 复制代码
DECLARE
    -- 方式一:直接指定类型
    -- 适合与表无关的局部变量
    v_area VARCHAR2(20);

    -- 方式二:%TYPE ------ 跟表中某一列保持同类型
    -- 好处:列类型改了,变量自动跟着变
    stu_name students.name%TYPE;

    -- 方式三:%ROWTYPE ------ 跟表或游标的整行记录同类型
    -- 适合需要存储一整行数据的场景
    one_stu students%ROWTYPE;
BEGIN
    SELECT name INTO stu_name FROM students WHERE id = 1;
END;

6.2 游标:别再用COUNT(*)判断有没有数据了

一个特别常见的低效写法:用SELECT COUNT(*)来判断表中是否存在满足条件的记录。这会扫描所有符合条件的行,哪怕你只想知道"有没有"。

用游标的%NOTFOUND属性,找到第一条就能知道答案:

sql 复制代码
-- ❌ 低效写法:扫描全部数据只为知道"有没有"
SELECT COUNT(*) INTO v_tmp FROM orders WHERE status = 0;
IF v_tmp > 0 THEN
    -- 有数据
END IF;

-- ✅ 高效写法:找到第一条就停
DECLARE
    CURSOR c1 IS SELECT order_id FROM orders WHERE status = 0;
    v_order_id INTEGER;
BEGIN
    OPEN c1;
    FETCH c1 INTO v_order_id;
    IF c1%NOTFOUND THEN
        -- 没有数据
        DBMS_OUTPUT.PUT_LINE('没有待处理订单');
    ELSE
        -- 有数据
        DBMS_OUTPUT.PUT_LINE('找到待处理订单: ' || v_order_id);
    END IF;
    CLOSE c1;
END;

6.3 批量操作:FORALL和BULK COLLECT

PL/SQL引擎和SQL引擎是分开的,每次在它们之间传递数据都有开销。如果逐行处理,这个开销会被放大成千上万倍。解决办法就是批量绑定------一次传一批数据过去。

sql 复制代码
-- FORALL:批量执行DML
DECLARE
    TYPE IDList IS VARRAY(100) OF PLS_INTEGER;
    target_ids IDList := IDList(101, 102, 103, 104, 105);
BEGIN
    -- 一条语句删5条,不是5条语句各删1条
    FORALL i IN target_ids.FIRST..target_ids.LAST
        DELETE FROM student_temp WHERE id = target_ids(i);
END;

-- BULK COLLECT:批量获取数据
DECLARE
    TYPE NameSet IS TABLE OF VARCHAR2(100);
    names NameSet;
    TYPE ScoreSet IS TABLE OF NUMBER;
    scores ScoreSet;
BEGIN
    -- 一次把所有结果取出来,不用一行一行FETCH
    SELECT name, score
    BULK COLLECT INTO names, scores
    FROM students
    WHERE score > 80
    ORDER BY score DESC;

    FOR i IN names.FIRST..names.LAST LOOP
        DBMS_OUTPUT.PUT_LINE(names(i) || ': ' || scores(i));
    END LOOP;
END;

6.4 异常处理:每个程序都该有的安全网

程序出错不可怕,可怕的是错了你还不知道,或者错了之后数据处在一个不伦不类的状态。好的异常处理要解决两个问题:一是数据要能回滚到一致状态,二是错误信息要能留下来方便排查。

下面这个模板可以直接拿来用:

sql 复制代码
-- 开启语句级回滚(推荐)
SET ora_statement_level_rollback TO ON;

-- 错误日志表
CREATE TABLE proc_log (
    log_time  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    proc_name VARCHAR(100),
    status    INTEGER,
    sql_code  INTEGER,
    err_msg   TEXT
);

-- 用自治事务写日志(不受主事务ROLLBACK影响)
CREATE OR REPLACE PROCEDURE log_error(
    p_proc_name VARCHAR,
    p_status    INTEGER,
    p_sql_code  INTEGER,
    p_err_msg   TEXT
) AS
    PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
    INSERT INTO proc_log(proc_name, status, sql_code, err_msg)
    VALUES (p_proc_name, p_status, p_sql_code, p_err_msg);
    COMMIT;  -- 自治事务单独提交
END;

-- 业务存储过程标准模板
CREATE OR REPLACE PROCEDURE process_order(p_order_id INTEGER) AS
BEGIN
    -- ========== 业务逻辑开始 ==========
    UPDATE orders SET status = 2 WHERE order_id = p_order_id;
    INSERT INTO order_log(order_id, action, action_time)
    VALUES (p_order_id, 'PROCESSED', CURRENT_TIMESTAMP);
    -- ========== 业务逻辑结束 ==========

    COMMIT;  -- 业务完成后才提交

EXCEPTION
    WHEN OTHERS THEN
        ROLLBACK;  -- 先回滚,保证数据一致性
        log_error('process_order', -1, SQLCODE, SQLERRM);  -- 记录错误
        RAISE;  -- 重新抛出,让调用方知道出了问题
END;

6.5 动态SQL:灵活但要注意安全

动态SQL允许你在运行时拼装SQL语句,非常灵活,但也引入了SQL注入的风险。记住两条铁律:

  1. 能用绑定变量的地方坚决用绑定变量,不要拼接用户输入。
  2. 必须拼接的地方(如表名),一定要做白名单校验
sql 复制代码
-- ✅ 安全:用绑定变量传递参数
CREATE OR REPLACE PROCEDURE delete_employee(p_emp_id INTEGER) AS
BEGIN
    EXECUTE IMMEDIATE 'DELETE FROM emp WHERE empno = :num'
    USING p_emp_id;
END;

-- ❌ 危险:直接拼接用户输入(SQL注入风险)
-- EXECUTE IMMEDIATE 'DELETE FROM emp WHERE empno = ' || p_user_input;

-- 动态表名的正确处理方式
CREATE OR REPLACE PROCEDURE truncate_table(p_table_name VARCHAR) AS
    v_allowed BOOLEAN := FALSE;
BEGIN
    -- 白名单校验:只允许truncate特定的表
    IF p_table_name IN ('temp_orders', 'temp_logs', 'temp_cache') THEN
        v_allowed := TRUE;
    END IF;

    IF v_allowed THEN
        EXECUTE IMMEDIATE 'TRUNCATE TABLE ' || p_table_name;
    ELSE
        RAISE EXCEPTION '不允许操作表: %', p_table_name;
    END IF;
END;

注意一个细节:动态SQL中的对象名(表名、列名)不能使用绑定变量,只能拼接。但拼接的值一定要做校验!

6.6 函数的稳定态:选错了会影响查询优化

KingbaseES的函数有三种稳定态标签,选错了可能导致查询优化器做出错误判断:

稳定态 含义 适用场景
IMMUTABLE 同样的输入永远返回同样的结果 纯数学运算、字符串处理
STABLE 同一个查询中,同样的输入返回同样的结果 查数据库表的函数
VOLATILE 每次调用可能返回不同结果 获取当前时间、随机数
sql 复制代码
-- 纯计算函数 → IMMUTABLE(优化器可以提前算好结果)
CREATE OR REPLACE FUNCTION calculate_tax(p_amount DECIMAL)
RETURNS DECIMAL AS $$
BEGIN
    RETURN p_amount * 0.13;
END;
$$ LANGUAGE PLSQL IMMUTABLE;

-- 查表函数 → STABLE(同一查询中结果不变)
CREATE OR REPLACE FUNCTION get_dept_name(p_dept_id INTEGER)
RETURNS VARCHAR AS $$
DECLARE
    v_name VARCHAR(100);
BEGIN
    SELECT dept_name INTO v_name
    FROM departments
    WHERE dept_id = p_dept_id;
    RETURN v_name;
END;
$$ LANGUAGE PLSQL STABLE;

-- 获取当前时间 → VOLATILE(每次调用结果不同)
CREATE OR REPLACE FUNCTION get_now()
RETURNS TIMESTAMP AS $$
BEGIN
    RETURN CURRENT_TIMESTAMP;
END;
$$ LANGUAGE PLSQL VOLATILE;

如果你的函数不修改数据库、且参数相同结果一定相同,就应该标记为IMMUTABLE。这能让优化器在SQL优化阶段就执行函数,而不是每一行都算一遍。

七、系统层面的几件大事

除了表设计和SQL编写,还有一些系统架构层面的事情需要提前规划。

7.1 你的系统是OLTP还是OLAP?

这两类系统的设计思路完全不一样,搞清楚你的系统属于哪一种,才能对症下药:

OLTP(事务型) OLAP(分析型)
典型场景 电商下单、银行转账 报表统计、数据分析
关心什么 响应快、并发高 查得准、算得对
优化重点 绑定变量、索引、连接池 分区、并行查询、work_mem
SQL特点 简单、高频 复杂、低频

OLTP系统最重要的是内存设计。shared_buffers调大了,数据尽量都在内存里处理,性能自然就上去了。绑定变量更是OLTP的标配------不使用绑定变量的高并发系统,光硬解析就能把CPU吃满。

OLAP系统则不同,数据量太大,内存装不下,磁盘I/O才是瓶颈。分区技术和并行查询是OLAP系统性能提升的关键。

7.2 连接池:用静态的,别用动态的

连接池的选择很多人不太在意,但它对系统稳定性影响很大。

动态连接池有个致命问题------高并发时会引发"连接风暴"。什么意思呢?当请求突然增多时,连接池会不断创建新连接,而数据库服务器CPU核心数是有限的(比如32核一次只能跑32个进程)。几百上千个连接涌进来,CPU疯狂切换上下文,结果就是:吞吐量暴跌、响应时间飙升,严重时系统直接卡死。

静态连接池就没有这个问题------连接数固定,不会因为突发流量而失控。连接数的经验值是:每个CPU核心不超过10个连接。比如一台2颗CPU、每颗18核的服务器,连接池配36~360个就够用了。

sql 复制代码
-- 查看当前连接情况
SELECT COUNT(*) AS total_connections,
       COUNT(*) FILTER (WHERE state = 'active') AS active_connections
FROM sys_stat_activity;

-- 给用户设个连接上限,防患于未然
ALTER USER app_user CONNECTION LIMIT 50;

7.3 安全:别当耳旁风

  • 改掉默认密码。装完数据库第一件事就是把默认账户的密码改掉,使用包含字母、数字、特殊字符的强密码。
  • 权限最小化 。只给用户授予他需要的权限,别动不动就GRANT ALL
  • 敏感数据要加密。根据场景选加密方式------少量敏感字段用函数加密,大量数据用表空间透明加密,只有个别表含敏感信息就用表级加密。
  • 开启审计。记录关键操作,出了事才能追溯。
sql 复制代码
-- 创建用户时设置强密码
CREATE USER app_admin WITH PASSWORD 'Kj#2024$Str0ng!';

-- 只授予必要的权限,精确到表和操作类型
GRANT SELECT, INSERT, UPDATE ON TB_SHOP_ORDER TO app_admin;
GRANT SELECT ON TB_CUSTOMER TO app_admin;
GRANT USAGE ON SCHEMA order_system TO app_admin;

八、最后聊几句

数据库设计和SQL优化这门手艺,纸上谈兵容易,真到了生产环境才知道每个细节都有分量。这篇文章里总结的每一条,背后都有人踩过坑、甚至付出过惨痛的代价。

最后送你四句话:

  1. 设计时多想一步,运维时少求一次人。 表结构和索引方案在设计阶段就想清楚,上线后再改的代价是几何级增长的。
  2. 索引不是万能药。 每加一个索引之前,问自己一个问题:"这个索引具体能给哪些查询提速?"答不上来就先别加。
  3. 好习惯比好技巧更重要。 不写SELECT *、注意类型匹配、用好绑定变量------这些看似不起眼的小习惯,在大数据量下会变成巨大的性能差异。
  4. 异常处理不是锦上添花,而是必修课。 每个存储过程都该有异常处理,每个错误都该被记录。等到出了线上事故再补,那就晚了。

希望这篇文章能帮你在KingbaseES的开发路上少踩几个坑,写出跑得快、撑得住的好系统。

相关推荐
forEverPlume1 小时前
SQL如何统计分组内不重复值的数量_COUNT与DISTINCT结合应用
jvm·数据库·python
极创信息1 小时前
信创领域五种主流CPU架构(X86 / ARM / RISC-V / MIPS / LoongArch)
java·arm开发·数据库·spring boot·mysql·软件工程·risc-v
chaofan9801 小时前
突破大模型落地瓶颈:Claude 4.7 与 GPT-5.5 长上下文工程实测
数据库·人工智能·python·gpt·自动化·php·api
2501_901200531 小时前
PHP源码部署需要多大硬盘空间_PHP项目存储空间估算方法【方法】
jvm·数据库·python
小肝一下1 小时前
3. 数据类型
android·数据库·mysql·adb
2401_832365521 小时前
mysql如何优化mysql在多核CPU下的性能_调整线程并发数
jvm·数据库·python
m0_736439301 小时前
JavaScript中显式创建包装对象的后果与性能损耗
jvm·数据库·python
Mr_sst1 小时前
文件上传并发控制:为什么选Redisson可过期信号量?(避坑指南)
网络·数据库·redis·分布式·安全架构
四维迁跃1 小时前
JavaScript中Object-defineProperties批量设置属性
jvm·数据库·python