数据库 - PostgreSQL 唯一索引详解
概述
PostgreSQL 提供了强大的唯一索引功能,是实现幂等性的核心机制之一。本文深入讲解 PostgreSQL 唯一索引的各种用法和最佳实践。
基础唯一索引
1. 创建表时定义
sql
-- 方式 1: 列级约束
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE, -- 列级唯一约束
username VARCHAR(50) NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 方式 2: 表级约束
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sku VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
CONSTRAINT uk_products_sku UNIQUE (sku) -- 表级唯一约束
);2. 单独创建唯一索引
sql
-- 基本语法
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- 指定索引方法(默认 B-tree)
CREATE UNIQUE INDEX idx_users_email ON users USING btree(email);
-- 多列唯一索引
CREATE UNIQUE INDEX idx_orders_user_number
ON orders(user_id, order_number);高级唯一索引
1. 部分唯一索引(Partial Unique Index)
PostgreSQL 特有的强大功能,只对满足条件的行建立唯一约束:
sql
-- 只保证活跃用户的邮箱唯一
CREATE UNIQUE INDEX idx_users_email_active
ON users(email)
WHERE is_active = true;
-- 允许同一个邮箱有多个账户,但只有一个可以是活跃的
INSERT INTO users (email, is_active) VALUES ('test@example.com', true); -- ✓ 成功
INSERT INTO users (email, is_active) VALUES ('test@example.com', false); -- ✓ 成功
INSERT INTO users (email, is_active) VALUES ('test@example.com', true); -- ✗ 失败
-- 订单状态的部分唯一索引
CREATE UNIQUE INDEX idx_orders_pending_per_user
ON orders(user_id)
WHERE status = 'pending';
-- 每个用户只能有一个待支付订单2. 表达式唯一索引
基于表达式的结果建立唯一索引:
sql
-- 邮箱大小写不敏感的唯一索引
CREATE UNIQUE INDEX idx_users_email_lower
ON users(LOWER(email));
-- 测试
INSERT INTO users (email) VALUES ('User@Example.COM'); -- ✓ 成功
INSERT INTO users (email) VALUES ('user@example.com'); -- ✗ 失败(冲突)
-- 去除空格的电话号码
CREATE UNIQUE INDEX idx_users_phone_trimmed
ON users(TRIM(phone));
-- 组合表达式
CREATE UNIQUE INDEX idx_products_normalized_name
ON products(LOWER(TRIM(name)));3. NULLS NOT DISTINCT(PostgreSQL 15+)
PostgreSQL 15 引入的新特性,允许将 NULL 视为相同值:
sql
-- PostgreSQL 15+
CREATE UNIQUE INDEX idx_users_referral_code
ON users(referral_code)
NULLS NOT DISTINCT;
-- 测试
INSERT INTO users (referral_code) VALUES (NULL); -- ✓ 第一个 NULL
INSERT INTO users (referral_code) VALUES (NULL); -- ✗ 第二个 NULL(冲突)
-- 旧版本 workaround:使用部分索引
CREATE UNIQUE INDEX idx_users_referral_code_not_null
ON users(referral_code)
WHERE referral_code IS NOT NULL;Entity Framework Core 配置
1. Fluent API 配置
csharp
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public DbSet<User> Users { get; set; }
public DbSet<Product> Products { get; set; }
public DbSet<Order> Orders { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 单字段唯一索引
modelBuilder.Entity<User>()
.HasIndex(u => u.Email)
.IsUnique()
.HasDatabaseName("idx_users_email");
// 多字段唯一索引
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.UserId, o.OrderNumber })
.IsUnique()
.HasDatabaseName("idx_orders_user_number");
// 部分唯一索引(需要 raw SQL)
modelBuilder.Entity<User>()
.HasIndex(u => u.Email)
.HasFilter("is_active = true")
.IsUnique()
.HasDatabaseName("idx_users_email_active");
// 表达式唯一索引(需要 raw SQL)
modelBuilder.Entity<User>()
.HasIndex(u => u.Email)
.HasMethod("btree")
.IsUnique();
// 在迁移后执行自定义 SQL
modelBuilder.HasPostgresExtension("citext"); // 大小写不敏感文本
}
}2. 数据注解
csharp
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
public class User
{
public Guid Id { get; set; }
[Required]
[MaxLength(255)]
[EmailAddress]
public string Email { get; set; }
[Required]
[MaxLength(50)]
public string Username { get; set; }
}
// 注意:数据注解不支持部分索引和表达式索引
// 需要使用 Fluent API 或 raw SQLUPSERT 操作
1. INSERT ... ON CONFLICT
PostgreSQL 的 UPSERT 语法:
sql
-- 基本语法:冲突时什么都不做
INSERT INTO users (id, email, username)
VALUES (gen_random_uuid(), 'test@example.com', 'testuser')
ON CONFLICT (email)
DO NOTHING;
-- 冲突时更新某些字段
INSERT INTO users (id, email, username, updated_at)
VALUES (gen_random_uuid(), 'test@example.com', 'testuser', NOW())
ON CONFLICT (email)
DO UPDATE SET
username = EXCLUDED.username,
updated_at = NOW();
-- 使用条件
INSERT INTO users (id, email, status)
VALUES (gen_random_uuid(), 'test@example.com', 'active')
ON CONFLICT (email)
DO UPDATE SET
status = EXCLUDED.status
WHERE users.status != 'deleted'; -- 只有非删除状态才更新2. C# 实现
csharp
public class UserRepository
{
private readonly AppDbContext _dbContext;
/// <summary>
/// 使用 UPSERT 创建或更新用户
/// </summary>
public async Task<User> UpsertUserAsync(User user)
{
const string sql = @"
INSERT INTO users (id, email, username, created_at, updated_at)
VALUES (@id, @email, @username, @createdAt, @updatedAt)
ON CONFLICT (email)
DO UPDATE SET
username = EXCLUDED.username,
updated_at = EXCLUDED.updated_at
RETURNING *";
await using var command = _dbContext.Database.GetDbConnection().CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("@id", user.Id);
command.Parameters.AddWithValue("@email", user.Email);
command.Parameters.AddWithValue("@username", user.Username);
command.Parameters.AddWithValue("@createdAt", user.CreatedAt);
command.Parameters.AddWithValue("@updatedAt", user.UpdatedAt);
await _dbContext.Database.OpenConnectionAsync();
await using var reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return MapUser(reader);
}
throw new Exception("Failed to upsert user");
}
/// <summary>
/// 批量 UPSERT
/// </summary>
public async Task<int> BulkUpsertUsersAsync(List<User> users)
{
using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
int affectedRows = 0;
foreach (var user in users)
{
var sql = @"
INSERT INTO users (id, email, username, created_at, updated_at)
VALUES (@id, @email, @username, @createdAt, @updatedAt)
ON CONFLICT (email)
DO UPDATE SET
username = EXCLUDED.username,
updated_at = EXCLUDED.updated_at";
affectedRows += await _dbContext.Database.ExecuteSqlRawAsync(sql,
new NpgsqlParameter("@id", user.Id),
new NpgsqlParameter("@email", user.Email),
new NpgsqlParameter("@username", user.Username),
new NpgsqlParameter("@createdAt", user.CreatedAt),
new NpgsqlParameter("@updatedAt", user.UpdatedAt));
}
await transaction.CommitAsync();
return affectedRows;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}性能优化
1. 索引监控
sql
-- 查看索引使用情况
SELECT
schemaname,
tablename,
indexname,
idx_scan, -- 索引扫描次数
idx_tup_read, -- 读取的元组数
idx_tup_fetch -- 获取的元组数
FROM pg_stat_user_indexes
WHERE indexname LIKE 'idx_%'
ORDER BY idx_scan DESC;
-- 查看未使用的索引
SELECT
schemaname,
tablename,
indexname,
idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexname LIKE 'idx_%';
-- 查看索引大小
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexname::text)) AS index_size
FROM pg_indexes
WHERE tablename = 'users'
AND indexname LIKE 'idx_%';2. 重建索引
sql
-- 重建单个索引
REINDEX INDEX idx_users_email;
-- 重建表的所有索引
REINDEX TABLE users;
-- 并发重建(不阻塞读写,PostgreSQL 12+)
REINDEX INDEX CONCURRENTLY idx_users_email;
-- 定期重建碎片化的索引
-- 当索引膨胀超过 30% 时重建
SELECT
indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan,
idx_tup_read
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;3. 避免索引膨胀
sql
-- 检查索引膨胀
SELECT
schemaname,
relname AS table_name,
indexrelname AS index_name,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
CASE
WHEN idx_scan = 0 THEN 'UNUSED'
WHEN pg_relation_size(indexrelid) > 100 * 1024 * 1024 THEN 'LARGE'
ELSE 'OK'
END AS status
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;
-- VACUUM 清理死元组
VACUUM ANALYZE users;
-- 完全清理(回收空间,但会锁表)
VACUUM FULL users;错误处理
1. 捕获唯一约束违反
csharp
using Npgsql;
public class UserService
{
private readonly AppDbContext _dbContext;
private readonly ILogger<UserService> _logger;
public async Task<Result<User>> CreateUserAsync(CreateUserRequest request)
{
var user = new User
{
Id = Guid.NewGuid(),
Email = request.Email.ToLowerInvariant(),
Username = request.Username,
CreatedAt = DateTime.UtcNow
};
try
{
_dbContext.Users.Add(user);
await _dbContext.SaveChangesAsync();
return Result<User>.Success(user);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
var constraintName = ExtractConstraintName(ex);
_logger.LogWarning("Unique constraint violation: {Constraint}", constraintName);
if (constraintName.Contains("email"))
{
return Result<User>.Failure("Email already registered");
}
else if (constraintName.Contains("username"))
{
return Result<User>.Failure("Username already taken");
}
return Result<User>.Failure("Duplicate entry detected");
}
}
private bool IsUniqueViolation(DbUpdateException ex)
{
return ex.InnerException is PostgresException pgEx
&& pgEx.SqlState == "23505"; // unique_violation
}
private string ExtractConstraintName(DbUpdateException ex)
{
if (ex.InnerException is PostgresException pgEx)
{
return pgEx.ConstraintName ?? "";
}
return "";
}
}2. PostgreSQL 错误码
csharp
public static class PostgresErrorCodes
{
public const string UniqueViolation = "23505";
public const string ForeignKeyViolation = "23503";
public const string NotNullViolation = "23502";
public const string CheckViolation = "23514";
}
// 使用
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pgEx)
{
switch (pgEx.SqlState)
{
case PostgresErrorCodes.UniqueViolation:
// 处理唯一约束违反
break;
case PostgresErrorCodes.ForeignKeyViolation:
// 处理外键约束违反
break;
default:
throw;
}
}最佳实践总结
✅ DO
- 选择合适的字段:只对真正需要唯一的字段建立索引
- 使用部分索引:减少索引大小,提高性能
- 定期监控:检查索引使用情况和膨胀
- 命名规范:使用
idx_table_column格式 - 考虑大小写:使用 LOWER() 或 citext 扩展
❌ DON'T
- 不要过度索引:每个索引都会降低写入性能
- 不要忽略 NULL:理解 NULL 在唯一索引中的行为
- 不要忘记维护:定期 VACUUM 和 REINDEX
- 不要假设顺序:多列索引的顺序很重要
总结
PostgreSQL 唯一索引是实现幂等性的强大工具:
✅ 功能丰富:部分索引、表达式索引、UPSERT
✅ 性能优秀:B-tree 索引高效可靠
✅ 灵活配置:支持多种场景
✅ 易于监控:丰富的系统视图
通过合理使用唯一索引,可以在数据库层面保证数据的唯一性,实现强一致性的幂等控制。