学习 12306 项目后的三个工程化收获:分库分表、异常处理与敏感数据保护
最近在学习 12306 分布式票务项目时,我重点看了三个比较有代表性的工程化设计:
- ShardingSphere 分库分表
- 全局异常拦截器
- 防止敏感数据泄露
这三个点不属于某一个具体业务功能,但它们对项目的稳定性、可维护性和安全性影响很大。学习完之后,我把自己的理解整理成这篇复盘。文章会围绕三个问题展开:
- 我为什么觉得项目要这样设计?
- 源码里是怎么实现的?
- 这个设计在项目里具体用在了哪里?
一、我对 ShardingSphere 分库分表的理解
1. 为什么项目需要分库分表
一开始看这个项目时,我的直觉是:12306 这类系统最大的问题应该是"并发高"。但继续看订单、用户相关代码后,我发现它不仅仅是并发问题,还有数据量问题。
这个项目里有几类数据增长会非常快:
- 用户数据:用户名、手机号、邮箱、身份证、地址等。
- 乘车人数据:一个用户可能绑定多个乘车人。
- 订单数据:购票、退票、改签、支付等都会围绕订单展开。
- 订单明细数据:一个订单里可能包含多个乘车人和多张票。
如果这些数据都放在单库单表里,随着数据量增长,单表索引会变大,查询会变慢,单库连接和 IO 也会成为瓶颈。
所以项目使用 ShardingSphere 做分库分表。我的理解是:它把"数据分散到多个库表"这件事放到了基础设施层,业务代码仍然像操作一张逻辑表一样写代码,具体路由到哪个数据库、哪张表,由 ShardingSphere 根据分片规则完成。
2. 源码剖析:订单服务怎么分片
订单服务的分片配置在:
text
services/order-service/src/main/resources/shardingsphere-config.yaml
项目中订单服务配置了两个数据源:
yaml
dataSources:
ds_0:
jdbcUrl: jdbc:mysql://127.0.0.1:3306/12306_order_0
ds_1:
jdbcUrl: jdbc:mysql://127.0.0.1:3306/12306_order_1
订单表的真实节点配置如下:
yaml
t_order:
actualDataNodes: ds_${0..1}.t_order_${0..31}
我理解这行配置的含义是:
text
ds_0.t_order_0 ... ds_0.t_order_31
ds_1.t_order_0 ... ds_1.t_order_31
也就是 2 个库,每个库 32 张订单表,总共 64 张物理表。
订单表使用的是复合分片策略:
yaml
databaseStrategy:
complex:
shardingColumns: user_id,order_sn
shardingAlgorithmName: order_database_complex_mod
tableStrategy:
complex:
shardingColumns: user_id,order_sn
shardingAlgorithmName: order_table_complex_mod
这里我注意到一个设计点:订单表没有只按 order_sn 分片,也没有只按 user_id 分片,而是用了 user_id, order_sn 复合分片。
我的理解是:
- 用户查订单列表时,通常会带
user_id。 - 支付回调、延迟关单、订单详情查询时,可能只拿到
order_sn。 - 复合分片可以兼顾这两类查询路径。
订单服务的分库算法在:
text
services/order-service/src/main/java/org/opengoofy/index12306/biz/orderservice/dao/algorithm/OrderCommonDataBaseComplexAlgorithm.java
核心逻辑简化后是:
java
public Collection<String> doSharding(Collection availableTargetNames,
ComplexKeysShardingValue shardingValue) {
Map<String, Collection<Comparable<?>>> shardingValues =
shardingValue.getColumnNameAndShardingValuesMap();
Collection<Comparable<?>> userIdValues = shardingValues.get("user_id");
if (CollUtil.isNotEmpty(userIdValues)) {
Comparable<?> userId = userIdValues.stream().findFirst().get();
String actualUserId = userId.toString();
String dbSuffix = String.valueOf(
hashShardingValue(actualUserId.substring(Math.max(actualUserId.length() - 6, 0)))
% shardingCount
/ tableShardingCount
);
return List.of("ds_" + dbSuffix);
}
// 如果没有 user_id,就使用 order_sn 做分片
}
它的核心公式可以理解为:
text
dbIndex = hash(分片键最后6位) % 32 / 16
因为 table-sharding-count = 16,所以:
text
分片编号 0 - 15 -> ds_0
分片编号 16 - 31 -> ds_1
分表算法在:
text
services/order-service/src/main/java/org/opengoofy/index12306/biz/orderservice/dao/algorithm/OrderCommonTableComplexAlgorithm.java
核心公式是:
text
tableIndex = hash(分片键最后6位) % 32
举个例子:
text
user_id = 123456789
取最后 6 位:456789
hash(456789) % 32 = 18
数据库:18 / 16 = 1 -> ds_1
表:18 -> t_order_18
最终路由到:ds_1.t_order_18
这个设计让我比较有感触的一点是:分库和分表不是随便取模,而是要围绕业务查询路径设计。订单业务既要支持用户维度查询,也要支持订单号维度查询,所以项目在分片键上做了兼容。
3. 源码剖析:用户服务怎么分片
用户服务的配置在:
text
services/user-service/src/main/resources/shardingsphere-config.yaml
用户服务同样是 2 库 32 表,但不同表的分片键不一样:
| 表 | 分片键 | 我的理解 |
|---|---|---|
t_user |
username |
用户名是注册和登录的核心标识 |
t_passenger |
username |
乘车人归属于某个用户 |
t_user_mail |
mail |
邮箱唯一性校验和邮箱登录 |
t_user_phone |
phone |
手机号唯一性校验和手机号登录 |
比如 t_user 的配置:
yaml
t_user:
actualDataNodes: ds_${0..1}.t_user_${0..31}
databaseStrategy:
standard:
shardingColumn: username
shardingAlgorithmName: user_database_hash_mod
tableStrategy:
standard:
shardingColumn: username
shardingAlgorithmName: user_table_hash_mod
标准分库算法在公共框架里:
text
frameworks/database/src/main/java/org/opengoofy/index12306/framework/starter/database/algorithm/sharding/CustomDbHashModShardingAlgorithm.java
核心代码是:
java
public String doSharding(Collection<String> availableTargetNames,
PreciseShardingValue<Comparable<?>> shardingValue) {
String suffix = String.valueOf(
hashShardingValue(shardingValue.getValue())
% shardingCount
/ tableShardingCount
);
return ShardingAutoTableAlgorithmUtil
.findMatchedTargetName(availableTargetNames, suffix, shardingValue.getDataNodeInfo())
.orElse(null);
}
这个设计让我理解到:用户服务不是只拆 t_user,还把手机号、邮箱单独建表并分片。这样做是为了让手机号登录、邮箱登录、唯一性校验都能精准路由,而不是在主用户表里全表查。
4. 在项目中的应用
在这个项目里,分库分表主要用在订单服务和用户服务。
订单服务里:
text
t_order
t_order_item
t_order_item_passenger
其中:
t_order和t_order_item使用user_id + order_sn复合分片。t_order_item_passenger使用id_card分片。
用户服务里:
text
t_user
t_passenger
t_user_mail
t_user_phone
其中:
- 用户主表按
username分片。 - 乘车人表按
username分片。 - 邮箱索引表按
mail分片。 - 手机号索引表按
phone分片。
我学习后的总结是:分库分表的重点不是"把表拆开"这么简单,而是要先分析业务查询入口。分片键选错了,表拆得再多也可能变成全库路由。
二、我对全局异常拦截器的理解
1. 为什么项目需要全局异常拦截器
在学习这个项目之前,我写接口时也容易在 Controller 或 Service 里直接返回错误结果。比如:
java
if (order == null) {
return Result.fail("订单不存在");
}
但是看完这个项目后,我发现更合理的做法是:业务层只负责判断业务是否成立,不负责组装 HTTP 响应。
也就是说,业务失败时直接抛异常:
java
throw new ServiceException("订单状态异常");
然后由框架层统一捕获,统一返回。
这样做的好处是:
- Controller 不需要到处写
try-catch。 - 所有接口返回格式一致。
- 参数异常、业务异常、系统异常可以分类处理。
- 未知异常不会把堆栈、SQL、服务器路径直接暴露给前端。
2. 源码剖析
全局异常处理器在:
text
frameworks/web/src/main/java/org/opengoofy/index12306/framework/starter/web/GlobalExceptionHandler.java
类上有两个关键注解:
java
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
}
我理解 @RestControllerAdvice 的作用是:
text
全局增强所有 Controller,并且返回值自动序列化为 JSON
2.1 参数校验异常处理
源码中第一类处理的是参数校验异常:
java
@SneakyThrows
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result validExceptionHandler(HttpServletRequest request,
MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult();
FieldError firstFieldError = CollectionUtil.getFirst(bindingResult.getFieldErrors());
String exceptionStr = Optional.ofNullable(firstFieldError)
.map(FieldError::getDefaultMessage)
.orElse(StrUtil.EMPTY);
log.error("[{}] {} [ex] {}", request.getMethod(), getUrl(request), exceptionStr);
return Results.failure(BaseErrorCode.CLIENT_ERROR.code(), exceptionStr);
}
这段代码处理的是 @Valid 或 @Validated 触发的异常。
比如请求 DTO 中有:
java
@NotBlank(message = "用户名不能为空")
private String username;
如果前端没有传 username,Spring 会抛出 MethodArgumentNotValidException。全局异常拦截器会拿到第一个字段错误,然后返回客户端错误。
这里我学到的一个细节是:参数错误属于客户端错误,不应该返回系统异常。
2.2 业务异常处理
业务异常统一继承自 AbstractException。
相关源码目录:
text
frameworks/convention/src/main/java/org/opengoofy/index12306/framework/starter/convention/exception/
里面包括:
text
AbstractException
ClientException
ServiceException
RemoteException
全局异常处理器里对应的代码是:
java
@ExceptionHandler(value = {AbstractException.class})
public Result abstractException(HttpServletRequest request, AbstractException ex) {
if (ex.getCause() != null) {
log.error("[{}] {} [ex] {}",
request.getMethod(),
request.getRequestURL().toString(),
ex.toString(),
ex.getCause());
return Results.failure(ex);
}
log.error("[{}] {} [ex] {}",
request.getMethod(),
request.getRequestURL().toString(),
ex.toString());
return Results.failure(ex);
}
我的理解是:
ClientException更偏向用户请求问题。ServiceException更偏向业务处理失败。RemoteException更偏向远程调用失败。
它们最后都会被转换成统一的 Result。
Results 构造器在:
text
frameworks/web/src/main/java/org/opengoofy/index12306/framework/starter/web/Results.java
java
protected static Result<Void> failure(AbstractException abstractException) {
String errorCode = Optional.ofNullable(abstractException.getErrorCode())
.orElse(BaseErrorCode.SERVICE_ERROR.code());
String errorMessage = Optional.ofNullable(abstractException.getErrorMessage())
.orElse(BaseErrorCode.SERVICE_ERROR.message());
return new Result<Void>()
.setCode(errorCode)
.setMessage(errorMessage);
}
2.3 未知异常兜底
最后一类是所有未知异常:
java
@ExceptionHandler(value = Throwable.class)
public Result defaultErrorHandler(HttpServletRequest request, Throwable throwable) {
log.error("[{}] {} ", request.getMethod(), getUrl(request), throwable);
return Results.failure();
}
这段代码让我意识到:全局异常处理不只是为了"少写 try-catch",还有安全意义。
如果没有兜底处理,空指针、SQL 异常、第三方 SDK 异常可能被直接返回给前端,暴露内部实现。现在的做法是:
text
真实异常 -> 打到服务端日志
前端响应 -> 统一的系统错误
3. 在项目中的应用
订单服务里很多业务判断都直接抛异常。
比如取消订单时:
java
if (orderDO == null) {
throw new ServiceException(OrderCanalErrorCodeEnum.ORDER_CANAL_UNKNOWN_ERROR);
} else if (orderDO.getStatus() != OrderStatusEnum.PENDING_PAYMENT.getStatus()) {
throw new ServiceException(OrderCanalErrorCodeEnum.ORDER_CANAL_STATUS_ERROR);
}
完整链路是:
text
Controller 接收请求
↓
Service 执行业务判断
↓
业务失败,throw ServiceException
↓
GlobalExceptionHandler 捕获 AbstractException
↓
Results.failure(ex) 生成统一响应
↓
前端收到统一 JSON
我学习后的感受是:全局异常拦截器的价值不是某一段代码有多复杂,而是它把项目里的错误处理规范统一了。业务代码只需要关注业务是否成立,异常怎么返回、怎么记录日志,都交给框架层。
三、我对防止敏感数据泄露的理解
1. 为什么项目要做敏感数据保护
12306 项目里有很多敏感字段,例如:
- 身份证号
- 手机号
- 邮箱
- 地址
- 乘车人信息
- 订单乘车信息
学习源码时我发现,项目不是只做了"前端展示脱敏",而是分了两个层面:
text
数据库层:加密存储
接口层:返回脱敏
这两个层面解决的问题不一样。
数据库加密解决的是:如果数据库泄露,攻击者不能直接看到明文身份证号、手机号。
接口脱敏解决的是:接口响应、浏览器、日志、抓包工具里不要出现完整敏感信息。
所以我的理解是:加密保护"存储",脱敏保护"展示",两者不能互相替代。
2. 源码剖析:数据库加密
用户服务加密配置在:
text
services/user-service/src/main/resources/shardingsphere-config.yaml
配置如下:
yaml
- !ENCRYPT
tables:
t_user:
columns:
id_card:
cipherColumn: id_card
encryptorName: common_encryptor
phone:
cipherColumn: phone
encryptorName: common_encryptor
mail:
cipherColumn: mail
encryptorName: common_encryptor
address:
cipherColumn: address
encryptorName: common_encryptor
t_passenger:
columns:
id_card:
cipherColumn: id_card
encryptorName: common_encryptor
phone:
cipherColumn: phone
encryptorName: common_encryptor
queryWithCipherColumn: true
encryptors:
common_encryptor:
type: AES
props:
aes-key-value: d6oadClrrb9A3GWo
订单服务里也对订单明细做了加密:
text
services/order-service/src/main/resources/shardingsphere-config.yaml
yaml
- !ENCRYPT
tables:
t_order_item:
columns:
id_card:
cipherColumn: id_card
encryptorName: common_encryptor
phone:
cipherColumn: phone
encryptorName: common_encryptor
queryWithCipherColumn: true
我的理解是,业务代码仍然写普通 SQL 或 MyBatis-Plus 操作:
sql
INSERT INTO t_user(id_card, phone) VALUES (?, ?)
但 ShardingSphere 会在真正写入数据库之前,把这些字段加密成密文。
查询时,ShardingSphere 又会自动解密,再交给 Java 对象。
也就是说,加密解密对业务代码基本透明。
3. 源码剖析:接口返回脱敏
数据库加密只解决落库问题,但接口返回时不能把完整信息返回给前端。
项目通过 Jackson 的自定义序列化器做脱敏。
手机号脱敏序列化器:
text
services/user-service/src/main/java/org/opengoofy/index12306/biz/userservice/serialize/PhoneDesensitizationSerializer.java
java
public class PhoneDesensitizationSerializer extends JsonSerializer<String> {
@Override
public void serialize(String phone,
JsonGenerator jsonGenerator,
SerializerProvider serializerProvider) throws IOException {
String phoneDesensitization = DesensitizedUtil.mobilePhone(phone);
jsonGenerator.writeString(phoneDesensitization);
}
}
身份证脱敏序列化器:
text
services/user-service/src/main/java/org/opengoofy/index12306/biz/userservice/serialize/IdCardDesensitizationSerializer.java
java
public class IdCardDesensitizationSerializer extends JsonSerializer<String> {
@Override
public void serialize(String idCard,
JsonGenerator jsonGenerator,
SerializerProvider serializerProvider) throws IOException {
String idCardDesensitization = DesensitizedUtil.idCardNum(idCard, 4, 4);
jsonGenerator.writeString(idCardDesensitization);
}
}
这里用的是 Hutool 的 DesensitizedUtil:
- 手机号会变成类似
138****5678 - 身份证会保留前 4 位和后 4 位,中间用
*隐藏
在响应 DTO 上使用 @JsonSerialize:
text
services/user-service/src/main/java/org/opengoofy/index12306/biz/userservice/dto/resp/UserQueryRespDTO.java
java
@JsonSerialize(using = IdCardDesensitizationSerializer.class)
private String idCard;
@JsonSerialize(using = PhoneDesensitizationSerializer.class)
private String phone;
乘车人响应对象也用了同样方式:
text
services/user-service/src/main/java/org/opengoofy/index12306/biz/userservice/dto/resp/PassengerRespDTO.java
订单服务里,订单乘车人详情也对身份证号做了脱敏:
text
services/order-service/src/main/java/org/opengoofy/index12306/biz/orderservice/dto/resp/TicketOrderPassengerDetailRespDTO.java
java
@JsonSerialize(using = IdCardDesensitizationSerializer.class)
private String idCard;
整个返回链路可以理解为:
text
Controller 返回 DTO
↓
Spring MVC 调用 Jackson 转 JSON
↓
Jackson 发现字段上有 @JsonSerialize
↓
调用自定义 JsonSerializer
↓
写出脱敏后的手机号/身份证号
4. 在项目中的应用
4.1 用户信息查询
用户信息查询时,底层数据库里的手机号、身份证、邮箱、地址可能是密文。ShardingSphere 先把密文解开,转换成 Java 对象。
但是响应给前端时,UserQueryRespDTO 上的 @JsonSerialize 会再次脱敏。
所以最终前端看到的是类似:
json
{
"idCard": "1101**********1234",
"phone": "138****5678"
}
4.2 乘车人列表
乘车人列表包含身份证号和手机号,是比较敏感的接口。
项目在 PassengerRespDTO 中对这两个字段做了脱敏,避免页面展示、浏览器缓存、抓包工具拿到完整信息。
4.3 订单详情
订单详情中会包含乘车人信息。项目在 TicketOrderPassengerDetailRespDTO 里对身份证号做脱敏,避免订单详情接口泄露完整证件号。
5. 我看到的改进点
学习完这部分后,我也发现项目里还有一些可以继续优化的地方:
第一,AES 密钥写在配置文件里:
yaml
aes-key-value: d6oadClrrb9A3GWo
生产环境更适合放到配置中心、KMS 或环境变量中。
第二,用户服务和订单服务各自都有一份脱敏序列化器。
更好的方式是把 PhoneDesensitizationSerializer、IdCardDesensitizationSerializer 抽到公共 starter 中,避免重复实现。
第三,邮箱、地址虽然做了数据库加密,但我没有看到所有响应 DTO 都统一做展示脱敏。如果这些字段会返回给前端,也可以补充对应脱敏规则。
四、这三个点放在一起看
这次学习让我感觉比较明显的是:一个项目的难点不只在业务流程,还在工程化能力。
| 学习点 | 解决的问题 | 项目中的实现 |
|---|---|---|
| ShardingSphere 分库分表 | 单库单表数据量和性能瓶颈 | 订单、用户服务按业务键分片 |
| 全局异常拦截器 | 异常返回不统一、内部错误泄露 | @RestControllerAdvice 统一捕获 |
| 敏感数据保护 | 身份证、手机号等隐私泄露 | ShardingSphere AES 加密 + Jackson 脱敏 |
如果让我用一句话总结这三个设计:
分库分表解决的是"数据规模上来后系统还能不能撑住",全局异常处理解决的是"系统出错时能不能可控地返回",敏感数据保护解决的是"数据在存储和展示时会不会泄露"。
五、学习后的几个面试思考
1. 为什么订单表不用单纯按 order_sn 分片?
因为用户查订单是非常高频的场景,通常会带 user_id。如果只按 order_sn 分片,用户订单列表查询可能无法精准路由。项目采用 user_id + order_sn 复合分片,是为了兼顾用户订单查询和订单号查询两类场景。
2. 全局异常拦截器会不会吞掉异常?
不会。它只是把异常转换成统一响应,同时会把真实异常写入服务端日志。前端拿到的是统一错误信息,服务端日志保留完整排查信息。
3. 数据库加密和接口脱敏有什么区别?
数据库加密保护的是存储安全,防止数据库泄露后直接看到明文。接口脱敏保护的是展示安全,防止响应、日志、浏览器缓存里出现完整敏感信息。
4. ShardingSphere Encrypt 会不会影响查询?
会有一定影响。精确查询可以通过密文字段匹配,但模糊查询不适合直接走加密字段。项目配置了 queryWithCipherColumn: true,表示查询时使用密文字段。
5. 这三个点对我最大的启发是什么?
我的最大收获是:工程化设计不是为了"炫技术",而是为了解决真实问题。
- 分库分表是为了解决数据规模问题。
- 全局异常处理是为了统一错误出口。
- 敏感数据保护是为了降低数据泄露风险。
这些设计放在业务系统里,才真正体现出价值。