前言
做过后端开发的人大概都有过这样的经历------项目刚上线时一切正常,跑了几个月之后,某个页面突然慢得离谱,一查才发现是一张表涨到了几千万行,索引建得乱七八糟,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场景下,不建议使用外键约束。数据一致性改由应用层来保证。如果非用不可,记住两件事:
- 外键字段必须建索引,否则父表删一条记录会把子表锁个遍。
- 尽量别用
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索引再重建的策略。
删除的讲究
删除全表数据?别犹豫,用TRUNCATE。DELETE需要逐行处理并产生大量日志,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注入的风险。记住两条铁律:
- 能用绑定变量的地方坚决用绑定变量,不要拼接用户输入。
- 必须拼接的地方(如表名),一定要做白名单校验。
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优化这门手艺,纸上谈兵容易,真到了生产环境才知道每个细节都有分量。这篇文章里总结的每一条,背后都有人踩过坑、甚至付出过惨痛的代价。
最后送你四句话:
- 设计时多想一步,运维时少求一次人。 表结构和索引方案在设计阶段就想清楚,上线后再改的代价是几何级增长的。
- 索引不是万能药。 每加一个索引之前,问自己一个问题:"这个索引具体能给哪些查询提速?"答不上来就先别加。
- 好习惯比好技巧更重要。 不写SELECT *、注意类型匹配、用好绑定变量------这些看似不起眼的小习惯,在大数据量下会变成巨大的性能差异。
- 异常处理不是锦上添花,而是必修课。 每个存储过程都该有异常处理,每个错误都该被记录。等到出了线上事故再补,那就晚了。
希望这篇文章能帮你在KingbaseES的开发路上少踩几个坑,写出跑得快、撑得住的好系统。