副标题:如何保证全球70亿用户的手机号不重复?🎯
🎬 开场:唯一约束的挑战
单机时代 vs 分布式时代
单机时代(很简单):
数据库唯一索引搞定:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
phone VARCHAR(11) UNIQUE, -- 唯一约束 ✅
email VARCHAR(100) UNIQUE
);
INSERT INTO users VALUES (1, '13800138000', 'test@qq.com');
INSERT INTO users VALUES (2, '13800138000', 'test2@qq.com');
-- 报错:Duplicate entry '13800138000' for key 'phone' ✅
分布式时代(困难重重):
场景:分库分表后
┌─────────┐ ┌─────────┐ ┌─────────┐
│ DB-1 │ │ DB-2 │ │ DB-3 │
│ users │ │ users │ │ users │
└─────────┘ └─────────┘ └─────────┘
问题:
同一个手机号可能同时在3个库中插入!❌
用户A → DB-1: INSERT phone='13800138000' ✅
用户B → DB-2: INSERT phone='13800138000' ✅
用户C → DB-3: INSERT phone='13800138000' ✅
结果:同一个手机号注册了3次!💥
真实故障案例
某社交平台的灾难:
背景:用户表分库分表(16个库)
10:00 用户A使用手机号 13800138000 注册
路由到 DB-1,注册成功 ✅
10:01 用户B也使用手机号 13800138000 注册
路由到 DB-5,注册成功 ✅
10:05 发现问题:同一个手机号注册了2个账号!
影响:
├── 用户混乱:不知道该用哪个账号
├── 数据污染:积分、订单分散在两个账号
├── 安全隐患:可能被恶意利用
└── 客诉暴增:需要人工合并账号
损失:约50万用户数据需要手工清理
📚 核心挑战
1. 分库分表场景
问题:唯一索引失效
单库:
┌─────────────────┐
│ users_db │
│ UNIQUE(phone) │ ← 数据库保证唯一
└─────────────────┘
分库:
┌──────────┐ ┌──────────┐
│ DB-1 │ │ DB-2 │
│UNIQUE │ │UNIQUE │
└──────────┘ └──────────┘
↑ ↑
└─────┬───────┘
│
只在各自库内唯一 ❌
全局不唯一!
2. 微服务场景
问题:服务之间无法共享数据库约束
用户服务:
├── users_db
└── UNIQUE(phone)
订单服务:
├── orders_db
└── 无法访问 users_db
商品服务:
├── products_db
└── 无法访问 users_db
如何保证跨服务的唯一性?
🛠️ 解决方案
方案1:中心化唯一性校验服务 ⭐⭐⭐⭐⭐
最常用、最可靠的方案!
架构设计
┌────────────────────────────────────┐
│ 唯一性校验服务 │
│ (Uniqueness Check Service) │
│ │
│ ┌──────────────────────────────┐ │
│ │ Redis(存储已使用的值) │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ MySQL(持久化存储) │ │
│ └──────────────────────────────┘ │
└────────────┬───────────────────────┘
│
┌────────┴────────┬────────┐
│ │ │
┌───▼────┐ ┌────▼───┐ ┌─▼─────┐
│用户服务│ │订单服务│ │其他...│
└────────┘ └────────┘ └───────┘
数据库设计
-- 唯一性约束表
CREATE TABLE `unique_constraint` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`constraint_type` VARCHAR(32) NOT NULL COMMENT '约束类型: PHONE, EMAIL, USERNAME',
`constraint_value` VARCHAR(255) NOT NULL COMMENT '约束值',
`business_id` VARCHAR(64) NOT NULL COMMENT '业务ID(如用户ID)',
`business_type` VARCHAR(32) NOT NULL COMMENT '业务类型: USER, MERCHANT',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1-有效, 0-已释放',
`create_time` DATETIME NOT NULL,
`update_time` DATETIME NOT NULL,
UNIQUE KEY `uk_constraint` (`constraint_type`, `constraint_value`) -- 唯一索引
) ENGINE=InnoDB COMMENT='全局唯一性约束表';
-- 索引
CREATE INDEX `idx_business` ON `unique_constraint`(`business_type`, `business_id`);
代码实现
/**
* 唯一性校验服务
*/
@Service
public class UniquenessCheckService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private UniqueConstraintMapper constraintMapper;
private static final String UNIQUE_PREFIX = "unique:";
/**
* 检查并占用(原子操作)
*/
@Transactional
public boolean checkAndOccupy(
String constraintType,
String constraintValue,
String businessType,
String businessId) {
// 1. 先检查Redis(快速失败)
String redisKey = buildRedisKey(constraintType, constraintValue);
Boolean absent = redisTemplate.opsForValue()
.setIfAbsent(redisKey, businessId, 7, TimeUnit.DAYS);
if (Boolean.FALSE.equals(absent)) {
// Redis中已存在
log.warn("唯一性校验失败(Redis): {}-{}", constraintType, constraintValue);
return false;
}
// 2. 写入数据库(持久化)
UniqueConstraint constraint = new UniqueConstraint();
constraint.setConstraintType(constraintType);
constraint.setConstraintValue(constraintValue);
constraint.setBusinessType(businessType);
constraint.setBusinessId(businessId);
constraint.setStatus(1);
constraint.setCreateTime(new Date());
constraint.setUpdateTime(new Date());
try {
constraintMapper.insert(constraint);
log.info("唯一性校验成功: {}-{}", constraintType, constraintValue);
return true;
} catch (DuplicateKeyException e) {
// 数据库唯一索引冲突
log.warn("唯一性校验失败(DB): {}-{}", constraintType, constraintValue);
// 删除Redis中的记录(保持一致)
redisTemplate.delete(redisKey);
return false;
}
}
/**
* 检查是否可用
*/
public boolean checkAvailable(String constraintType, String constraintValue) {
// 1. 先查Redis
String redisKey = buildRedisKey(constraintType, constraintValue);
if (Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))) {
return false;
}
// 2. 再查数据库
UniqueConstraint constraint = constraintMapper.selectByConstraint(
constraintType, constraintValue
);
return constraint == null || constraint.getStatus() == 0;
}
/**
* 释放约束
*/
@Transactional
public void release(String constraintType, String constraintValue) {
// 1. 删除Redis
String redisKey = buildRedisKey(constraintType, constraintValue);
redisTemplate.delete(redisKey);
// 2. 更新数据库(软删除)
constraintMapper.updateStatus(constraintType, constraintValue, 0);
log.info("释放唯一约束: {}-{}", constraintType, constraintValue);
}
/**
* 批量检查
*/
public Map<String, Boolean> batchCheck(
String constraintType,
List<String> constraintValues) {
Map<String, Boolean> result = new HashMap<>();
for (String value : constraintValues) {
result.put(value, checkAvailable(constraintType, value));
}
return result;
}
private String buildRedisKey(String type, String value) {
return UNIQUE_PREFIX + type + ":" + value;
}
}
使用示例
/**
* 用户注册服务
*/
@Service
public class UserRegisterService {
@Autowired
private UniquenessCheckService uniquenessCheckService;
@Autowired
private UserMapper userMapper;
/**
* 注册用户
*/
@Transactional
public User register(RegisterRequest request) {
String phone = request.getPhone();
String email = request.getEmail();
// 1. 校验手机号唯一性
boolean phoneAvailable = uniquenessCheckService.checkAndOccupy(
"PHONE", phone, "USER", null // businessId暂时为null
);
if (!phoneAvailable) {
throw new BusinessException("手机号已被注册");
}
// 2. 校验邮箱唯一性
boolean emailAvailable = uniquenessCheckService.checkAndOccupy(
"EMAIL", email, "USER", null
);
if (!emailAvailable) {
// 回滚手机号占用
uniquenessCheckService.release("PHONE", phone);
throw new BusinessException("邮箱已被注册");
}
try {
// 3. 创建用户
User user = new User();
user.setPhone(phone);
user.setEmail(email);
user.setUsername(request.getUsername());
user.setPassword(encryptPassword(request.getPassword()));
user.setCreateTime(new Date());
userMapper.insert(user);
// 4. 更新业务ID
updateBusinessId("PHONE", phone, user.getId());
updateBusinessId("EMAIL", email, user.getId());
log.info("用户注册成功: userId={}, phone={}", user.getId(), phone);
return user;
} catch (Exception e) {
// 失败回滚
uniquenessCheckService.release("PHONE", phone);
uniquenessCheckService.release("EMAIL", email);
throw e;
}
}
private void updateBusinessId(String type, String value, Long userId) {
constraintMapper.updateBusinessId(type, value, String.valueOf(userId));
}
}
方案2:分布式锁 + 数据库 ⭐⭐⭐⭐
适合高并发场景!
/**
* 使用分布式锁保证唯一性
*/
@Service
public class UniquenessLockService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private UserMapper userMapper;
/**
* 注册用户(分布式锁)
*/
public User register(RegisterRequest request) {
String phone = request.getPhone();
String lockKey = "lock:register:phone:" + phone;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,最多等待5秒
boolean locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("系统繁忙,请稍后重试");
}
// 双重检查:手机号是否已存在
User existingUser = userMapper.selectByPhone(phone);
if (existingUser != null) {
throw new BusinessException("手机号已被注册");
}
// 创建用户
User user = new User();
user.setPhone(phone);
user.setEmail(request.getEmail());
user.setUsername(request.getUsername());
user.setCreateTime(new Date());
userMapper.insert(user);
log.info("用户注册成功: userId={}, phone={}", user.getId(), phone);
return user;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("注册失败", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
优缺点:
优点 ✅:
- 强一致性保证
- 实现相对简单
缺点 ❌:
- 性能开销大(加锁解锁)
- 并发度受限
- 依赖Redis
适用场景:
- 并发不是特别高
- 对一致性要求极高
方案3:布隆过滤器 + 数据库 ⭐⭐⭐
快速判断,减少数据库查询!
/**
* 布隆过滤器 + 数据库
*/
@Service
public class UniquenessBloomService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private UserMapper userMapper;
private RBloomFilter<String> phoneBloomFilter;
@PostConstruct
public void init() {
// 初始化布隆过滤器
phoneBloomFilter = redissonClient.getBloomFilter("user:phone:bloom");
phoneBloomFilter.tryInit(100000000L, 0.01); // 1亿用户,1%误判率
// 加载已存在的手机号
List<String> existingPhones = userMapper.selectAllPhones();
existingPhones.forEach(phoneBloomFilter::add);
log.info("布隆过滤器初始化完成,加载{}个手机号", existingPhones.size());
}
/**
* 检查手机号是否可用
*/
public boolean isPhoneAvailable(String phone) {
// 1. 先用布隆过滤器判断
if (phoneBloomFilter.contains(phone)) {
// 可能存在,需要查数据库确认
User user = userMapper.selectByPhone(phone);
return user == null;
}
// 2. 布隆过滤器判断不存在,一定不存在
return true;
}
/**
* 注册用户
*/
@Transactional
public User register(RegisterRequest request) {
String phone = request.getPhone();
// 1. 快速判断
if (!isPhoneAvailable(phone)) {
throw new BusinessException("手机号已被注册");
}
// 2. 创建用户
User user = new User();
user.setPhone(phone);
user.setEmail(request.getEmail());
user.setCreateTime(new Date());
try {
userMapper.insert(user);
// 3. 添加到布隆过滤器
phoneBloomFilter.add(phone);
log.info("用户注册成功: {}", phone);
return user;
} catch (DuplicateKeyException e) {
// 并发创建,数据库唯一索引保底
throw new BusinessException("手机号已被注册");
}
}
}
优缺点:
优点 ✅:
- 快速判断(内存操作)
- 减少数据库查询
- 性能高
缺点 ❌:
- 存在误判(需要数据库二次确认)
- 内存占用
- 需要定期重建
适用场景:
- 数据量大
- 读多写少
- 可接受小概率误判
方案4:分段路由 + 数据库唯一索引 ⭐⭐⭐⭐
分库分表场景的最佳实践!
/**
* 分段路由策略
*/
@Service
public class UniqueRoutingService {
@Autowired
private List<DataSource> dataSources; // 多个数据源
/**
* 根据手机号路由到固定的库
*/
private int routeByPhone(String phone) {
// 使用一致性Hash保证同一个手机号总是路由到同一个库
return Math.abs(phone.hashCode()) % dataSources.size();
}
/**
* 注册用户
*/
public User register(RegisterRequest request) {
String phone = request.getPhone();
// 1. 路由到固定的库
int dbIndex = routeByPhone(phone);
DataSource dataSource = dataSources.get(dbIndex);
log.info("手机号{}路由到DB-{}", phone, dbIndex);
// 2. 在该库中利用唯一索引保证唯一性
try (Connection conn = dataSource.getConnection()) {
// 检查是否已存在
String checkSql = "SELECT id FROM users WHERE phone = ?";
PreparedStatement checkStmt = conn.prepareStatement(checkSql);
checkStmt.setString(1, phone);
ResultSet rs = checkStmt.executeQuery();
if (rs.next()) {
throw new BusinessException("手机号已被注册");
}
// 插入用户
String insertSql = "INSERT INTO users (phone, email, username, create_time) VALUES (?, ?, ?, NOW())";
PreparedStatement insertStmt = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS);
insertStmt.setString(1, phone);
insertStmt.setString(2, request.getEmail());
insertStmt.setString(3, request.getUsername());
insertStmt.executeUpdate();
ResultSet generatedKeys = insertStmt.getGeneratedKeys();
if (generatedKeys.next()) {
Long userId = generatedKeys.getLong(1);
log.info("用户注册成功: userId={}, phone={}, db={}", userId, phone, dbIndex);
User user = new User();
user.setId(userId);
user.setPhone(phone);
user.setEmail(request.getEmail());
user.setUsername(request.getUsername());
return user;
}
} catch (SQLException e) {
if (e.getErrorCode() == 1062) { // MySQL Duplicate entry
throw new BusinessException("手机号已被注册");
}
throw new RuntimeException("注册失败", e);
}
return null;
}
}
关键点:
1. 路由规则固定
同一个手机号必须始终路由到同一个库
2. 利用数据库唯一索引
每个库内部的唯一性由数据库保证
3. 全局唯一性
因为同一个手机号永远在同一个库,所以全局唯一
优点 ✅:
- 性能好(无额外依赖)
- 可靠性高(数据库保证)
- 易于扩展
缺点 ❌:
- 路由逻辑固定,不易调整
- 需要处理数据迁移
推荐场景:
- 分库分表
- 数据量大
- 推荐方案 ⭐⭐⭐⭐
🎯 实战场景
场景1:用户注册(手机号唯一)
/**
* 完整的用户注册流程
*/
@Service
public class UserRegisterCompleteService {
@Autowired
private UniquenessCheckService uniquenessCheckService;
@Autowired
private UserMapper userMapper;
@Autowired
private SmsService smsService;
/**
* 注册流程
*/
@Transactional(rollbackFor = Exception.class)
public User register(RegisterRequest request) {
String phone = request.getPhone();
String smsCode = request.getSmsCode();
// 1. 验证短信验证码
if (!smsService.verify(phone, smsCode)) {
throw new BusinessException("验证码错误");
}
// 2. 检查手机号唯一性并占用
boolean available = uniquenessCheckService.checkAndOccupy(
"PHONE", phone, "USER", null
);
if (!available) {
throw new BusinessException("手机号已被注册");
}
try {
// 3. 创建用户
User user = new User();
user.setPhone(phone);
user.setUsername(generateUsername(phone)); // 自动生成用户名
user.setAvatar(getDefaultAvatar());
user.setStatus(UserStatus.NORMAL);
user.setCreateTime(new Date());
userMapper.insert(user);
// 4. 更新唯一约束表的业务ID
uniquenessCheckService.updateBusinessId(
"PHONE", phone, "USER", String.valueOf(user.getId())
);
// 5. 发送欢迎短信
smsService.sendWelcome(phone);
log.info("用户注册成功: userId={}, phone={}", user.getId(), phone);
return user;
} catch (Exception e) {
// 失败回滚:释放手机号占用
uniquenessCheckService.release("PHONE", phone);
throw e;
}
}
/**
* 注销用户(释放手机号)
*/
@Transactional
public void unregister(Long userId) {
// 1. 查询用户
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
// 2. 释放手机号
uniquenessCheckService.release("PHONE", user.getPhone());
// 3. 删除用户(软删除)
userMapper.updateStatus(userId, UserStatus.DELETED);
log.info("用户注销成功,手机号已释放: userId={}, phone={}",
userId, user.getPhone());
}
}
场景2:商家入驻(营业执照唯一)
/**
* 商家入驻服务
*/
@Service
public class MerchantRegisterService {
@Autowired
private UniquenessCheckService uniquenessCheckService;
@Autowired
private MerchantMapper merchantMapper;
/**
* 商家入驻
*/
@Transactional
public Merchant register(MerchantRegisterRequest request) {
String licenseNo = request.getLicenseNo(); // 营业执照号
String phone = request.getPhone();
// 1. 检查营业执照号唯一性
boolean licenseAvailable = uniquenessCheckService.checkAndOccupy(
"LICENSE_NO", licenseNo, "MERCHANT", null
);
if (!licenseAvailable) {
throw new BusinessException("该营业执照已被使用");
}
// 2. 检查手机号唯一性(商家维度)
boolean phoneAvailable = uniquenessCheckService.checkAndOccupy(
"MERCHANT_PHONE", phone, "MERCHANT", null
);
if (!phoneAvailable) {
uniquenessCheckService.release("LICENSE_NO", licenseNo);
throw new BusinessException("该手机号已被使用");
}
try {
// 3. 创建商家
Merchant merchant = new Merchant();
merchant.setLicenseNo(licenseNo);
merchant.setCompanyName(request.getCompanyName());
merchant.setPhone(phone);
merchant.setStatus(MerchantStatus.PENDING); // 待审核
merchant.setCreateTime(new Date());
merchantMapper.insert(merchant);
// 4. 更新业务ID
String merchantId = String.valueOf(merchant.getId());
uniquenessCheckService.updateBusinessId(
"LICENSE_NO", licenseNo, "MERCHANT", merchantId
);
uniquenessCheckService.updateBusinessId(
"MERCHANT_PHONE", phone, "MERCHANT", merchantId
);
log.info("商家入驻成功: merchantId={}, licenseNo={}",
merchant.getId(), licenseNo);
return merchant;
} catch (Exception e) {
// 失败回滚
uniquenessCheckService.release("LICENSE_NO", licenseNo);
uniquenessCheckService.release("MERCHANT_PHONE", phone);
throw e;
}
}
}
📊 方案对比
| 方案 | 性能 | 一致性 | 复杂度 | 推荐度 | 适用场景 |
|---|---|---|---|---|---|
| 中心化服务 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 通用 |
| 分布式锁 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | 低并发 |
| 布隆过滤器 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | 大数据量 |
| 分段路由 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | 分库分表 |
🎉 总结
核心要点
1. 问题本质
分布式环境下,数据库唯一索引失效
2. 解决思路
├── 中心化管理(推荐)
├── 分布式锁
├── 快速过滤
└── 固定路由
3. 关键技术
├── Redis(快速检查)
├── 数据库(持久化)
├── 分布式锁(并发控制)
└── 布隆过滤器(快速过滤)
4. 最佳实践
├── Redis + 数据库双重保证
├── 原子操作(Lua脚本)
├── 失败回滚
└── 监控告警
选择建议
场景1:用户注册(手机号唯一)
方案:中心化唯一性服务
理由:通用、可靠、易维护
场景2:分库分表
方案:分段路由 + 数据库唯一索引
理由:性能好、可靠性高
场景3:超大规模(亿级用户)
方案:布隆过滤器 + 中心化服务
理由:快速过滤,减少数据库压力
场景4:低并发场景
方案:分布式锁
理由:实现简单,足够用
记忆口诀
分布式唯一约束难,
数据库索引已失效。
四种方案来解决,
场景不同方案选。
中心化服务最常用,
Redis数据库双保险。
原子操作用Lua脚本,
失败回滚要记牢。
分布式锁强一致,
性能开销要考虑。
低并发可以用,
高并发要慎重。
布隆过滤性能高,
快速判断减查询。
大数据量适用它,
小概率误判可接受。
分段路由最优雅,
固定路由到同库。
利用数据库索引,
全局唯一有保证!
愿你的系统唯一约束坚不可摧,重复数据无处遁形! 🔐✨