12305项目学习day5

学习 12306 项目后的三个工程化收获:分库分表、异常处理与敏感数据保护

最近在学习 12306 分布式票务项目时,我重点看了三个比较有代表性的工程化设计:

  1. ShardingSphere 分库分表
  2. 全局异常拦截器
  3. 防止敏感数据泄露

这三个点不属于某一个具体业务功能,但它们对项目的稳定性、可维护性和安全性影响很大。学习完之后,我把自己的理解整理成这篇复盘。文章会围绕三个问题展开:

  • 我为什么觉得项目要这样设计?
  • 源码里是怎么实现的?
  • 这个设计在项目里具体用在了哪里?

一、我对 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_ordert_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 或环境变量中。

第二,用户服务和订单服务各自都有一份脱敏序列化器。

更好的方式是把 PhoneDesensitizationSerializerIdCardDesensitizationSerializer 抽到公共 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. 这三个点对我最大的启发是什么?

我的最大收获是:工程化设计不是为了"炫技术",而是为了解决真实问题。

  • 分库分表是为了解决数据规模问题。
  • 全局异常处理是为了统一错误出口。
  • 敏感数据保护是为了降低数据泄露风险。

这些设计放在业务系统里,才真正体现出价值。

相关推荐
::呵呵哒::1 小时前
java中SseEmitter
java·开发语言
lichuangcsdn1 小时前
【Spring AI 学习(三)】实现简单的对话
java·人工智能·学习·spring·spring ai
智码看视界1 小时前
Day33-数据层 × 中间件AI化篇:Redis缓存经典问题-击穿、穿透、雪崩的终极解决方案
数据库·redis·缓存·中间件·穿透·雪崩·击穿
长不胖的路人甲1 小时前
Spring Bean 完整生命周期
java·spring·rpc
会编程的土豆2 小时前
功能详解 01 · 用户注册:从表单到数据库一行
开发语言·数据库·后端·mysql·golang
青石路2 小时前
记一次Qoder排查mave-shade-plugin只重定向部分package导致的NoSuchMethodError问题,查的还挺快的
java·maven
墨神谕2 小时前
认识一下Unicode(统一码)
android·java·数据库
宸津-代码粉碎机2 小时前
Jar热部署进阶实战|修复原生方案OOM与类冲突问题,生产级无BUG优化方案
java·大数据·服务器·开发语言·前端·人工智能·python
qq_1851986913 小时前
(Spring Bean + delegateExpression)实现http服务 的完整可运行项目
spring