牛券oneCoupon系统 第②章节:后台管理服务-上

eve2333 发布于 4 小时前 5 次阅读


 ▪第05小节:从零到一创建SpringBoot项目&初始化通用配置

《牛券oneCoupon优惠系统视频教学》第05小节

  • 从零到一创建SpringBoot项目&初始化通用配置
  • 20240708_init-code_ding.ma

《牛券oneCoupon优惠系统视频教学》第06小节

  • 基于责任链模式创建优惠券模板
  • 2024xxxxx_dev_create-template_chain_ding.ma

《牛券oneCoupon优惠系统视频教学》第07小节

  • 通过ShardingSphere完成优惠券分库分表
  • 2024xxxx_dev_coupon-tablue_shardingsphere_ding.ma

《牛券oneCoupon优惠系统视频教学》第08小节

  • 引入日志组件优雅记录操作日志
  • 2024xxxx_dev_operation-log_mzt-biz-log_ding.ma

《牛券oneCoupon优惠系统视频教学》第09小节

  • 基于注解实现分布式锁防重复提交
  • 2024xxxxx_dev_repeat-submit_lock-annotation_ding.ma

​编辑

初始化通用配置

如果大家对于多 Modules 的项目创建不太熟悉,可以查看以下视频学习:从零到一创建基础项目;从零到一创建子模块项目

1. 异常码

异常码设计
阿里巴巴泰山版java开发手册@异常日志-错误码章节

2. 全局统一返回类

  • 全局统一返回类 Result 讲解

3. 全局异常拦截器

  • 全局异常拦截器 GlobalExceptionHandler 讲解

4. SpringBoot Starter

SpringBoot Starter 是 SpringBoot 提供的一种简化配置和依赖管理的机制。它是预配置的依赖集合,旨在帮助开发者快速集成常用的库和功能,从而加速开发过程。

Starter 是帮助我们提供了一种简化配置和依赖管理的工具,或者说它能够帮助我们快速开发某些内部功能,对吧?进而帮我们提高代码开发的效率。这是它的一个定义,如果不理解不要紧,我们继续往下看。

它的优点是什么?优点就是:

它可以帮助我们做一些自动配置的事情。也就是说,我们在没有使用 Starter 之前,如果想要定义一个 Spring Bean,就需要手动去创建,然后进行定义。如果说我们封装了一些内部组件,比如你像 XXX 组件这种,你想引用它,引入之后还要对它做 Bean 的配置。我给大家看一下代码。

其实这种 Bean 的配置对我们来说,从使用角度来讲是完全没有必要的。为什么没有必要?是因为 Spring Boot 提供了一种自动装配的机制。也就是说,像这样的 Bean,我们可以在 Starter 里面去指定创建,然后当我们把依赖引入进来之后,Spring Boot 在启动过程中会自动把 Bean 扫描并注册到容器中。

比如说我们这里的“全局异常处理器”,有同学可能会比较疑问:为什么我们这里要去手动创建?很多同学可能有这样的疑问:我们难道不能直接加一个注解吗?比如 @Component?加注解可不可以?加注解不能说完全不行,只能说不推荐。为什么?我给大家再解释一下。

首先,Spring 扫描的包路径范围大家应该都知道,在 Spring 的基础课程里讲过:它默认只会扫描启动类所在包及其子包下的类。如果你的类不在这个路径下面,你就需要显式地指定扫描路径,对吧?

你可以通过 @ComponentScan 去配置,比如在这里加上要扫描的外部包路径,它是可以实现的。把外部包加进去,这样是可以的。但是大家有没有想过:如果我所有的基础组件都不按这种方式来写,而是全都依赖包扫描的方式,那你要写多少配置?

我给大家简单列举一下:你要写消息队列的配置,要写接口依赖的配置,要写 RPC 的配置,要写缓存的配置,还要写各种工具类的配置……你的配置会非常冗杂。

在这种情况下,基于依赖最小化管理的原则,我们为什么不在 Starter 里面就把这些 Bean 创建好?直接把它们注入到 Spring 的 IOC 容器里面。这样依赖方就不再需要做任何额外操作,只需要引入这个 Starter 的 jar 包就可以了,对不对?这样的话肯定是最简单的。

然后第二个优点就是:它简化了依赖配置。以前如果我们想引入外部服务,比如我们的服务想要对外访问,就需要手动引入很多依赖包。比如你想引入 HTTP 客户端,就得引入 OkHttp 或 HttpClient;你想引入 Spring Data JPA,也需要引入一堆相关依赖;再比如一个大家比较熟悉的场景:你想引入 Redis,是不是得先引入连接池,再引入 Redis 客户端,再加上各种工具包?

而 Spring Boot 通过 Starter 的方式,帮我们把这些基础的依赖都封装好了。我们只需要引入一个 starter,比如 spring-boot-starter-data-redis,它就会自动引入所有必要的依赖,不需要我们一个个去管理。我给大家简单看一眼。

像我们这边提供的 spring-boot-starter-test,都是官方提供的标准 Starter。你比如说大家常见的 spring-boot-starter-webspring-boot-starter-jdbc 等等,也都是一样的机制。这样的话,相信大家对 Starter 就有一个基本的认识了。

接下来我们聊一下:如何去定义一个自己的 Starter?其实很简单,只需要 5 步。

第一步:创建一个项目。
第二步:编写对应的业务代码,比如我们刚才提到的“全局异常处理器”或“统一日志切面”这类组件。
第三步:定义一个配置类(Configuration Class),它本质上是提供了一个 Bean 的容器,让这些 Bean 能够被 IOC 容器注册和管理。

然后我们需要把这个配置类交给 Spring Boot 的自动装配机制,让它能够被发现。也就是说,在 resources/META-INF 路径下创建一个特殊的配置文件,文件名是固定的:spring.factories

这个格式是固定的。只要我们想让某个配置类在项目启动时被自动加载,就必须在这个文件里声明。例如:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\

com.example.starter.MyAutoConfiguration

以“外部组件”为例,如果你有多个自动配置类,可以换行写多个。我们点进去这个配置文件,其实就是我们自己写的配置类的全限定名。

然后通过 @Bean 注解,把这些组件注册到 Spring 容器中。这样的话,只要我们的业务项目(比如“贷款系统”)引入了这个 Starter 包,它在启动的时候就会自动加载这个配置类,完成自动装配。@Bean 的注解也就生效了,这些 Bean 会自动注册到上下文中,不需要我们再做任何额外配置。

不知道这么跟大家讲,大家能不能听清楚?如果有问题,我们可以再沟通讨论一下。

然后有一个小知识点:Spring Boot 2.x 和 3.x 在自动装配的实现方式上有一些区别。

我们刚才展示的是 Spring Boot 3.x 的方式,也就是通过 spring.factories 文件来实现自动装配。但在 2.x 版本中,它其实是 META-INF/spring.factories 文件直接定义自动配置类。

而在 3.x 中,Spring Boot 引入了新的机制:META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件。这个文件里只需要写配置类的全限定名,一行一个,不再使用 spring.factories 的键值对格式。

这个变化我也在网上查过,其实很多资料讲得不太清楚。我结合 GPT 和官方文档做了一些了解,总结出两个主要变化:

第一个是启动速度更快。虽然我自己没实测出来,但官方文档和社区普遍这么说。因为新的机制减少了反射扫描和类加载的开销。

第二个是模块划分更清晰。这一点是可以明显看出来的。以前在 spring.factories 文件里,除了自动配置,还可以配置其他内容,比如初始化器、监听器等,所有东西都混在一起。而现在,imports 文件只负责自动配置,职责更单一,结构更清晰。

我们看一下代码吧,首次提交给到创建了search和settlement,distribution;framework这4个功能区,还有format,还有database放到的sql文件;调整IDEA项目图标和提交代码版权信息和其他变更没什么用啊;

初始化牛券商家后管模块merchant-admin,对启动项等来修改获得;这是总xml文件

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.nageoffer.onecoupon</groupId>
    <artifactId>onecoupon-all</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>pom</packaging>

    <description>
        🔥热门推荐🔥牛券系统,春招、秋招、应届、社招项目。SpringBoot3+Java17+SpringCloudAlibaba+RocketMQ+ElasticSearch等技术架构,完成优惠券秒杀+分发+结算+搜索等服务,帮助学生主打就业的项目。
    </description>

    <developers>
        <developer>
            <name>马丁</name>
            <email>machen@apache.org</email>
            <url>https://github.com/magestacks</url>
            <organization>Apache and openGoofy</organization>
        </developer>
    </developers>

    <modules>
        <!-- 基础架构模块:仅包含公共内容,不涉及SpringBean定义 -->
        <module>framework</module>
        <!-- 分发模块:负责按批次分发用户优惠券,可提供应用弹框推送、站内信或短信通知等 -->
        <module>distribution</module>
        <!-- 结算模块:负责用户下单时订单金额计算功能 -->
        <module>settlement</module>
        <!-- 搜索模块:提供用户优惠券搜索功能 -->
        <module>search</module>
        <!-- 引擎模块:负责优惠券单个查看、列表查看、锁定以及核销等功能 -->
        <module>engine</module>
        <!-- 为何其他模块仅一个单词,而后管是两个? -->
        <!-- 这是因为在企业的实际运营中,并非仅有单一的优惠券管理后台,而是普遍存在统称为“商家后台”的多功能管理平台 -->
        <module>merchant-admin</module> <!-- 后管模块:创建优惠券、店家查看以及管理优惠券、创建优惠券发放批次 -->
    </modules>

    <properties>
        <java.version>17</java.version>
        <spring-boot.version>3.0.7</spring-boot.version>
        <spring-cloud.version>2022.0.3</spring-cloud.version>
        <spring-cloud-alibaba.version>2022.0.0.0-RC2</spring-cloud-alibaba.version>
        <mybatis-spring-boot-starter.version>3.0.2</mybatis-spring-boot-starter.version>
        <shardingsphere.version>5.3.2</shardingsphere.version>
        <fastjson2.version>2.0.36</fastjson2.version>
        <mybatis-plus.version>3.5.3.1</mybatis-plus.version>
        <dozer-core.version>6.5.2</dozer-core.version>
        <hutool-all.version>5.8.27</hutool-all.version>
        <redisson.version>3.27.2</redisson.version>
        <guava.version>30.0-jre</guava.version>
        <jsoup.version>1.15.3</jsoup.version>
        <easyexcel.version>3.1.3</easyexcel.version>
        <spotless-maven-plugin.version>2.22.1</spotless-maven-plugin.version>
        <maven-compiler-plugin.version>3.6.1</maven-compiler-plugin.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
                <version>${mybatis-plus.version}</version>
            </dependency>

            <dependency>
                <groupId>org.apache.shardingsphere</groupId>
                <artifactId>shardingsphere-jdbc-core</artifactId>
                <version>${shardingsphere.version}</version>
            </dependency>

            <dependency>
                <groupId>com.alibaba.fastjson2</groupId>
                <artifactId>fastjson2</artifactId>
                <version>${fastjson2.version}</version>
            </dependency>

            <dependency>
                <groupId>com.github.dozermapper</groupId>
                <artifactId>dozer-core</artifactId>
                <version>${dozer-core.version}</version>
            </dependency>

            <dependency>
                <groupId>cn.hutool</groupId>
                <artifactId>hutool-all</artifactId>
                <version>${hutool-all.version}</version>
            </dependency>

            <dependency>
                <groupId>org.redisson</groupId>
                <artifactId>redisson-spring-boot-starter</artifactId>
                <version>${redisson.version}</version>
            </dependency>

            <dependency>
                <groupId>com.google.guava</groupId>
                <artifactId>guava</artifactId>
                <version>${guava.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>${maven-compiler-plugin.version}</version>
                <configuration>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                </configuration>
            </plugin>

            <plugin>
                <groupId>com.diffplug.spotless</groupId>
                <artifactId>spotless-maven-plugin</artifactId>
                <version>${spotless-maven-plugin.version}</version>
                <configuration>
                    <java>
                        <!--<eclipse>
                            <file>${maven.multiModuleProjectDirectory}/format/one-coupon_spotless_formatter.xml</file>
                        </eclipse>-->
                        <licenseHeader>
                            <!-- ${maven.multiModuleProjectDirectory} 爆红属于正常,并不影响编译或者运行,忽略就好 -->
                            <file>${maven.multiModuleProjectDirectory}/copyright/copyright.txt</file>
                        </licenseHeader>
                    </java>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>apply</goal>
                        </goals>
                        <phase>compile</phase>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

初始化牛券引擎系统engine,负责券锁定、核销、查看等功能;端口号给到10010

▪第06小节:基于责任链模式创建优惠券模板

  • 优惠券模板创建业务
  • 优惠券模板解析
  • Git 分支名称
  • 前置逻辑
  • 代码执行逻辑

优惠券模板创建业务

优惠券分为平台券和店铺券,平台券由平台运营人员创建和管理员审批,关系隶属于平台,用户使用后成本一般来说由平台和商家共同承担;而店铺券由商家在商家后台直接创建,无需审核,成本由店家独自承担。

优惠券模板解析

创建数据库 one_coupon_rebuild 数据库。

CREATE DATABASE IF NOT EXISTS one_coupon_rebuild;

1. 数据库表

可以进入到我们的 one_coupon_rebuild 数据库中执行下述 SQL 语句。

CREATE TABLE `t_coupon_template` (    `id`               bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `name`             varchar(256) DEFAULT NULL COMMENT '优惠券名称',    `shop_number`      bigint(20) DEFAULT NULL COMMENT '店铺编号',    `source`           tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券',    `target`           tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用',    `goods`            varchar(64)  DEFAULT NULL COMMENT '优惠商品编码',    `type`             tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券',    `valid_start_time` datetime     DEFAULT NULL COMMENT '有效期开始时间',    `valid_end_time`   datetime     DEFAULT NULL COMMENT '有效期结束时间',    `stock`            int(11) DEFAULT NULL COMMENT '库存',    `receive_rule`     json         DEFAULT NULL COMMENT '领取规则',    `consume_rule`     json         DEFAULT NULL COMMENT '消耗规则',    `status`           tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束',    `create_time`      datetime     DEFAULT NULL COMMENT '创建时间',    `update_time`      datetime     DEFAULT NULL COMMENT '修改时间',    `del_flag`         tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',    PRIMARY KEY (`id`),    KEY                `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967816300515330 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表';

因为领取规则和消耗规则多变,所以使用 JSON 存储。后续如果有变化不需要改动表字段,改动 JSON 内容即可。

1. 领取规则

JSONObject receiveRule = new JSONObject(); receiveRule.put("limitPerPerson", 1); // 每人限领 receiveRule.put("usageInstructions", "xxx"); // 使用说明

2. 消耗规则

JSONObject consumeRule = new JSONObject(); consumeRule.put("termsOfUse", new BigDecimal("10")); // 使用条件 满 x 元可用 consumeRule.put("maximumDiscountAmount", new BigDecimal("3")); // 最大优惠金额 consumeRule.put("explanationOfUnmetC 3onditions", "xxx"); // 不满足使用条件说明 consumeRule.put("validityPeriod", 48); // 自领取优惠券后有效时间,单位小时

Git 分支名称

20240814_dev_create-template_chain_ding.ma

前置逻辑

1. 使用接口 API 工具

1.1 Maven 依赖引入

<dependency>    <groupId>com.github.xiaoymin</groupId>    <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>    <version>4.5.0</version> </dependency>

1.2 application.yaml 添加配置


server:
  port: 10010

spring:
  application:
    name: oneCoupon-merchant-admin
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/one_coupon_rebuild?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
    username: root
    password: root
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password: 123456

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

springdoc:
  default-flat-param-object: true
  swagger-ui:
    path: /swagger-ui.html
    tags-sorter: alpha
    operations-sorter: alpha
  api-docs:
    path: /v3/api-docs
  group-configs:
    - group: 'default'
      paths-to-match: '/**'
      packages-to-scan: com.nageoffer.onecoupon

knife4j:
  enable: true
  setting:
    language: zh_cn

1.3 添加配置文件

主要是config部分的内容,还有UserConfiguration和DataBaseConfiguration的内容部分,


@Slf4j
@Configuration
public class SwaggerConfiguration implements ApplicationRunner {

    @Value("${server.port:8080}")
    private String serverPort;
    @Value("${server.servlet.context-path:}")
    private String contextPath;

    /**
     * 自定义 openAPI 个性化信息
     */
    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
                .info(new Info() // 基本信息配置
                        .title("牛券-商家后台管理系统") // 标题
                        .description("创建优惠券、店家查看以及管理优惠券、创建优惠券发放批次等") // 描述 Api 接口文档的基本信息
                        .version("v1.0.0") // 版本
                        // 设置 OpenAPI 文档的联系信息,包括联系人姓名为"ding.ma",邮箱为"machen@apache.org"
                        .contact(new Contact().name("ding.ma").email("machen@apache.org"))
                        // 设置 OpenAPI 文档的许可证信息,包括许可证名称和许可证URL
                        .license(new License().name("山东流年网络科技有限公司").url("https://gitcode.net/nageoffer/onecoupon/-/blob/main/LICENSE"))
                );
    }

    /**
     * 方便大家启动项目后可以直接点击链接跳转,而不用自己到浏览器输入路径
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {
        log.info("API Document: http://127.0.0.1:{}{}/doc.html", serverPort, contextPath);
    }
}

1.4 使用效果

项目启动后会在控制台打印访问地址。

2024-08-14T16:21:26.620+08:00  INFO 89222 --- [           main] c.n.o.m.a.config.SwaggerConfiguration   : API Document: http://127.0.0.1:10010/doc.html

点击后跳转浏览器访问。

2. 持久化配置

主要两点内容,分页插件和源数据自动填充类。


@Configuration
public class DataBaseConfiguration {

    /**
     * MyBatis-Plus MySQL 分页插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

    /**
     * MyBatis-Plus 源数据自动填充类
     */
    @Bean
    public MyMetaObjectHandler myMetaObjectHandler() {
        return new MyMetaObjectHandler();
    }

    /**
     * MyBatis-Plus 源数据自动填充类
     */
    static class MyMetaObjectHandler implements MetaObjectHandler {

        @Override
        public void insertFill(MetaObject metaObject) {
            strictInsertFill(metaObject, "createTime", Date::new, Date.class);
            strictInsertFill(metaObject, "updateTime", Date::new, Date.class);
            strictInsertFill(metaObject, "delFlag", () -> 0, Integer.class);
        }

        @Override
        public void updateFill(MetaObject metaObject) {
            strictInsertFill(metaObject, "updateTime", Date::new, Date.class);
        }
    }
}

3. 用户上下文

因为我们聚焦优惠券逻辑代码,所以关于用户登录、注册等逻辑忽略,仅通过硬编码形式保障优惠券业务不受影响。

public final class MerchantAdminRedisConstant {

    /**
     * 优惠券模板缓存 Key
     */
    public static final String COUPON_TEMPLATE_KEY = "one-coupon_engine:template:%s";
}

3.1 用户实体

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserInfoDTO {

    /**
     * 用户 ID
     */
    private String userId;

    /**
     * 用户名
     */
    private String username;

    /**
     * 店铺编号
     */
    private Long shopNumber;
}

3.2 用户存储上下文

public final class UserContext {

    /**
     * <a href="https://github.com/alibaba/transmittable-thread-local" />
     */
    private static final ThreadLocal<UserInfoDTO> USER_THREAD_LOCAL = new TransmittableThreadLocal<>();

    /**
     * 设置用户至上下文
     *
     * @param user 用户详情信息
     */
    public static void setUser(UserInfoDTO user) {
        USER_THREAD_LOCAL.set(user);
    }

    /**
     * 获取上下文中用户 ID
     *
     * @return 用户 ID
     */
    public static String getUserId() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getUserId).orElse(null);
    }

    /**
     * 获取上下文中用户名称
     *
     * @return 用户名称
     */
    public static String getUsername() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getUsername).orElse(null);
    }

    /**
     * 获取上下文中用户店铺编号
     *
     * @return 用户店铺编号
     */
    public static Long getShopNumber() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getShopNumber).orElse(null);
    }

    /**
     * 清理用户上下文
     */
    public static void removeUser() {
        USER_THREAD_LOCAL.remove();
    }
}

3.3 用户配置拦截器

@Configuration
public class UserConfiguration implements WebMvcConfigurer {

    /**
     * 用户信息传输拦截器
     */
    @Bean
    public UserTransmitInterceptor userTransmitInterceptor() {
        return new UserTransmitInterceptor();
    }

    /**
     * 添加用户信息传递过滤器至相关路径拦截
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(userTransmitInterceptor())
                .addPathPatterns("/**");
    }

    /**
     * 用户信息传输拦截器
     * 开发时间:2024-07-09
     */
    static class UserTransmitInterceptor implements HandlerInterceptor {

        @Override
        public boolean preHandle(@Nullable HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Object handler) throws Exception {
            // 用户属于非核心功能,这里先通过模拟的形式代替。后续如果需要后管展示,会重构该代码
            UserInfoDTO userInfoDTO = new UserInfoDTO("1810518709471555585", "pdd45305558318", 1810714735922956666L);
            UserContext.setUser(userInfoDTO);
            return true;
        }

        @Override
        public void afterCompletion(@Nullable HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Object handler, Exception exception) throws Exception {
            UserContext.removeUser();
        }
    }
}

3.4 使用用户上下文

如果想在代码中使用用户上下文,直接调用即可。

UserContext.getShopNumber()

代码执行逻辑

1. 时序图

controller里面CouponTemplateController:他从控制层开始往下去看,第一步的话就是验证我们的参数,比如说一些是否为空或者工作字符串,如果我们这边要求必填的话是不允许的,然后就是我们的关系是否正确,比如说我们如果说设置了优惠券全店可以使用的话,我们的商品编码字段就不能传值。反之如果说我们鉴定它的一个优惠券只能指定商品可用,它的商品编码字就不能为空,大概是这样的一个逻辑。然后还有一些的话它的库存是否设置异常,以及我们相关的一个数据是否正确,比如说我们传入的商品的一些信息,商品信息是否存在等等,上面是我们的一个验证逻辑,只是举了一小部分的例子,当然还会有更多。然后的话我们要将优惠券新增的数据库第一步要构建我们的一个19层实体,然后给他新增完了之后,我们进行预热,逻辑大概就是这个样子。然后我们预热这里的话就是使用 spring boot提供的一个组件,一个通用操作release的模板,大家知道就可以。然后这里比较有意思的一点是什么?就是我们关于对象转换这里可能有很多同学有疑问,为什么我们要用这一个去进行转换呢?我们给大家梳理一下,首先我们为什么要转换?就是用户通过入餐也就是request的dto,我们负责进行对它验证,然后没问题,我们要转成19层的对象。

一般对于这种数据对象转换的话,我们有几种形式,最基础的也就是我们的 set,通过这种形式的话,无疑对我们的代码工作量上的话有一个比较重的负担。其次如果说我们后续要新增字段或者改动字段对应的 set,代码也需要对应的调整,我们可以通过一个案例非常清楚的知道这个代码是什么样子的,然后它的优点就是性能是最高的,且不需要依赖其他的组件,缺点也就是代码比较繁琐,后期改动容易有遗漏。 

接下来是一些喜闻乐见的东西:约定common/enums的判定

@RequiredArgsConstructor
public enum CouponTemplateStatusEnum {

    /**
     * 0: 表示优惠券处于生效中的状态。
     */
    ACTIVE(0),

    /**
     * 1: 表示优惠券已经结束,不可再使用。
     */
    ENDED(1);

    @Getter
    private final int status;
}

@RequiredArgsConstructor
public enum DiscountTargetEnum {

    /**
     * 商品专属优惠
     */
    PRODUCT_SPECIFIC(0, "商品专属优惠"),
    /**
     * 全店通用优惠
     */
    ALL_STORE_GENERAL(1, "全店通用优惠");

    @Getter
    private final int type;

    @Getter
    private final String value;

    /**
     * 根据 type 找到对应的 value
     *
     * @param type 要查找的类型代码
     * @return 对应的描述值,如果没有找到抛异常
     */
    public static String findValueByType(int type) {
        for (DiscountTargetEnum target : DiscountTargetEnum.values()) {
            if (target.getType() == type) {
                return target.getValue();
            }
        }
        throw new IllegalArgumentException();
    }
}

@RequiredArgsConstructor
public enum DiscountTypeEnum {

    /**
     * 立减券
     */
    FIXED_DISCOUNT(0, "立减券"),

    /**
     * 满减券
     */
    THRESHOLD_DISCOUNT(1, "满减券"),

    /**
     * 折扣券
     */
    DISCOUNT_COUPON(2, "折扣券");

    @Getter
    private final int type;

    @Getter
    private final String value;

    /**
     * 根据 type 找到对应的 value
     *
     * @param type 要查找的类型代码
     * @return 对应的描述值,如果没有找到抛异常
     */
    public static String findValueByType(int type) {
        for (DiscountTypeEnum target : DiscountTypeEnum.values()) {
            if (target.getType() == type) {
                return target.getValue();
            }
        }
        throw new IllegalArgumentException();
    }
}

2. 对象复制

新增优惠券模板信息到数据库时,需要将入参 reqDTO 转换为最终的 DO 持久层实体。一般对于对象转换,我们有以下几种方式。

2.1 get/set

如果每次都进行 get/set 的形式进行转换,无疑会在代码上有较大的工作量。而且,如果后续改动或新增字段还需要进行对应调整。

使用案例举例:

public PersonDTO personToPersonDTO(Person person) {    if (person == null) {        return null;   } ​    PersonDTO personDTO = new PersonDTO();    personDTO.setName(person.getName());    personDTO.setAge(person.getAge()); ​    return personDTO; }

  • 优点:性能最高,且无需依赖额外组件。
  • 缺点:代码繁琐,后期改动容易有遗漏。

2.2 MapStruct

官方网址:https://mapstruct.org

使用案例举例:

// 定义转换映射器 @Mapper public interface PersonMapper {    PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class); ​    PersonDTO personToPersonDTO(Person person); } ​ // 底层在编译期生成对应实现类并通过 get/set 方式复制 public class PersonMapperImpl implements PersonMapper { ​    @Override    public PersonDTO personToPersonDTO(Person person) {        if (person == null) {            return null;       } ​        PersonDTO personDTO = new PersonDTO();        personDTO.setName(person.getName());        personDTO.setAge(person.getAge()); ​        return personDTO;   } } ​ // 业务代码使用 PersonMapper.INSTANCE.personToPersonDTO(person);

总结如下:

  • 优点:性能等同 get/set 且无序配置繁琐代码。
  • 缺点:代码配置虽没有 get/set 繁琐,但是也需要创建类等配置。

2.3 Hutool or Dozer

Dozer 官方网址:https://github.com/DozerMapper/dozer

Hutool 官方网址:https://hutool.cn

使用案例举例:

CouponTemplateDO couponTemplateDO = BeanUtil.toBean(requestParam, CouponTemplateDO.class);

总结如下:

  • 优点:使用最为方便,一行代码填入入参和指定转换对象 class 即可完成转换。
  • 缺点:使用反射,对比 get/set 性能较差。

大家可以看到我们这里的话其实验证了比较多的逻辑,其实真实的一个项目里面它验证的只会比这更多,我初步估计一个500行的代码它是验证不了这些逻辑的,所以说我们怎么能够把这些代码给合理的进行优化。这里就不得不先说一下它的问题,我们看一下它都有哪些问题,首先第一个就是职责单一问题,我们如果细分的话,其实刚才的那些验证逻辑我们分为了三块,第一块的话是验证是否非空或者说空的字符串,这个是我们比较简单的一个验证逻辑。

 第二块的话就是我们需要去验证它对应的一个数据是否符合基本的一些逻辑,比如说它每个字段之间的依赖关系是否符合。第三个的话是我们需要依赖它的这些数据对不对?比如说你给我传的商品编码是否正确等等,这样的话我们是不是可以把这三块给单独拆三个处理器,这样的话不就职责单一了,对不对?而且后续改的话只需要改对应的处理器就可以了。然后另外的话,如果我们要新增,我们只需要添加新的处理器,而不需要修改现有代码,这也就是我们大家常说的高内聚低耦合以及我们的一个开辟原则。最后的话也是蛮重要的,就是对于看代码的人来说,它是流程比较清晰的,一眼就知道我这边会有三个逻辑,对于他理解业务会有一定的帮助作用。

我们可以想一下用责任链模式是不是能解决上面的这些问题?

对于dao/entity/CouponTemplateDO.java
dao/mapper/CouponTemplateMapper.java
dto/req/CouponTemplateSaveReqDTO.java
dto/resp/CouponTemplateQueryRespDTO.java
就暂时不要说了,

public interface CouponTemplateService extends IService<CouponTemplateDO> {

    /**
     * 创建商家优惠券模板
     *
     * @param requestParam 请求参数
     */
    void createCouponTemplate(CouponTemplateSaveReqDTO requestParam);
}
@Service
@RequiredArgsConstructor
public class CouponTemplateServiceImpl extends ServiceImpl<CouponTemplateMapper, CouponTemplateDO> implements CouponTemplateService {

    private final CouponTemplateMapper couponTemplateMapper;
    private final StringRedisTemplate stringRedisTemplate;

    private final int maxStock = 20000000;

    @Override
    public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {
        // 验证必填参数是否为空或空的字符串
        if (StrUtil.isEmpty(requestParam.getName())) {
            throw new ClientException("优惠券名称不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getSource())) {
            throw new ClientException("优惠券来源不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getTarget())) {
            throw new ClientException("优惠对象不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getType())) {
            throw new ClientException("优惠类型不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getValidStartTime())) {
            throw new ClientException("有效期开始时间不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getValidEndTime())) {
            throw new ClientException("有效期结束时间不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getStock())) {
            throw new ClientException("库存不能为空");
        }

        if (StrUtil.isEmpty(requestParam.getReceiveRule())) {
            throw new ClientException("领取规则不能为空");
        }

        if (StrUtil.isEmpty(requestParam.getConsumeRule())) {
            throw new ClientException("消耗规则不能为空");
        }

        // 验证参数基本数据关系是否正确
        boolean targetAnyMatch = Arrays.stream(DiscountTargetEnum.values())
                .anyMatch(enumConstant -> enumConstant.getType() == requestParam.getTarget());
        if (!targetAnyMatch) {
            // 此处已经基本能判断数据请求属于恶意攻击,可以上报风控中心进行封禁账号
            throw new ClientException("优惠对象值不存在");
        }
        if (ObjectUtil.equal(requestParam.getTarget(), DiscountTargetEnum.ALL_STORE_GENERAL)
                && StrUtil.isNotEmpty(requestParam.getGoods())) {
            throw new ClientException("优惠券全店通用不可设置指定商品");
        }
        if (ObjectUtil.equal(requestParam.getTarget(), DiscountTargetEnum.PRODUCT_SPECIFIC)
                && StrUtil.isEmpty(requestParam.getGoods())) {
            throw new ClientException("优惠券商品专属未设置指定商品");
        }

        boolean typeAnyMatch = Arrays.stream(DiscountTypeEnum.values())
                .anyMatch(enumConstant -> enumConstant.getType() == requestParam.getType());
        if (!typeAnyMatch) {
            // 此处已经基本能判断数据请求属于恶意攻击,可以上报风控中心进行封禁账号
            throw new ClientException("优惠类型不存在");
        }

        Date now = new Date();
        if (requestParam.getValidStartTime().before(now)) {
            // 为了方便大家测试,不用关注这个时间,这里取消异常抛出
            // throw new ClientException("有效期开始时间不能早于当前时间");
        }

        if (requestParam.getStock() <= 0 || requestParam.getStock() > maxStock) {
            // 此处已经基本能判断数据请求属于恶意攻击,可以上报风控中心进行封禁账号
            throw new ClientException("库存数量设置异常");
        }

        if (!JSON.isValid(requestParam.getReceiveRule())) {
            // 此处已经基本能判断数据请求属于恶意攻击,可以上报风控中心进行封禁账号
            throw new ClientException("领取规则格式错误");
        }
        if (!JSON.isValid(requestParam.getConsumeRule())) {
            // 此处已经基本能判断数据请求属于恶意攻击,可以上报风控中心进行封禁账号
            throw new ClientException("消耗规则格式错误");
        }

        // 验证参数数据是否正确
        if (ObjectUtil.equal(requestParam.getTarget(), DiscountTargetEnum.PRODUCT_SPECIFIC)) {
            // 调用商品中台验证商品是否存在,如果不存在抛出异常
            // ......
        }

        // 新增优惠券模板信息到数据库
        CouponTemplateDO couponTemplateDO = BeanUtil.toBean(requestParam, CouponTemplateDO.class);
        couponTemplateDO.setStatus(CouponTemplateStatusEnum.ACTIVE.getStatus());
        couponTemplateDO.setShopNumber(UserContext.getShopNumber());
        couponTemplateMapper.insert(couponTemplateDO);

        // 缓存预热:通过将数据库的记录序列化成 JSON 字符串放入 Redis 缓存
        CouponTemplateQueryRespDTO actualRespDTO = BeanUtil.toBean(couponTemplateDO, CouponTemplateQueryRespDTO.class);
        Map<String, Object> cacheTargetMap = BeanUtil.beanToMap(actualRespDTO, false, true);
        Map<String, String> actualCacheTargetMap = cacheTargetMap.entrySet().stream()
                .collect(Collectors.toMap(
                        Map.Entry::getKey,
                        entry -> entry.getValue() != null ? entry.getValue().toString() : ""
                ));
        String couponTemplateCacheKey = String.format(MerchantAdminRedisConstant.COUPON_TEMPLATE_KEY, couponTemplateDO.getId());
        stringRedisTemplate.opsForHash().putAll(couponTemplateCacheKey, actualCacheTargetMap);
    }
}
@RestController
@RequiredArgsConstructor
@Tag(name = "优惠券模板管理")
public class CouponTemplateController {

    private final CouponTemplateService couponTemplateService;

    @Operation(summary = "商家创建优惠券模板")
    @PostMapping("/api/merchant-admin/coupon-template/create")
    public Result<Void> createCouponTemplate(@RequestBody CouponTemplateSaveReqDTO requestParam) {
        couponTemplateService.createCouponTemplate(requestParam);
        return Results.success();
    }
}

3. 责任链模式

3.1 现有代码的问题

  • 职责单一: 责任链模式可以将每个验证逻辑封装到一个独立的处理器中,每个处理器负责单一的验证职责,符合单一职责原则。
  • 可扩展性: 增加新的验证逻辑时,只需添加新的处理器,而不需要修改现有的代码。
  • 清晰的流程: 将所有验证逻辑组织在一起,使得代码结构更加清晰,易于理解。

@Component
public final class MerchantAdminChainContext<T> implements ApplicationContextAware, CommandLineRunner {

    /**
     * 应用上下文,我们这里通过 Spring IOC 获取 Bean 实例
     */
    private ApplicationContext applicationContext;
    /**
     * 保存商家后管责任链实现类
     * <p>
     * Key:{@link MerchantAdminAbstractChainHandler#mark()}
     * Val:{@link MerchantAdminAbstractChainHandler} 一组责任链实现 Spring Bean 集合
     * <p>
     * 比如有一个优惠券模板创建责任链,实例如下:
     * Key:MERCHANT_ADMIN_CREATE_COUPON_TEMPLATE_KEY
     * Val:
     * - 验证优惠券信息基本参数是否必填 —— 执行器 {@link CouponTemplateCreateParamNotNullChainFilter}
     * - 验证优惠券信息基本参数是否按照格式传递 —— 执行器 {@link CouponTemplateCreateParamBaseVerifyChainFilter}
     * - 验证优惠券信息基本参数是否正确,比如商品数据是否存在等 —— 执行器 {@link CouponTemplateCreateParamVerifyChainFilter}
     * - ......
     */
    private final Map<String, List<MerchantAdminAbstractChainHandler>> abstractChainHandlerContainer = new HashMap<>();

    /**
     * 责任链组件执行
     *
     * @param mark         责任链组件标识
     * @param requestParam 请求参数
     */
    public void handler(String mark, T requestParam) {
        // 根据 mark 标识从责任链容器中获取一组责任链实现 Bean 集合
        List<MerchantAdminAbstractChainHandler> abstractChainHandlers = abstractChainHandlerContainer.get(mark);
        if (CollectionUtils.isEmpty(abstractChainHandlers)) {
            throw new RuntimeException(String.format("[%s] Chain of Responsibility ID is undefined.", mark));
        }
        abstractChainHandlers.forEach(each -> each.handler(requestParam));
    }

    @Override
    public void run(String... args) throws Exception {
        // 从 Spring IOC 容器中获取指定接口 Spring Bean 集合
        Map<String, MerchantAdminAbstractChainHandler> chainFilterMap = applicationContext.getBeansOfType(MerchantAdminAbstractChainHandler.class);
        chainFilterMap.forEach((beanName, bean) -> {
            // 判断 mark 是否已经存在抽象责任链容器中,如果已经存在直接向集合新增;如果不存在,创建 Mark 和对应的集合
            List<MerchantAdminAbstractChainHandler> abstractChainHandlers = abstractChainHandlerContainer.get(bean.mark());
            if (CollectionUtils.isEmpty(abstractChainHandlers)) {
                abstractChainHandlers = new ArrayList();
            }
            abstractChainHandlers.add(bean);
            // 对 Mark 下责任链实现的处理类排序,性能执行小的在前
            List<MerchantAdminAbstractChainHandler> actualAbstractChainHandlers = abstractChainHandlers.stream()
                    .sorted(Comparator.comparing(Ordered::getOrder))
                    .collect(Collectors.toList());
            abstractChainHandlerContainer.put(bean.mark(), actualAbstractChainHandlers);
        });
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }
}

我们大家其实可以类比一下,像我们刚才讲到的用户拦截器,本质上它就是责任链的一种模式,因为它能配置多个拦截器,而且这些拦截器一定会把请求给执行一遍,除非你在过程当中抛出异常。 大家想一下是不是和我们的验证逻辑,其实核心思想是一致的,对不对?我们去看一下责任链的一个地图,首先我们要有一个责任链的抽象接口,然后通过抽象接口各个实现类去实现我们要对请求所要做的一些逻辑处理步骤,然后我们需要一些处理类去继承我们责任链的接口,然后去实现自己内部的一些逻辑封装。同时我们将一组的责任链给它放到一个容器里面,这个也就是我们的一个责任链的完整的逻辑,如果我们去看代码的话,会发现我们会有以下5个类,第一个类的话也就是我们责任链接口,第二个是我们的上下文存储,然后接下来的3个的话,我们具体的1个责任链实现,因为时间的问题,我这里就不给大家去从0~1去演示它对应去怎么创建了,我直接给大家去展示一个已经做好的责任链,我们就看一下代码是怎么实现的。

首先按照刚才的逻辑,我们首先要去创建一个责任链的接口,实现这个接口的话其实里面就两个逻辑,第一块我们去处理责任链的一个执行逻辑。第二个就是我们要标识一组的责任链的处理器,然后的话下面是一些它去实现了接口的对应的一个时限内,然后这里面都是它对应的一些具体的封装逻辑,对应我刚才讲的三块内容,第一块的话就是进行非空的验证,第二块进行基础数据的一个校验。第三块针对数据的真实性做一个校验,可以看到还是比较清晰的对吧?然后我们在使用方面就只需要引入我们对应的一个上下文,然后去执行处理就可以了,我们就看一下它具体是怎么把这一组的责任链的处理器给聚合到容器里面的,可以看到我们的上下文容器是实现了两个接口,第一个接口是sport的一个上下文OA接口,实现这个接口的话我们能够拿是不是对象的一个IOC的上下文,也就是application context。通过我们可以去拿责任链的处理器的spring病。然后第二个就是我们要在spring boot启动了之后,要去做一些后置的逻辑,我们先从入口出发,入口的话就是我们通过各位接口拿到了对应的是朋友的上下文,然后我们通过获取类型的形式,也就是获取我们当前接口下面有哪些具体的实现类,然后拿到一个map里面,我们通过 map再进而去获取到我们想要的内容。

首先那就是我们对应的一个存储容器是用了一个map去做的,也就是一个希麦克,然后它的K就是我们对应的一个麦克字段,这个也就是我们标识一组责任链的一个关键字段。然后 value就是一个例子,标识了我们一个标识下面它的那一组责任链的处理器。我举一个例子,我们有一个K是这样的,创建优惠券模板K,然后的话它的value就是我们刚才讲到的那三个类,这个时候责任链的容器怎么存储,我们已经知道了它是怎么给它放进去的,首先第一步我们要根据它对应的一个Mark,确定我们的容器里面是否已经有标识对应的集合了,如果没有的话我们要去给他创建一个空的例子,如果有的话直接获取,然后将当前因为是个循环,将当前的病放到它对应标识下的例子的容器里面,然后我们再将对应的标识和集合放回到我们的容器里面,然后我们是需要给它进行一个排序的,然后具体为什么排序后面会跟大家有讲。然后到我们具体的业务代码里面的话,就只需要去引入我们的一个上下文容器,然后去执行我们的handle的方法就可以了。那handle的方法非常简单,那就是我们去get对应的一个责任链逻辑集合。然后如果说它等于空的话,就意味着我们的业务代码出现了问题,直接抛异常就行了,如果不出异常我们就直接按照顺序去执行,这样的话我们的一个责任链其实也就已经完成了,如果说后续我有新的业务要添加,我直接在这里copy一个,这样的话对我们原来的逻辑是没有任何影响的,如果说我的非公验证或者说我的验证数据逻辑有一些变化,我就直接在对应的那里改就行了,对其他也是完全没有影响的

3.2 什么是责任链模式

责任链模式(Chain of Responsibility Pattern)是一种行为设计模式,它允许将请求沿着一个处理链传递,直到链中的某个对象处理它。这样,发送者无需知道哪个对象将处理请求,所有的处理对象都可以尝试处理请求或将请求传递给链上的下一个对象。

创建优惠券模板责任链模式类分布。还有common/enums里面

public enum ChainBizMarkEnum {

    /**
     * 创建优惠券模板验证参数是否正确责任链流程
     */
    MERCHANT_ADMIN_CREATE_COUPON_TEMPLATE_KEY;

    @Override
    public String toString() {
        return this.name();
    }
}

3.3 实现逻辑

定义责任链抽象接口。

public interface MerchantAdminAbstractChainHandler<T> extends Ordered {

    /**
     * 执行责任链逻辑
     *
     * @param requestParam 责任链执行入参
     */
    void handler(T requestParam);

    /**
     * @return 责任链组件标识
     */
    String mark();
}

创建责任链模式上下文容器。


/**
 * 商家后管责任链上下文容器
 * ApplicationContextAware 接口获取应用上下文,并复制局部变量方便后续使用;CommandLineRunner 项目启动后执行责任链容器的填充工作
 * <p>
 * 作者:马丁
 * 加项目群:早加入就是优势!500人内部项目群,分享的知识总有你需要的 <a href="https://t.zsxq.com/cw7b9" />
 * 开发时间:2024-07-09
 */
@Component
public final class MerchantAdminChainContext<T> implements ApplicationContextAware, CommandLineRunner {

    /**
     * 应用上下文,我们这里通过 Spring IOC 获取 Bean 实例
     */
    private ApplicationContext applicationContext;
    /**
     * 保存商家后管责任链实现类
     * <p>
     * Key:{@link MerchantAdminAbstractChainHandler#mark()}
     * Val:{@link MerchantAdminAbstractChainHandler} 一组责任链实现 Spring Bean 集合
     * <p>
     * 比如有一个优惠券模板创建责任链,实例如下:
     * Key:MERCHANT_ADMIN_CREATE_COUPON_TEMPLATE_KEY
     * Val:
     * - 验证优惠券信息基本参数是否必填 —— 执行器 {@link CouponTemplateCreateParamNotNullChainFilter}
     * - 验证优惠券信息基本参数是否按照格式传递 —— 执行器 {@link CouponTemplateCreateParamBaseVerifyChainFilter}
     * - 验证优惠券信息基本参数是否正确,比如商品数据是否存在等 —— 执行器 {@link CouponTemplateCreateParamVerifyChainFilter}
     * - ......
     */
    private final Map<String, List<MerchantAdminAbstractChainHandler>> abstractChainHandlerContainer = new HashMap<>();

    /**
     * 责任链组件执行
     *
     * @param mark         责任链组件标识
     * @param requestParam 请求参数
     */
    public void handler(String mark, T requestParam) {
        // 根据 mark 标识从责任链容器中获取一组责任链实现 Bean 集合
        List<MerchantAdminAbstractChainHandler> abstractChainHandlers = abstractChainHandlerContainer.get(mark);
        if (CollectionUtils.isEmpty(abstractChainHandlers)) {
            throw new RuntimeException(String.format("[%s] Chain of Responsibility ID is undefined.", mark));
        }
        abstractChainHandlers.forEach(each -> each.handler(requestParam));
    }

    @Override
    public void run(String... args) throws Exception {
        // 从 Spring IOC 容器中获取指定接口 Spring Bean 集合
        Map<String, MerchantAdminAbstractChainHandler> chainFilterMap = applicationContext.getBeansOfType(MerchantAdminAbstractChainHandler.class);
        chainFilterMap.forEach((beanName, bean) -> {
            // 判断 Mark 是否已经存在抽象责任链容器中,如果已经存在直接向集合新增;如果不存在,创建 Mark 和对应的集合
            List<MerchantAdminAbstractChainHandler> abstractChainHandlers = abstractChainHandlerContainer.getOrDefault(bean.mark(), new ArrayList<>());
            abstractChainHandlers.add(bean);
            abstractChainHandlerContainer.put(bean.mark(), abstractChainHandlers);
        });
        abstractChainHandlerContainer.forEach((mark, unsortedChainHandlers) -> {
            // 对每个 Mark 对应的责任链实现类集合进行排序,优先级小的在前
            unsortedChainHandlers.sort(Comparator.comparing(Ordered::getOrder));
        });
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }
}

3.4 如何使用?


@Service
@RequiredArgsConstructor
public class CouponTemplateServiceImpl extends ServiceImpl<CouponTemplateMapper, CouponTemplateDO> implements CouponTemplateService {

    private final CouponTemplateMapper couponTemplateMapper;
    private final MerchantAdminChainContext merchantAdminChainContext;
    private final StringRedisTemplate stringRedisTemplate;

    @Override
    public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {
        // 通过责任链验证请求参数是否正确
        merchantAdminChainContext.handler(MERCHANT_ADMIN_CREATE_COUPON_TEMPLATE_KEY.name(), requestParam);

        // 新增优惠券模板信息到数据库
        CouponTemplateDO couponTemplateDO = BeanUtil.toBean(requestParam, CouponTemplateDO.class);
        couponTemplateDO.setStatus(CouponTemplateStatusEnum.ACTIVE.getStatus());
        couponTemplateDO.setShopNumber(UserContext.getShopNumber());
        couponTemplateMapper.insert(couponTemplateDO);

        // 缓存预热:通过将数据库的记录序列化成 JSON 字符串放入 Redis 缓存
        CouponTemplateQueryRespDTO actualRespDTO = BeanUtil.toBean(couponTemplateDO, CouponTemplateQueryRespDTO.class);
        Map<String, Object> cacheTargetMap = BeanUtil.beanToMap(actualRespDTO, false, true);
        Map<String, String> actualCacheTargetMap = cacheTargetMap.entrySet().stream()
                .collect(Collectors.toMap(
                        Map.Entry::getKey,
                        entry -> entry.getValue() != null ? entry.getValue().toString() : ""
                ));
        String couponTemplateCacheKey = String.format(MerchantAdminRedisConstant.COUPON_TEMPLATE_KEY, couponTemplateDO.getId());
        stringRedisTemplate.opsForHash().putAll(couponTemplateCacheKey, actualCacheTargetMap);
    }
}


是不是对象的一个 Spring 的上下文,也就是 ApplicationContext。通过它可以去获取责任链处理器的 Spring Bean。然后第二个就是我们要在 Spring Boot 启动之后,执行一些后置逻辑。我们先从入口出发:入口是通过本位接口拿到对应的 Spring 上下文,然后通过获取类型的形式,也就是获取当前接口下有哪些具体的实现类,再拿到 Map 里面,通过 Map 进而获取到我们想要的内容。

首先,我们对应的一个存储容器是用了一个 Map 去做的,也就是一个 HashMap,它的 Key 就是我们对应的一个 mark 字段,这个也就是我们标识一组责任链的关键字段,Value 就是一个 List,标识了该标识下那一组责任链的处理器。我举个例子,我们有一个 Key 是这样的:创建优惠券模板的 mark(MERCHANT_ADMIN_CREATE_COUPON_TEMPLATE_KEY),它的 Value 就是我刚才讲到的三个类:CouponTemplateCreateParamNotNullChainFilter、CouponTemplateCreateParamBaseVerifyChainFilter、CouponTemplateCreateParamVerifyChainFilter。这个时候责任链的容器怎么存储?我们已经知道了它是怎么放进去的:首先第一步,我们要根据它对应的 mark,判断容器里是否已经有该标识对应的集合,如果没有,就创建一个空的 List;如果有的话,直接获取,然后因为是循环处理,将当前的 Bean 放到它对应标识下的 List 容器里,再将对应的标识和集合放回我们的容器中。然后我们需要对它进行排序,具体为什么排序后面会讲。

然后到我们具体的业务代码里,只需要引入我们的上下文组件,然后调用 execute 方法就可以了。它的方法非常简单:我们去 get 对应责任链的集合,如果等于 null,就意味着业务配置有问题,直接抛异常;如果不抛异常,我们就直接按 getOrder() 的顺序依次执行。这样的话,我们的责任链其实也就完成了。如果后续有新的业务要添加,我直接新增一个 @Component 实现类即可,对原来的逻辑没有任何影响;如果某个验证逻辑需要调整,直接修改对应类即可,对其他处理器也完全没有影响。相信说到这里,很多同学已经明白了责任链的优点。如果大家还有问题,可以在评论区沟通。

接下来讲刚才预留的点:为什么继承的接口要实现排序?因为责任链会有多个处理器,我们一般会把性能好、对其他组件无依赖的处理器放在前面。我给大家看一下:比如非空验证(CouponTemplateCreateParamNotNullChainFilter),性能最好,它的 getOrder() 返回 0;基础校验(CouponTemplateCreateParamBaseVerifyChainFilter)紧随其后,返回 10;而商品存在性校验(CouponTemplateCreateParamVerifyChainFilter)可能需要 RPC 调用商品服务,性能最差,所以返回 20,排在最后。

相信大家能明白为什么要有 order:如果我们先把商品存在性校验放在第一位,假设商品确实存在,但用户根本没传“领取规则”,那这次 RPC 调用就是浪费的。所以我们应该把性能最好的校验放在最前面,逐步执行,性能差的放后面。

然后,我们的责任链模式符合开闭原则。大家可能在网上看的时候会有疑问:很多人讲责任链时会说,你要把处理器加入责任链容器,需要手动调用 register 方法——大家就有疑问了:那岂不是每次新增处理器都要改注册逻辑?

但我们刚才的实现没有这个问题。我们实现的责任链是符合开闭原则的,因为通过 Spring ApplicationContext 自动扫描和注入,帮助我们完成注册逻辑。如果你不是在 Spring 环境下,有些实现确实不符合开闭原则。这个点算是一个扩展,因为这个框架处理得非常周全,所以我们对于这种开箱即用的机制,直接使用即可,不需要自己再写注册逻辑,或者把业务复杂化。不知道这么讲大家能不能理解?

接下来我们验证一下现在的逻辑是否好使。我现在已经把项目启动起来了,可以直接访问对应的 API 接口。因为我窗口已经打开了,我点了发送——这里我已经预置了一些示例字段,大家直接用即可。点击发送后,可以看到记录已经成功存储到数据库里了。关于数据库这块也要跟大家说明一下:我们从 0 到 1 搭建的数据库表,其实后面也加了字段扩展(比如 radio),但如果你看的是股份制公司的原始表结构,可能是没有这些字段的——这点要跟大家说清楚。


@Component
public class CouponTemplateCreateParamVerifyChainFilter implements MerchantAdminAbstractChainHandler<CouponTemplateSaveReqDTO> {

    @Override
    public void handler(CouponTemplateSaveReqDTO requestParam) {
        if (ObjectUtil.equal(requestParam.getTarget(), DiscountTargetEnum.PRODUCT_SPECIFIC)) {
            // 调用商品中台验证商品是否存在,如果不存在抛出异常
            // ......
        }
    }

    @Override
    public String mark() {
        return MERCHANT_ADMIN_CREATE_COUPON_TEMPLATE_KEY.name();
    }

    @Override
    public int getOrder() {
        return 20;
    }
}

@Component
public class CouponTemplateCreateParamNotNullChainFilter implements MerchantAdminAbstractChainHandler<CouponTemplateSaveReqDTO> {

    @Override
    public void handler(CouponTemplateSaveReqDTO requestParam) {
        if (StrUtil.isEmpty(requestParam.getName())) {
            throw new ClientException("优惠券名称不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getSource())) {
            throw new ClientException("优惠券来源不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getTarget())) {
            throw new ClientException("优惠对象不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getType())) {
            throw new ClientException("优惠类型不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getValidStartTime())) {
            throw new ClientException("有效期开始时间不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getValidEndTime())) {
            throw new ClientException("有效期结束时间不能为空");
        }

        if (ObjectUtil.isEmpty(requestParam.getStock())) {
            throw new ClientException("库存不能为空");
        }

        if (StrUtil.isEmpty(requestParam.getReceiveRule())) {
            throw new ClientException("领取规则不能为空");
        }

        if (StrUtil.isEmpty(requestParam.getConsumeRule())) {
            throw new ClientException("消耗规则不能为空");
        }
    }

    @Override
    public String mark() {
        return MERCHANT_ADMIN_CREATE_COUPON_TEMPLATE_KEY.name();
    }

    @Override
    public int getOrder() {
        return 0;
    }
}
@Component
public class CouponTemplateCreateParamBaseVerifyChainFilter implements MerchantAdminAbstractChainHandler<CouponTemplateSaveReqDTO> {

    private final int maxStock = 20000000;

    @Override
    public void handler(CouponTemplateSaveReqDTO requestParam) {
        boolean targetAnyMatch = Arrays.stream(DiscountTargetEnum.values())
                .anyMatch(enumConstant -> enumConstant.getType() == requestParam.getTarget());
        if (!targetAnyMatch) {
            // 此处已经基本能判断数据请求属于恶意攻击,可以上报风控中心进行封禁账号
            throw new ClientException("优惠对象值不存在");
        }
        if (ObjectUtil.equal(requestParam.getTarget(), DiscountTargetEnum.ALL_STORE_GENERAL)
                && StrUtil.isNotEmpty(requestParam.getGoods())) {
            throw new ClientException("优惠券全店通用不可设置指定商品");
        }
        if (ObjectUtil.equal(requestParam.getTarget(), DiscountTargetEnum.PRODUCT_SPECIFIC)
                && StrUtil.isEmpty(requestParam.getGoods())) {
            throw new ClientException("优惠券商品专属未设置指定商品");
        }

        boolean typeAnyMatch = Arrays.stream(DiscountTypeEnum.values())
                .anyMatch(enumConstant -> enumConstant.getType() == requestParam.getType());
        if (!typeAnyMatch) {
            // 此处已经基本能判断数据请求属于恶意攻击,可以上报风控中心进行封禁账号
            throw new ClientException("优惠类型不存在");
        }

        Date now = new Date();
        if (requestParam.getValidStartTime().before(now)) {
            // 为了方便大家测试,不用关注这个时间,这里取消异常抛出
            // throw new ClientException("有效期开始时间不能早于当前时间");
        }

        if (requestParam.getStock() <= 0 || requestParam.getStock() > maxStock) {
            // 此处已经基本能判断数据请求属于恶意攻击,可以上报风控中心进行封禁账号
            throw new ClientException("库存数量设置异常");
        }

        if (!JSON.isValid(requestParam.getReceiveRule())) {
            // 此处已经基本能判断数据请求属于恶意攻击,可以上报风控中心进行封禁账号
            throw new ClientException("领取规则格式错误");
        }
        if (!JSON.isValid(requestParam.getConsumeRule())) {
            // 此处已经基本能判断数据请求属于恶意攻击,可以上报风控中心进行封禁账号
            throw new ClientException("消耗规则格式错误");
        }
    }

    @Override
    public String mark() {
        return MERCHANT_ADMIN_CREATE_COUPON_TEMPLATE_KEY.name();
    }

    @Override
    public int getOrder() {
        return 10;
    }
}

3.5 常见问题答疑

Q:为什么需要 Order 接口?

A:因为实现责任链的话会有多个处理器,创建优惠券业务优先处理性能较好的,然后逐步验证。

Q:责任链模式满足开闭原则么?

A:我们是通过 Spring IOC 容器去获取责任链处理器的,所以不管新增和删除都不需要变更获取逻辑。新增的话创建对应处理器即可,符合开闭原则。

4. 缓存预热

在视频中缓存预热一笔带过,但是有同学提了两个问题,我们在这里解答下。

4.1 为什么使用 Hash 不是 String?

因为优惠券模板存在库存,而我们后续会通过缓存扣减,如果使用字符串没办法做原子自减,而使用 Redis Hash 结构是可以针对某个字段进行自减的。

如果对应 Redis 命令的话,如下所示,即可完成用户领取优惠券时的原子自减逻辑。

HINCRBY HashKey stock -1

4.2 如何设置过期时间?

有位同学指正了一个问题,那就是咱们没有设置过期时间。

之前代码逻辑如下,过期时间默认 -1,也就是不会过期。这种情况下,如果优惠券模板过期了,但是缓存还在,对 Redis 内存存储压力较大。为此,我们需要设置 Key 的过期时间。

stringRedisTemplate.opsForHash().putAll(couponTemplateCacheKey, actualCacheTargetMap);

在这个基础上,添加对应优惠券的截止时间作为过期时间即可。

stringRedisTemplate.opsForHash().putAll(couponTemplateCacheKey, actualCacheTargetMap); stringRedisTemplate.expireAt(couponTemplateCacheKey, couponTemplateDO.getValidEndTime());

很多同学到这里就结束了,但是殊不知还是有点问题,那就是这个是非原子性的。如果想要原子设置过期时间,我们可以使用 LUA 脚本执行组合命令。

其实这里考虑的极端情况,也就是刚执行完新增 Hash 缓存,但是还没执行设置过期时间时宕机了。但是正式企业中往往没那么多极端情况,即使出现了无外乎多一条记录而已,对于企业存储来说无关紧要。

但是既然考虑到了这种极端情况,就和大家说下应该怎么去防止问题。你可以不这么写,但是不能不知道解题思路

CouponTemplateServiceImpl添加代码如下所示:

// 通过 LUA 脚本执行设置 Hash 数据以及设置过期时间
        String luaScript = "redis.call('HMSET', KEYS[1], unpack(ARGV, 1, #ARGV - 1)) " +
                "redis.call('EXPIREAT', KEYS[1], ARGV[#ARGV])";

        List<String> keys = Collections.singletonList(couponTemplateCacheKey);
        List<String> args = new ArrayList<>(actualCacheTargetMap.size() * 2 + 1);
        actualCacheTargetMap.forEach((key, value) -> {
            args.add(key);
            args.add(value);
        });

        // 优惠券活动过期时间转换为秒级别的 Unix 时间戳
        args.add(String.valueOf(couponTemplateDO.getValidEndTime().getTime() / 1000));

        // 执行 LUA 脚本
        stringRedisTemplate.execute(
                new DefaultRedisScript<>(luaScript, Long.class),
                keys,
                args.toArray()
        ); 

通过 LUA 脚本执行多个命令的原子问题,并设置优惠券截止时间为 Key 过期时间。注意:后续添加优惠券模板入参中的 validEndTime 参数不能小于当前时间,不然缓存设置不成功

至此,问题到这里圆满解决。

▪第07小节:通过ShardingSphere完成优惠券分库分表

业务说明

创建优惠券的主力是商家用户,按照淘宝、天猫非官方数据统计,商家数量已有近 3000万。我们假设每个商家会创建 100 张优惠券(这个数据是折中记录,有些商家可能创建几十,有些商家可能会创建 1000 不止),那优惠券模板表就会接近 30 亿数据量。

为什么是假设?因为优惠券创建行为隶属于每一个商家,不管是平台还是任何人,都只能以常规数据进行推测。

这个推测也是具备时效性的,随着时间的推迟,商家会更多,同时创建的优惠券也可能会更多,预估数据也会随之增加。

编辑

分库分表概述

分库是将原本的单库拆分为多个库,分表是将原来的单表拆分为多个表。

编辑

分库有两种模式:

  • 垂直拆库:电商库 MallDB,业务拆分后就是 UserDB、OrderDB、PayDB 等。
  • 分片拆库:用户库 UserDB,分片库后就是 UserDB_0、UserDB_1、UserDB_xx。

分表也有两种模式:

  • 垂直拆分:订单表 OrderTable,拆分后就是 OrderTable 以及 OrderExtTable。
  • 水平拆分:订单表 OrderTable,拆分后就是 OrderTable_0、 OrderTable_xxx。

1. 什么场景分表?

当出现以下三种情况的时候,我们需要考虑分表:

  • 单表的数据量过大
  • 单表存在较高的写入场景,可能引发行锁竞争。
  • 当表中包含大量的 TEXT、LONGTEXT 或 BLOB 等大字段。

2. 什么场景分库?

当出现以下两种情况时,我们需要考虑通过分库来将数据分散到多个数据库实例上,以提升整体系统的性能:

  • 当单个数据库支持的连接数已经不足以满足客户端需求。
  • 数据量已经超过单个数据库实例的处理能力。

3. 什么场景分库分表?

当出现以下两种场景下,需要进行分库又分表:高并发写入和海量数据:

  • 高并发写入场景:当应用面临高并发的写入请求时,单一数据库可能无法满足写入压力,此时可以将数据按照一定规则拆分到多个数据库中,每个数据库处理部分数据的写入请求,从而提高写入性能。
  • 海量数据场景:随着数据量的不断增加,单一数据库的存储和查询性能可能逐渐下降。此时,可以将数据按照一定的规则拆分到多个表中,每个表存储部分数据,从而分散数据的存储压力,提高查询性能。

分库分表设计

1. 如何选择分片键?

  • 数据均匀性:分片键应该保证数据的均匀分布在各个分片上,避免出现热点数据集中在某个分片上的情况。
  • 业务关联性:分片键应该与业务关联紧密,这样可以避免跨分片查询和跨库事务的复杂性。
  • 数据不可变:一旦选择了分片键,它应该是不可变的,不能随着业务的变化而频繁修改。

2. 分库分表算法?

分库分表的算法会根据业务的不同而变化,所以并没有固定算法。在业界里用的比较多的有两种:

  • HashMod:通过对分片键进行哈希取模的分片算法。
  • 时间范围: 基于时间范围分片算法。

分片算法讲解一个数据均匀,时间范围并不适合优惠券模板业务,因为商家用户前期比较少,后面会越来越多,所以有比较明显的不均匀问题。

分库分表框架选型

选择 MyCat 还是 ShardingSphere 取决于项目的具体需求、架构设计、团队技术栈和个人偏好。

如果项目对功能需求较高,希望在一个较为活跃的社区中获取支持,且对数据库的支持要求较高,那么 ShardingSphere 可能是一个更好的选择。如果项目相对简单,对生态和社区支持要求不高,那么 Mycat 也是一个稳定的选择。

我们可以从下面几个维度做一下评估:

1. 生态和社区支持

Mycat:MyCat 的社区相对较小,更新和支持相对有限。

ShardingSphere:ShardingSphere 是 Apache 旗下的项目,有较大的社区支持,得到了广泛的认可和使用。

2. 社区活跃度

Mycat:社区相对较小,更新和新功能开发可能相对缓慢。

ShardingSphere:由于是 Apache 顶级项目,社区活跃度较高,更新和新功能的开发较为迅速。

3. 分库分表策略

Mycat:Mycat 主要支持水平分表和垂直分库,提供相对基础的分片策略。

ShardingSphere:ShardingSphere 提供了更为灵活和丰富的分库分表策略,支持广泛的分片规则,包括范围、哈希、复合分片等。

为什么不用分布式数据库?

具备代表性分布式数据库:

  • 阿里云 PolarDB for MySQL。
  • 腾讯云 TDSQL for MySQL。
  • PingCAP TiDB。

以下说法谨代表我个人看法:

  • 兼容性:部分分布式数据库并不能 100%兼容 MySQL,导致业务无法平滑迁移。
  • 技术储备:需要有这方面的分布式数据库专家,平常使用谁都可以,线上出现了问题不知道怎么解决才是致命。
  • 使用成本:阿里云和腾讯云没有开源版本,付费版本相对于 MySQL 成本偏高。TiDB 开源版本 Issue BUG 较多,商业未知。

以下截图自 2024年08月15日 TiDB GitHub 仓库。

编辑

Git 分支

20240815_dev_coupon-tablue_shardingsphere_ding.ma

优惠券模板如何分库分表?

1. 优惠券模板分多少表?

根据上面数据估算,30 亿数据量需要分多少个表?这其实又会涉及到一个知识点,那就是 SQL 复杂么?

  • SQL 复杂,拆分百万级别。
  • SQL 不复杂,全部走索引,千万甚至亿级别。

我们以优惠券模板举例,不涉及复杂 SQL,但是依然不建议大家数据量到达亿级别,总归要留有余量。在这里我们取经验值 2000 万,30 亿数据就是拆分 150 张表即可。

为什么取 2000 万?其实数据量不是特别多的情况下,基本上 3 次磁盘 IO 就能获取到数据。再多的话可能磁盘 IO 会增加,但是还好。考虑到数据库表备份等其他操作,不建议单表太多数据。

2. 优惠券模板是否需要分库?

不需要,因为并发不高。

如果需要分析一个业务场景如何分库,那就需要知道单个 MySQL Server 的瓶颈是多少?通过之前压测得知,单台 MySQL Server 的写瓶颈大概在 4000-5000/TPS,查询可能更高一些。

如果我们的场景业务每秒 TPS 在 1 万,那么就需要至少分两个库,然后将上面的 150 张表分别放入即可。

后面优惠券的分发和领取是需要分库的,所以我们为了不重复创建表,在这里直接通过分库的形式展示。

3. 优惠券模板表分片键如何选择?

参考国内某电商平台优惠券管理页面,需要根据当前店铺创建的优惠券分页查询。

答案呼之欲出,那就是店铺编号字段

编辑

ShardingSphere 项目实战

1. 初始化数据库&表

首先创建数据库,可以通过命令去 MySQL 执行,或者去可视化工具创建:

CREATE DATABASE IF NOT EXISTS one_coupon_rebuild_0; CREATE DATABASE IF NOT EXISTS one_coupon_rebuild_1;

分别在两个数据库中创建优惠券模板表:

CREATE TABLE `t_coupon_template_0` (    `id`               bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `name`             varchar(256) DEFAULT NULL COMMENT '优惠券名称',    `shop_number`      bigint(20) DEFAULT NULL COMMENT '店铺编号',    `source`           tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券',    `target`           tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用',    `goods`            varchar(64)  DEFAULT NULL COMMENT '优惠商品编码',    `type`             tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券',    `valid_start_time` datetime     DEFAULT NULL COMMENT '有效期开始时间',    `valid_end_time`   datetime     DEFAULT NULL COMMENT '有效期结束时间',    `stock`            int(11) DEFAULT NULL COMMENT '库存',    `receive_rule`     json         DEFAULT NULL COMMENT '领取规则',    `consume_rule`     json         DEFAULT NULL COMMENT '消耗规则',    `status`           tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束',    `create_time`      datetime     DEFAULT NULL COMMENT '创建时间',    `update_time`      datetime     DEFAULT NULL COMMENT '修改时间',    `del_flag`         tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',    PRIMARY KEY (`id`),    KEY                `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967816300515330 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_1` (    `id`               bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `name`             varchar(256) DEFAULT NULL COMMENT '优惠券名称',    `shop_number`      bigint(20) DEFAULT NULL COMMENT '店铺编号',    `source`           tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券',    `target`           tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用',    `goods`            varchar(64)  DEFAULT NULL COMMENT '优惠商品编码',    `type`             tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券',    `valid_start_time` datetime     DEFAULT NULL COMMENT '有效期开始时间',    `valid_end_time`   datetime     DEFAULT NULL COMMENT '有效期结束时间',    `stock`            int(11) DEFAULT NULL COMMENT '库存',    `receive_rule`     json         DEFAULT NULL COMMENT '领取规则',    `consume_rule`     json         DEFAULT NULL COMMENT '消耗规则',    `status`           tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束',    `create_time`      datetime     DEFAULT NULL COMMENT '创建时间',    `update_time`      datetime     DEFAULT NULL COMMENT '修改时间',    `del_flag`         tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',    PRIMARY KEY (`id`),    KEY                `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967812836020227 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_2` (    `id`               bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `name`             varchar(256) DEFAULT NULL COMMENT '优惠券名称',    `shop_number`      bigint(20) DEFAULT NULL COMMENT '店铺编号',    `source`           tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券',    `target`           tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用',    `goods`            varchar(64)  DEFAULT NULL COMMENT '优惠商品编码',    `type`             tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券',    `valid_start_time` datetime     DEFAULT NULL COMMENT '有效期开始时间',    `valid_end_time`   datetime     DEFAULT NULL COMMENT '有效期结束时间',    `stock`            int(11) DEFAULT NULL COMMENT '库存',    `receive_rule`     json         DEFAULT NULL COMMENT '领取规则',    `consume_rule`     json         DEFAULT NULL COMMENT '消耗规则',    `status`           tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束',    `create_time`      datetime     DEFAULT NULL COMMENT '创建时间',    `update_time`      datetime     DEFAULT NULL COMMENT '修改时间',    `del_flag`         tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',    PRIMARY KEY (`id`),    KEY                `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967817126793218 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_3` (    `id`               bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `name`             varchar(256) DEFAULT NULL COMMENT '优惠券名称',    `shop_number`      bigint(20) DEFAULT NULL COMMENT '店铺编号',    `source`           tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券',    `target`           tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用',    `goods`            varchar(64)  DEFAULT NULL COMMENT '优惠商品编码',    `type`             tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券',    `valid_start_time` datetime     DEFAULT NULL COMMENT '有效期开始时间',    `valid_end_time`   datetime     DEFAULT NULL COMMENT '有效期结束时间',    `stock`            int(11) DEFAULT NULL COMMENT '库存',    `receive_rule`     json         DEFAULT NULL COMMENT '领取规则',    `consume_rule`     json         DEFAULT NULL COMMENT '消耗规则',    `status`           tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束',    `create_time`      datetime     DEFAULT NULL COMMENT '创建时间',    `update_time`      datetime     DEFAULT NULL COMMENT '修改时间',    `del_flag`         tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',    PRIMARY KEY (`id`),    KEY                `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967817122598915 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_4` (    `id`               bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `name`             varchar(256) DEFAULT NULL COMMENT '优惠券名称',    `shop_number`      bigint(20) DEFAULT NULL COMMENT '店铺编号',    `source`           tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券',    `target`           tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用',    `goods`            varchar(64)  DEFAULT NULL COMMENT '优惠商品编码',    `type`             tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券',    `valid_start_time` datetime     DEFAULT NULL COMMENT '有效期开始时间',    `valid_end_time`   datetime     DEFAULT NULL COMMENT '有效期结束时间',    `stock`            int(11) DEFAULT NULL COMMENT '库存',    `receive_rule`     json         DEFAULT NULL COMMENT '领取规则',    `consume_rule`     json         DEFAULT NULL COMMENT '消耗规则',    `status`           tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束',    `create_time`      datetime     DEFAULT NULL COMMENT '创建时间',    `update_time`      datetime     DEFAULT NULL COMMENT '修改时间',    `del_flag`         tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',    PRIMARY KEY (`id`),    KEY                `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967797723942918 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_5` (    `id`               bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `name`             varchar(256) DEFAULT NULL COMMENT '优惠券名称',    `shop_number`      bigint(20) DEFAULT NULL COMMENT '店铺编号',    `source`           tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券',    `target`           tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用',    `goods`            varchar(64)  DEFAULT NULL COMMENT '优惠商品编码',    `type`             tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券',    `valid_start_time` datetime     DEFAULT NULL COMMENT '有效期开始时间',    `valid_end_time`   datetime     DEFAULT NULL COMMENT '有效期结束时间',    `stock`            int(11) DEFAULT NULL COMMENT '库存',    `receive_rule`     json         DEFAULT NULL COMMENT '领取规则',    `consume_rule`     json         DEFAULT NULL COMMENT '消耗规则',    `status`           tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束',    `create_time`      datetime     DEFAULT NULL COMMENT '创建时间',    `update_time`      datetime     DEFAULT NULL COMMENT '修改时间',    `del_flag`         tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',    PRIMARY KEY (`id`),    KEY                `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967789205311493 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_6` (    `id`               bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `name`             varchar(256) DEFAULT NULL COMMENT '优惠券名称',    `shop_number`      bigint(20) DEFAULT NULL COMMENT '店铺编号',    `source`           tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券',    `target`           tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用',    `goods`            varchar(64)  DEFAULT NULL COMMENT '优惠商品编码',    `type`             tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券',    `valid_start_time` datetime     DEFAULT NULL COMMENT '有效期开始时间',    `valid_end_time`   datetime     DEFAULT NULL COMMENT '有效期结束时间',    `stock`            int(11) DEFAULT NULL COMMENT '库存',    `receive_rule`     json         DEFAULT NULL COMMENT '领取规则',    `consume_rule`     json         DEFAULT NULL COMMENT '消耗规则',    `status`           tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束',    `create_time`      datetime     DEFAULT NULL COMMENT '创建时间',    `update_time`      datetime     DEFAULT NULL COMMENT '修改时间',    `del_flag`         tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',    PRIMARY KEY (`id`),    KEY                `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967789150785539 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_7` (    `id`               bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `name`             varchar(256) DEFAULT NULL COMMENT '优惠券名称',    `shop_number`      bigint(20) DEFAULT NULL COMMENT '店铺编号',    `source`           tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券',    `target`           tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用',    `goods`            varchar(64)  DEFAULT NULL COMMENT '优惠商品编码',    `type`             tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券',    `valid_start_time` datetime     DEFAULT NULL COMMENT '有效期开始时间',    `valid_end_time`   datetime     DEFAULT NULL COMMENT '有效期结束时间',    `stock`            int(11) DEFAULT NULL COMMENT '库存',    `receive_rule`     json         DEFAULT NULL COMMENT '领取规则',    `consume_rule`     json         DEFAULT NULL COMMENT '消耗规则',    `status`           tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束',    `create_time`      datetime     DEFAULT NULL COMMENT '创建时间',    `update_time`      datetime     DEFAULT NULL COMMENT '修改时间',    `del_flag`         tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',    PRIMARY KEY (`id`),    KEY                `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967780615376898 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; ​

2. 引入 ShardingSphere Maven Jar 依赖

<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core</artifactId> <version>5.3.2</version> </dependency>

3. 变更 Application.yaml 和创建 ShardingSphere 配置文件

ShardingSphere 自 5.x.x 版本后自定了框架数据库驱动,所以之前的 ShardingSphere 配置都是写到 application.yaml 中,现在需要拆分出来了。

spring: application: name: oneCoupon-merchant-admin datasource: url: jdbc:mysql://127.0.0.1:3306/one_coupon_rebuild?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai username: root password: root # 修改为 ShardingSphere 自定义驱动 spring: application: name: oneCoupon-merchant-admin datasource: driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver url: jdbc:shardingsphere:classpath:shardingsphere-config.yaml

创建 shardingsphere-config.yaml 数据库分片配置文件。

# 数据源集合 dataSources: # 自定义数据源名称,可以是 ds_0 也可以叫 datasource_0 都可以 ds_0: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://127.0.0.1:3306/one_coupon_rebuild_0?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai username: root password: root ds_1: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://127.0.0.1:3306/one_coupon_rebuild_1?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai username: root password: root rules: - !SHARDING tables: # 需要分片的数据库表集合 t_coupon_template: # 优惠券模板表 # 真实存在数据库中的物理表 actualDataNodes: ds_${0..1}.t_coupon_template_${0..8} databaseStrategy: # 分库策略 standard: # 单分片键分库 shardingColumn: shop_number # 分片键 shardingAlgorithmName: coupon_template_database_mod # 库分片算法名称,对应 rules[0].shardingAlgorithms tableStrategy: # 分表策略 standard: # 单分片键分表 shardingColumn: shop_number # 分片键 shardingAlgorithmName: coupon_template_table_mod # 表分片算法名称,对应 rules[0].shardingAlgorithms shardingAlgorithms: # 分片算法定义集合 coupon_template_database_mod: # 优惠券分库算法定义 type: HASH_MOD # 基于 Hash 方式分片 props: sharding-count: 2 # 一共有 2 个库 coupon_template_table_mod: # 优惠券分表算法定义 type: HASH_MOD # 基于 Hash 方式分片 props: sharding-count: 8 # 单库 8 张表 props: # 配置 ShardingSphere 默认打印 SQL 执行语句 sql-show: true

解释下其中涉及到的行表达式:

  • ds_${0..1} 意味着 ds_0、ds_1。
  • t_coupon_template_${0..8} 同上。

4. 什么是逻辑库、物理库、逻辑表、物理表?

我们新增一条优惠券模板时可以看到 ShardingSphere 打印的两条日志,分别是逻辑 SQL 和真实 SQL。

逻辑SQL: ​ 2024-08-15T17:32:55.566+08:00  INFO 259 --- [pool-1-thread-1] ShardingSphere-SQL                       : Logic SQL: INSERT INTO t_coupon_template ( id, shop_number, name, source, target,  type, valid_start_time, valid_end_time, stock, receive_rule, consume_rule, status, create_time, update_time, del_flag )  VALUES (  ?, ?, ?, ?, ?,  ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ​ 真实SQL: 2024-08-15T17:32:55.566+08:00  INFO 259 --- [pool-1-thread-1] ShardingSphere-SQL                       : Actual SQL: ds_0 ::: INSERT INTO t_coupon_template_6 ( id, shop_number, name, source, target,  type, valid_start_time, valid_end_time, stock, receive_rule, consume_rule, status, create_time, update_time, del_flag )  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ::: [1824016410750246914, 1824016410750300160, 商品立减券, 0, 1, 0, 2024-08-15 17:32:55.566, 2024-08-15 17:32:55.566, 10, {"limitPerPerson":1,"usageInstructions":"3"}, {"termsOfUse":10,"maximumDiscountAmount":3,"explanationOfUnmetC 3onditions":"3","validityPeriod":48}, 0, 2024-08-15 17:32:55.566, 2024-08-15 17:32:55.566, 0] ​

上面数据库分片数据源配置填的 ds_0ds_1 就是逻辑库,one_coupon_rebuild_0one_coupon_rebuild_1 对应物理库。

我们是没有变更任何业务代码的,所以逻辑 SQL 里的表名依然是 t_coupon_template,也就是逻辑表。真实 SQL 里的 t_coupon_template_6 是物理表。

物理表也叫做真实表,指的是数据库中真实存在的表。

5. 数据分片不均匀问题

大家可以使用下面这个 Mock 数据单元测试类跑一下,会出现分片不均匀问题。

  • MockCouponTemplateDataTests#mockCouponTemplateTest

表象就是 0 库的奇数表没有数据,1 库的偶数表没有值。

编辑

因为我们的分片键店铺编号经过数据库 Hash 后已经确定是奇数还是偶数了,所以哪怕 Hash 的数值(库和表数量)变了,但是依然只能是奇数和偶数。

所以这里我们需要变更 Hash 算法,通过自定义的 Hash 算法扰动分片结果。

5.1 修改数据库表

我们将 one_coupon_rebuild_1 数据库中的 t_coupon_template_0-7 表删除,创建以下数据库表:

CREATE TABLE `t_coupon_template_10` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `name` varchar(256) DEFAULT NULL COMMENT '优惠券名称', `shop_number` bigint(20) DEFAULT NULL COMMENT '店铺编号', `source` tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券', `target` tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用', `goods` varchar(64) DEFAULT NULL COMMENT '优惠商品编码', `type` tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券', `valid_start_time` datetime DEFAULT NULL COMMENT '有效期开始时间', `valid_end_time` datetime DEFAULT NULL COMMENT '有效期结束时间', `stock` int(11) DEFAULT NULL COMMENT '库存', `receive_rule` json DEFAULT NULL COMMENT '领取规则', `consume_rule` json DEFAULT NULL COMMENT '消耗规则', `status` tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除', PRIMARY KEY (`id`), KEY `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967787024273416 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_11` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `name` varchar(256) DEFAULT NULL COMMENT '优惠券名称', `shop_number` bigint(20) DEFAULT NULL COMMENT '店铺编号', `source` tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券', `target` tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用', `goods` varchar(64) DEFAULT NULL COMMENT '优惠商品编码', `type` tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券', `valid_start_time` datetime DEFAULT NULL COMMENT '有效期开始时间', `valid_end_time` datetime DEFAULT NULL COMMENT '有效期结束时间', `stock` int(11) DEFAULT NULL COMMENT '库存', `receive_rule` json DEFAULT NULL COMMENT '领取规则', `consume_rule` json DEFAULT NULL COMMENT '消耗规则', `status` tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除', PRIMARY KEY (`id`), KEY `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967787062022148 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_12` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `name` varchar(256) DEFAULT NULL COMMENT '优惠券名称', `shop_number` bigint(20) DEFAULT NULL COMMENT '店铺编号', `source` tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券', `target` tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用', `goods` varchar(64) DEFAULT NULL COMMENT '优惠商品编码', `type` tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券', `valid_start_time` datetime DEFAULT NULL COMMENT '有效期开始时间', `valid_end_time` datetime DEFAULT NULL COMMENT '有效期结束时间', `stock` int(11) DEFAULT NULL COMMENT '库存', `receive_rule` json DEFAULT NULL COMMENT '领取规则', `consume_rule` json DEFAULT NULL COMMENT '消耗规则', `status` tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除', PRIMARY KEY (`id`), KEY `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967795496767492 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_13` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `name` varchar(256) DEFAULT NULL COMMENT '优惠券名称', `shop_number` bigint(20) DEFAULT NULL COMMENT '店铺编号', `source` tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券', `target` tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用', `goods` varchar(64) DEFAULT NULL COMMENT '优惠商品编码', `type` tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券', `valid_start_time` datetime DEFAULT NULL COMMENT '有效期开始时间', `valid_end_time` datetime DEFAULT NULL COMMENT '有效期结束时间', `stock` int(11) DEFAULT NULL COMMENT '库存', `receive_rule` json DEFAULT NULL COMMENT '领取规则', `consume_rule` json DEFAULT NULL COMMENT '消耗规则', `status` tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除', PRIMARY KEY (`id`), KEY `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967817328119814 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_14` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `name` varchar(256) DEFAULT NULL COMMENT '优惠券名称', `shop_number` bigint(20) DEFAULT NULL COMMENT '店铺编号', `source` tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券', `target` tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用', `goods` varchar(64) DEFAULT NULL COMMENT '优惠商品编码', `type` tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券', `valid_start_time` datetime DEFAULT NULL COMMENT '有效期开始时间', `valid_end_time` datetime DEFAULT NULL COMMENT '有效期结束时间', `stock` int(11) DEFAULT NULL COMMENT '库存', `receive_rule` json DEFAULT NULL COMMENT '领取规则', `consume_rule` json DEFAULT NULL COMMENT '消耗规则', `status` tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除', PRIMARY KEY (`id`), KEY `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967817407811587 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_15` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `name` varchar(256) DEFAULT NULL COMMENT '优惠券名称', `shop_number` bigint(20) DEFAULT NULL COMMENT '店铺编号', `source` tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券', `target` tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用', `goods` varchar(64) DEFAULT NULL COMMENT '优惠商品编码', `type` tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券', `valid_start_time` datetime DEFAULT NULL COMMENT '有效期开始时间', `valid_end_time` datetime DEFAULT NULL COMMENT '有效期结束时间', `stock` int(11) DEFAULT NULL COMMENT '库存', `receive_rule` json DEFAULT NULL COMMENT '领取规则', `consume_rule` json DEFAULT NULL COMMENT '消耗规则', `status` tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除', PRIMARY KEY (`id`), KEY `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1811614173755469826 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_8` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `name` varchar(256) DEFAULT NULL COMMENT '优惠券名称', `shop_number` bigint(20) DEFAULT NULL COMMENT '店铺编号', `source` tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券', `target` tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用', `goods` varchar(64) DEFAULT NULL COMMENT '优惠商品编码', `type` tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券', `valid_start_time` datetime DEFAULT NULL COMMENT '有效期开始时间', `valid_end_time` datetime DEFAULT NULL COMMENT '有效期结束时间', `stock` int(11) DEFAULT NULL COMMENT '库存', `receive_rule` json DEFAULT NULL COMMENT '领取规则', `consume_rule` json DEFAULT NULL COMMENT '消耗规则', `status` tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除', PRIMARY KEY (`id`), KEY `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967783614304261 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表'; CREATE TABLE `t_coupon_template_9` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `name` varchar(256) DEFAULT NULL COMMENT '优惠券名称', `shop_number` bigint(20) DEFAULT NULL COMMENT '店铺编号', `source` tinyint(1) DEFAULT NULL COMMENT '优惠券来源 0:店铺券 1:平台券', `target` tinyint(1) DEFAULT NULL COMMENT '优惠对象 0:商品专属 1:全店通用', `goods` varchar(64) DEFAULT NULL COMMENT '优惠商品编码', `type` tinyint(1) DEFAULT NULL COMMENT '优惠类型 0:立减券 1:满减券 2:折扣券', `valid_start_time` datetime DEFAULT NULL COMMENT '有效期开始时间', `valid_end_time` datetime DEFAULT NULL COMMENT '有效期结束时间', `stock` int(11) DEFAULT NULL COMMENT '库存', `receive_rule` json DEFAULT NULL COMMENT '领取规则', `consume_rule` json DEFAULT NULL COMMENT '消耗规则', `status` tinyint(1) DEFAULT NULL COMMENT '优惠券状态 0:生效中 1:已结束', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除', PRIMARY KEY (`id`), KEY `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1810967778472087554 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表';

5.2 修改分片配置

有两个变更点,那就是数据库表的分片从每个数据库的 0..8 变更为所有数据库里的表 0..16,以及从框架自带的 HashMod 分片算法修改为自定义分片算法。分片算法见下文所示。

rules: - !SHARDING tables: # 需要分片的数据库表集合 t_coupon_template: # 优惠券模板表 # 真实存在数据库中的物理表 actualDataNodes: ds_${0..1}.t_coupon_template_${0..15} databaseStrategy: # 分库策略 standard: # 单分片键分库 shardingColumn: shop_number # 分片键 shardingAlgorithmName: coupon_template_database_mod # 库分片算法名称,对应 rules[0].shardingAlgorithms tableStrategy: # 分表策略 standard: # 单分片键分表 shardingColumn: shop_number # 分片键 shardingAlgorithmName: coupon_template_table_mod # 表分片算法名称,对应 rules[0].shardingAlgorithms shardingAlgorithms: # 分片算法定义集合 coupon_template_database_mod: # 优惠券分库算法定义 type: CLASS_BASED # 根据自定义库分片算法类进行分片 props: # 分片相关属性 # 自定义库分片算法Class algorithmClassName: com.nageoffer.onecoupon.merchant.admin.dao.sharding.DBHashModShardingAlgorithm sharding-count: 16 # 分片总数量 strategy: standard # 分片类型,单字段分片 coupon_template_table_mod: # 优惠券分表算法定义 type: CLASS_BASED # 根据自定义库分片算法类进行分片 props: # 分片相关属性 # 自定义表分片算法Class algorithmClassName: com.nageoffer.onecoupon.merchant.admin.dao.sharding.TableHashModShardingAlgorithm strategy: standard # 分片类型,单字段分片

5.2 创建自定义分片算法

创建自定义分片算法,包括分库和分表:

  • com.nageoffer.onecoupon.merchant.admin.dao.sharding.DBHashModShardingAlgorithm
  • com.nageoffer.onecoupon.merchant.admin.dao.sharding.TableHashModShardingAlgorithm

分库和分表算法类似,我们以分库算法解析:

@Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) { long id = shardingValue.getValue(); // 分片键值,也就是商家店铺编号 int dbSize = availableTargetNames.size(); // 一共有多少个真实的数据库,咱们就两个 ds_0、ds_1 int mod = (int) hashShardingValue(id) % shardingCount / (shardingCount / dbSize); // 取模 int index = 0; // 通过刚才的数据库下标,获取到数据库逻辑名称 ds_0 或者 ds_1 for (String targetName : availableTargetNames) { if (index == mod) { return targetName; } index++; } throw new IllegalArgumentException("No target found for value: " + id); }

修改完分片算法之后,我们调用上面的 Mock 单元测试类模拟 5 万数据到数据库中,通过查看数据量得知,不存在明显倾斜问题。

编辑

提示:对于没接触过分库分表的同学,没那么容易快速接受,大家可以多 Debug 分库分表算法类这块的代码。

常见问题答疑

1. 数据是否存在不均匀问题?

常规的 HashMod 方式会存在不均匀情况,通过自定义 Hash 分片算法已解决该问题。

2. 如果查询不走分片键会有什么问题?

会出现查询所有分片库的所有分片表,通过 UNION ALL 的形式关联,该举动存在读扩散问题,所以我们的查询一定要带上分片键。

读扩散 SQL 示例如下:

SELECT * FROM `t_coupon_template_0` WHERE name = '测试' UNION ALL SELECT * FROM `t_coupon_template_1` WHERE name = '测试' UNION ALL SELECT * FROM `t_coupon_template_2` WHERE name = '测试' UNION ALL SELECT * FROM `t_coupon_template_x` WHERE name = '测试'

ShardingSphere 通过这种形式查询所有库所有表,将所有符合条件的记录全部查询返回。这就是读扩散问题,极端情况下会存在性能深渊。

完结,撒花 🎉

▪第08小节:引入日志组件优雅记录操作日志

业务背景

系统操作日志是用于记录系统中用户或系统本身所执行的各类操作的日志信息。这些日志通常包括操作的时间、操作的用户、具体操作内容、操作结果以及其他相关信息。

  • 安全审计:记录用户操作以防止恶意行为,确保系统的安全性。
  • 问题排查:在系统出现问题时,可以通过操作日志快速定位问题来源。

编辑

Git 分支

20240816_dev_operation-log_mzt-biz-log_ding.ma

记录操作日志

如果我们在已有业务加上操作日志,时序图如下所示:

编辑

1. 数据库表设计

关于操作日志记录表,有两种设计思路:

  • 统一管理:t_operation_log,比如优惠券操作、权限操作等放在一张表。
  • 细粒度拆分:t_coupon_template_log,操作记录随着业务隔离。

如果系统不大业务不复杂我建议前者,如果系统挺大,我建议后者。

CREATE TABLE `t_coupon_template_log` (  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',  `shop_number` bigint(20) DEFAULT NULL COMMENT '店铺编号',  `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',  `operator_id` bigint(20) DEFAULT NULL COMMENT '操作人',  `operation_log` text COMMENT '操作日志',  `original_data` varchar(1024) DEFAULT NULL COMMENT '原始数据',  `modified_data` varchar(1024) DEFAULT NULL COMMENT '修改后数据',  `create_time` datetime DEFAULT NULL COMMENT '创建时间',  PRIMARY KEY (`id`),  KEY `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1817866003552428034 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表';

因为优惠券模板操作日志表会记录模板的创建、修改等任意操作,所以也需要分表,规则同优惠券模板表。

进入 one_coupon_rebuild_0 数据库,执行下述 SQL。

CREATE TABLE `t_coupon_template_log_0` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_1` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_2` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_3` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_4` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_5` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_6` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_7` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表';

再进入 one_coupon_rebuild_1 数据库,执行下述 SQL。

​ CREATE TABLE `t_coupon_template_log_10` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_11` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_12` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_13` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_14` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_15` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_8` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表'; ​ CREATE TABLE `t_coupon_template_log_9` (    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',    `operation_log`      text COMMENT '操作日志',    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',    PRIMARY KEY (`id`),    KEY                  `idx_shop_number` (`shop_number`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表';

2. 数据分片

因为分片规则基本等同于优惠券模板表,所以这里也就不再赘述。

3. 记录操作日志

我们先实现业务代码功能,代码如下:

@Override public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {    // ......    try {        String operationLog = String.format("%s 用户创建优惠券:%s,优惠对象:%s,优惠类型:%s,库存数量:%d,优惠商品编码:%s,有效期开始时间:%s,有效期结束时间:%s,领取规则:%s,消耗规则:%s;",                UserContext.getUsername(),                requestParam.getName(),                DiscountTargetEnum.findValueByType(requestParam.getTarget()),                DiscountTypeEnum.findValueByType(requestParam.getType()),                requestParam.getStock(),                requestParam.getGoods() == null ? "" : requestParam.getGoods(),                requestParam.getValidStartTime(),                requestParam.getValidEndTime(),                requestParam.getReceiveRule(),                requestParam.getConsumeRule()); ​        CouponTemplateLogDO couponTemplateLogDO = CouponTemplateLogDO.builder()               .couponTemplateId(String.valueOf(couponTemplateDO.getId()))               .operatorId(UserContext.getUserId())               .shopNumber(UserContext.getShopNumber())               .operationLog(operationLog)               .modifiedData(JSON.toJSONString(couponTemplateDO))               .build();        couponTemplateLogMapper.insert(couponTemplateLogDO);   } catch (Exception ex) {        log.error("记录操作日志错误", ex);   } }

缺点如下:

  • 当业务变得复杂后,记录操作日志放在业务代码中会导致业务的逻辑比较繁琐。
  • 对于代码的可读性和可维护性来说是一个灾难,因为不止这一处使用,存在大量的冗余代码。

通过 SpringAOP 和 SpEL 优雅记录

1. 什么是 SpringAOP?

Spring AOP (Aspect-Oriented Programming) 是 Spring 框架中的一个模块,它通过面向切面编程的方式将关注点(如日志记录、安全性、事务管理等)与业务逻辑代码分离,从而实现代码的模块化和复用。

推荐学习文章:

第一篇文章学习 SpringAOP 知识点,但是其中的示例还是 Spring XML 配置方式,如果想学习目前主流配置查看第二篇文章。

2. 什么是 SpEL?

SpEL 即 Spring 表达式语言,是一种强大的表达式语言,可以在运行时评估表达式并生成值。SpEL 最常用于 Spring Framework 中的注解等场景,也可以以编程方式在 Java 应用程序中使用。

SpEL 应用场景:

  • 动态参数配置:可以通过 SpEL 将应用程序中的各种参数配置化,例如配置文件中的数据库连接信息、业务规则等。通过动态配置,可以在运行时根据不同的环境或需求来进行灵活的参数设置。
  • 运行时注入:使用SpEL,可以在运行时动态注入属性值,而不需要在编码时硬编码。这对于需要根据当前上下文动态调整属性值的场景非常有用。
  • 条件判断与业务逻辑:SpEL支持复杂的条件判断和逻辑计算,可以方便地在运行时根据条件来执行特定的代码逻辑。例如,在权限控制中,可以使用SpEL进行资源和角色的动态授权判断。

SpEL 简单例子:

/** * SpEL 表达式测试类 */ public class CouponTemplateLogSpELTests { ​    /**     * 调用静态类方法     */    @Test    public void testSpELGetRandom() {        String spELKey = "T(java.lang.Math).random()";        ExpressionParser parser = new SpelExpressionParser();        Expression expression = parser.parseExpression(spELKey);        Assert.isTrue(expression.getValue() instanceof Double);   } ​    /**     * 调用静态类方法并运算     */    @Test    public void testSpELGetRandomV2() {        String spELKey = "T(java.lang.Math).random() * 100.0";        ExpressionParser parser = new SpelExpressionParser();        Expression expression = parser.parseExpression(spELKey);        Assert.isTrue(expression.getValue() instanceof Double);   } ​    /**     * 调用当前登录用户静态类方法     */    @Test    public void testSpELGetCurrentUser() {        // 初始化数据        String userid = "1810518709471555585";        UserContext.setUser(new UserInfoDTO(userid, "pdd45305558318", 1810714735922956666L)); ​        // 调用用户上下文获取当前用户 ID        String spELKey = "T(com.nageoffer.onecoupon.merchant.admin.common.context.UserContext).getUserId()";        ExpressionParser parser = new SpelExpressionParser();        Expression expression = parser.parseExpression(spELKey);        try {            Assert.equals(expression.getValue(), userid);       } finally {            UserContext.removeUser();       }   } ​    /**     * 调用当前登录用户静态类方法,如果为空取默认值     */    @Test    public void testSpELGetCurrentUserDefaultValue() {        // 调用用户上下文获取当前用户 ID,如果为空,取默认值        String spELKey = "T(com.nageoffer.onecoupon.merchant.admin.common.context.UserContext).getUserId() ?: 'ding.ma'";        ExpressionParser parser = new SpelExpressionParser();        Expression expression = parser.parseExpression(spELKey);        Assert.equals(expression.getValue(), "ding.ma");   } }

除了上面的简单逻辑之外,还支持很多特性,大家可以查看官方文章学习:Spring EL 表达式官方文档(英文版)

美团 mzt-biz-log 操作日志框架

1. mzt-biz-log 介绍

可能很多同学看过美团 2021 年发布一篇现象级文章:美团2021年最受欢迎的文章之一:如何优雅地记录操作日志?,也是关于操作日志的,没看过的建议看看。

作者将对应文章中的思路开源了,也就是今天要讲的 mzt-biz-log:支持 Springboot,基于注解的可使用变量、可以自定义函数的通用操作日志组件。

mzt-biz-log 以下简称 biz-log。

2. 如何选择造轮子和现有框架?

为什么使用现有框架而不是重复造轮子?

  • 效率提升。
  • 质量保障。
  • 持续更新

什么时候造轮子而不是选择现有框架?

  • 特定需求无法满足。
  • 性能优化。
  • 学习和探索。

3. 使用 mzt-biz-log 改造

3.1 引入 Maven 依赖

<dependency>    <groupId>io.github.mouzt</groupId>    <artifactId>bizlog-sdk</artifactId> <version>3.0.6</version> </dependency>

3.2 应用添加启动注解

应用启动类添加 @EnableLogRecord 注解,并配置租户。tenant 是代表租户的标识,一般一个服务或者一个业务下的多个服务都固定一个 tenant 就可以。

@EnableLogRecord(tenant = "MerchantAdmin") public class MerchantAdminApplication {    // ...... }

3.3 添加注解 @LogRecord

@LogRecord(        success = """                创建优惠券:{{#requestParam.name}}, \                优惠对象:{{#requestParam.target}}, \                优惠类型:{{#requestParam.type}}, \                库存数量:{{#requestParam.stock}}, \                优惠商品编码:{{#requestParam.goods}}, \                有效期开始时间:{{#requestParam.validStartTime}}, \                有效期结束时间:{{#requestParam.validEndTime}}, \                领取规则:{{#requestParam.receiveRule}}, \                消耗规则:{{#requestParam.consumeRule}};                """,        type = "CouponTemplate",        bizNo = "{{#bizNo}}",        extra = "{{#requestParam.toString()}}" ) @Override public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {    // ...... }

解析下上面四个注解属性分别对应什么语义:

  • success:方法执行成功后的日志模版。
  • type:操作日志的类型,比如:订单类型、商品类型。
  • bizNo:日志绑定的业务标识,需要是我们优惠券模板的 ID,但是目前拿不到,放一个占位符。
  • extra:日志的额外信息。

加上改方法后,可以看到打印日志如下所示:

2024-08-16T23:47:46.838+08:00 INFO 16761 --- [io-10010-exec-1] c.m.l.s.i.DefaultLogRecordServiceImpl   : 【logRecord】log=LogRecord(id=null, tenant=MerchantAdmin, type=CouponTemplate, subType=, bizNo={{#bizNo}}, operator=111, action=创建优惠券:用户下单满10减3特大优惠,优惠对象:1,优惠类型:0,库存数量:20990,优惠商品编码:,有效期开始时间:Mon Jul 08 12:00:00 CST 2024,有效期结束时间:Tue Jul 08 12:00:00 CST 2025,领取规则:{"limitPerPerson":10,"usageInstructions":"3"},消耗规则:{"termsOfUse":10,"maximumDiscountAmount":3,"explanationOfUnmetConditions":"3","validityPeriod":"48"};, fail=false, createTime=Fri Aug 16 23:47:46 CST 2024, extra={"consumeRule":"{\"termsOfUse\":10,\"maximumDiscountAmount\":3,\"explanationOfUnmetConditions\":\"3\",\"validityPeriod\":\"48\"}","goods":"","name":"用户下单满10减3特大优惠","receiveRule":"{\"limitPerPerson\":10,\"usageInstructions\":\"3\"}","source":0,"stock":20990,"target":1,"type":0,"validEndTime":"2025-07-08 12:00:00","validStartTime":"2024-07-08 12:00:00"}, codeVariable={MethodName=createCouponTemplate, ClassName=class com.nageoffer.onecoupon.merchant.admin.service.impl.CouponTemplateServiceImpl})

看到该日志打印,证明我们的 @LogRecord 注解生效了。但是,上面这些信息还是有一些问题,那就是优惠对象和优惠券类型都是 type 值,我们希望展示的时候是具体值,这种应该怎么解决?

3.4 自定义函数

上面 biz-log 提到过,可以自定义函数,我们可以试试这个函数功能。仅需要实现 IParseFunction 接口即可完成自定义函数,非常方便。

@Component public class CommonEnumParseFunction implements IParseFunction { ​    public static final String DISCOUNT_TARGET_ENUM_NAME = DiscountTargetEnum.class.getSimpleName();    private static final String DISCOUNT_TYPE_ENUM_NAME = DiscountTypeEnum.class.getSimpleName(); ​    @Override    public String functionName() {        return "COMMON_ENUM_PARSE";   } ​    @Override    public String apply(Object value) {        try {            List<String> parts = StrUtil.split(value.toString(), "_");            if (parts.size() != 2) {                throw new IllegalArgumentException("格式错误,需要 '枚举类_具体值' 的形式。");           } ​            String enumClassName = parts.get(0);            int enumValue = Integer.parseInt(parts.get(1)); ​            return findEnumValueByName(enumClassName, enumValue);       } catch (NumberFormatException e) {            throw new IllegalArgumentException("第二个下划线后面的值需要是整数。", e);       }   } ​    private String findEnumValueByName(String enumClassName, int enumValue) {        if (DISCOUNT_TARGET_ENUM_NAME.equals(enumClassName)) {            return DiscountTargetEnum.findValueByType(enumValue);       } else if (DISCOUNT_TYPE_ENUM_NAME.equals(enumClassName)) {            return DiscountTypeEnum.findValueByType(enumValue);       } else {            throw new IllegalArgumentException("未知的枚举类名: " + enumClassName);       }   } }

COMMON_ENUM_PARSE 是这个函数的标识,加到 success 字符串变量中,即可自动完成解析。如果检查到 success 包含自定义函数,交由 IParseFunction#apply 方法执行。

业务代码改造如下:

@LogRecord(        success = """                创建优惠券:{{#requestParam.name}}, \                优惠对象:{COMMON_ENUM_PARSE{'DiscountTargetEnum' + '_' + #requestParam.target}}, \                优惠类型:{COMMON_ENUM_PARSE{'DiscountTypeEnum' + '_' + #requestParam.type}}, \                库存数量:{{#requestParam.stock}}, \                优惠商品编码:{{#requestParam.goods}}, \                有效期开始时间:{{#requestParam.validStartTime}}, \                有效期结束时间:{{#requestParam.validEndTime}}, \                领取规则:{{#requestParam.receiveRule}}, \                消耗规则:{{#requestParam.consumeRule}};                """,        type = "CouponTemplate",        bizNo = "{{#bizNo}}",        extra = "{{#requestParam.toString()}}" ) @Override public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {    // ...... }

打印日志如下,可以看到已将 type 值切换为具体名称。

2024-08-17T00:00:28.540+08:00  INFO 16923 --- [io-10010-exec-1] c.m.l.s.i.DefaultLogRecordServiceImpl   : 【logRecord】log=LogRecord(id=null, tenant=MerchantAdmin, type=CouponTemplate, subType=, bizNo={{#bizNo}}, operator=111, action=创建优惠券:用户下单满10减3特大优惠, 优惠对象:全店通用优惠, 优惠类型:立减券, 库存数量:20990, 优惠商品编码:, 有效期开始时间:Mon Jul 08 12:00:00 CST 2024, 有效期结束时间:Tue Jul 08 12:00:00 CST 2025, 领取规则:{"limitPerPerson":10,"usageInstructions":"3"}, 消耗规则:{"termsOfUse":10,"maximumDiscountAmount":3,"explanationOfUnmetConditions":"3","validityPeriod":"48"}; , fail=false, createTime=Sat Aug 17 00:00:28 CST 2024, extra={"consumeRule":"{\"termsOfUse\":10,\"maximumDiscountAmount\":3,\"explanationOfUnmetConditions\":\"3\",\"validityPeriod\":\"48\"}","goods":"","name":"用户下单满10减3特大优惠","receiveRule":"{\"limitPerPerson\":10,\"usageInstructions\":\"3\"}","source":0,"stock":20990,"target":1,"type":0,"validEndTime":"2025-07-08 12:00:00","validStartTime":"2024-07-08 12:00:00"}, codeVariable={MethodName=createCouponTemplate, ClassName=class com.nageoffer.onecoupon.merchant.admin.service.impl.CouponTemplateServiceImpl})

3.5 日志记录上下文

上面还提到一个点,那就是 bizNo 这个值还空着呢。正常应该是优惠券模板的 ID,但是优惠券模板 ID 我们也拿不到,因为是临时生成的。

@Override public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {    // 新增优惠券模板信息到数据库    CouponTemplateDO couponTemplateDO = BeanUtil.toBean(requestParam, CouponTemplateDO.class);    couponTemplateDO.setStatus(CouponTemplateStatusEnum.ACTIVE.getStatus());    couponTemplateDO.setShopNumber(UserContext.getShopNumber());    couponTemplateMapper.insert(couponTemplateDO);    // 新增到数据库成功后,会自动为 couponTemplateDO 赋值 ID }

biz-log 为我们提供了日志记录上下文功能,将值放到上下文 LogRecordContext 里面,我们就能在运行时拿到。

代码如下所示:

@LogRecord(        success = """                创建优惠券:{{#requestParam.name}}, \                优惠对象:{COMMON_ENUM_PARSE{'DiscountTargetEnum' + '_' + #requestParam.target}}, \                优惠类型:{COMMON_ENUM_PARSE{'DiscountTypeEnum' + '_' + #requestParam.type}}, \                库存数量:{{#requestParam.stock}}, \                优惠商品编码:{{#requestParam.goods}}, \                有效期开始时间:{{#requestParam.validStartTime}}, \                有效期结束时间:{{#requestParam.validEndTime}}, \                领取规则:{{#requestParam.receiveRule}}, \                消耗规则:{{#requestParam.consumeRule}};                """,        type = "CouponTemplate",        bizNo = "{{#bizNo}}",        extra = "{{#requestParam.toString()}}" ) @Override public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {    // ......    // 新增优惠券模板信息到数据库    CouponTemplateDO couponTemplateDO = BeanUtil.toBean(requestParam, CouponTemplateDO.class);    couponTemplateDO.setStatus(CouponTemplateStatusEnum.ACTIVE.getStatus());    couponTemplateDO.setShopNumber(UserContext.getShopNumber());    couponTemplateMapper.insert(couponTemplateDO);        // 因为模板 ID 是运行中生成的,@LogRecord 默认拿不到,所以我们需要手动设置    LogRecordContext.putVariable("bizNo", couponTemplateDO.getId()); }

这样我们在 bizNo 字段中可以通过 SpEL 表达式获取对应的值。

LogRecordContext 会在方法结束后自动 Remove,所以不需要我们手动操作。

日志打印如下,可以看到 bizNo 已经有具体的值了。

2024-08-17T00:06:05.691+08:00  INFO 16978 --- [io-10010-exec-2] c.m.l.s.i.DefaultLogRecordServiceImpl   : 【logRecord】log=LogRecord(id=null, tenant=MerchantAdmin, type=CouponTemplate, subType=, bizNo=1824477740594647042, operator=111, action=创建优惠券:用户下单满10减3特大优惠, 优惠对象:全店通用优惠, 优惠类型:立减券, 库存数量:20990, 优惠商品编码:, 有效期开始时间:Mon Jul 08 12:00:00 CST 2024, 有效期结束时间:Tue Jul 08 12:00:00 CST 2025, 领取规则:{"limitPerPerson":10,"usageInstructions":"3"}, 消耗规则:{"termsOfUse":10,"maximumDiscountAmount":3,"explanationOfUnmetConditions":"3","validityPeriod":"48"}; , fail=false, createTime=Sat Aug 17 00:06:05 CST 2024, extra={"consumeRule":"{\"termsOfUse\":10,\"maximumDiscountAmount\":3,\"explanationOfUnmetConditions\":\"3\",\"validityPeriod\":\"48\"}","goods":"","name":"用户下单满10减3特大优惠","receiveRule":"{\"limitPerPerson\":10,\"usageInstructions\":\"3\"}","source":0,"stock":20990,"target":1,"type":0,"validEndTime":"2025-07-08 12:00:00","validStartTime":"2024-07-08 12:00:00"}, codeVariable={ClassName=class com.nageoffer.onecoupon.merchant.admin.service.impl.CouponTemplateServiceImpl, MethodName=createCouponTemplate})

3.6 保存数据库

biz-log 中为我们预留了扩展接口,实现 ILogRecordService 接口就可以自定义保存方法。

@Slf4j @Service @RequiredArgsConstructor public class DBLogRecordServiceImpl implements ILogRecordService { ​    private final CouponTemplateLogMapper couponTemplateLogMapper; ​    @Override    public void record(LogRecord logRecord) {        try {            switch (logRecord.getType()) {                case "CouponTemplate": {                    CouponTemplateLogDO couponTemplateLogDO = CouponTemplateLogDO.builder()                           .couponTemplateId(logRecord.getBizNo())                           .shopNumber(UserContext.getShopNumber())                           .operatorId(UserContext.getUserId())                           .operationLog(logRecord.getAction())                           .originalData(Optional.ofNullable(LogRecordContext.getVariable("originalData")).map(Object::toString).orElse(null))                           .modifiedData(StrUtil.isBlank(logRecord.getExtra()) ? null : logRecord.getExtra())                           .build();                    couponTemplateLogMapper.insert(couponTemplateLogDO);               }           }       } catch (Exception ex) {            log.error("记录[{}]操作日志失败", logRecord.getType(), ex);       }   } ​    @Override    public List<LogRecord> queryLog(String bizNo, String type) {        return List.of();   } ​    @Override    public List<LogRecord> queryLogByBizNo(String bizNo, String type, String subType) {        return List.of();   } }

至此,我们的操作日志即完成,后续商家有问题或者说平台自查优惠券相关的内容,都可以通过操作日志留痕排查。

常见问题答疑

Q:操作日志失败,需要回滚整个优惠券操作么?

A:不建议,我们是没有回滚的。如果说操作记录保存失败,应该打印异常日志以及报警。如果说对操作日志零容忍,可以选择事务。

Q:操作日志可以异步么?

A:视情况而定,如果说并发量比较小,可以同步执行。如果并发量比较大,建议在 DBLogRecordServiceImpl#record 方法中调用消息队列异步。

▪第09小节:基于注解实现分布式锁防重复提交

业务背景

商家用户在优惠券管理系统中,点击“创建优惠券”按钮来生成一个新的优惠券。这个操作通常是在一个表单提交页面上完成的。商家填写了必要的信息(如优惠券名称、金额、有效期等),然后点击了创建按钮,后端系统能够生成并保存这个优惠券。

在用户点击“创建优惠券”按钮后,可能会出现重复提交的问题。这可能是由于以下原因造成的:

  • 网络延迟:用户的网络连接可能有延迟,或者用户无意中刷新了页面,导致按钮被点击多次,系统接收到多个相同的请求。
  • 按钮未禁用:前端页面中的按钮在用户点击后没有及时禁用,导致用户可以多次点击,从而发起多个创建请求。
  • 系统处理延迟:系统在处理请求时可能出现延迟,用户误以为请求没有成功,从而重复提交相同的请求。

编辑

为此,我们需要在后端系统中防重复提交的逻辑解决可能出现的问题。

从交互上来说,可以把前端按钮禁用先完成,然后才是后端工程编码。

Git 分支

20240817_dev_no-duplicate-submit_lock_ding.ma

通过分布式锁防重复提交

1. 能不能用本地锁?

常见的本地锁有两种:synchronizedReentrantLock

synchronized 是 Java 中的一种内置锁机制,用于在代码块或方法上实现线程同步。

public synchronized void synchronizedMethod() {    // 线程安全的代码 }

ReentrantLockjava.util.concurrent.locks 包下的锁实现,它提供了更多的控制和灵活性。

private final ReentrantLock lock = new ReentrantLock(); ​ public void method() {    lock.lock(); // 加锁    try {        // 线程安全的代码   } finally {        lock.unlock(); // 解锁,确保在最终块中释放锁   } }

为什么不能用本地锁?

  • 范围有限:本地锁仅在应用程序的单个实例中有效。如果你的应用程序在多台服务器上运行(即分布式环境),每个实例的本地锁相互独立,无法在集群中共享锁状态。
  • 竞争条件:不同实例上的本地锁无法相互感知,这意味着多个实例可能同时认为自己获得了锁,从而导致并发冲突。

而且,上面锁定的话,单个实例里的逻辑就变成串行了。如果想让不同的用户、不同的参数并行执行,还需要额外代码控制。

2. 什么是分布式锁?

分布式锁是一种用于在分布式系统中协调多个节点对共享资源的访问的机制。它确保在多个节点并发访问时,只有一个节点可以在某个时刻拥有特定资源的访问权,从而避免数据不一致、竞争条件或资源冲突的问题。

目前市场主要是以 Redis 实现的分布式锁为主,其中 Redisson 这个工具包中的分布式锁功能用的较多。

分布式锁比较关键的一个概念就是分布式锁 Key,这个应该如何定义?我们由以下几部分组成:

  • 分布式锁前缀。
  • 请求路径。
  • 当前访问用户。
  • 参数 MD5。

代码如下所示:

private final RedissonClient redissonClient; ​ @Override public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {    // 获取分布式锁标识    String lockKey = String.format("no-duplicate-submit:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(requestParam));    RLock lock = redissonClient.getLock(lockKey); ​    // 尝试获取锁,获取锁失败就意味着已经重复提交,直接抛出异常    if (!lock.tryLock()) {        throw new ClientException("请勿短时间内重复提交优惠券模板");   } ​    try {        // 执行常规业务代码        // ......   } finally {        lock.unlock();   } } ​ /** * @return 获取当前线程上下文 ServletPath */ private String getServletPath() {    ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();    return sra.getRequest().getServletPath(); } ​ /** * @return 当前操作用户 ID */ private String getCurrentUserId() {    // 用户属于非核心功能,这里先通过模拟的形式代替。后续如果需要后管展示,会重构该代码    return "1810518709471555585"; } ​ /** * @return joinPoint md5 */ private String calcArgsMD5(CouponTemplateSaveReqDTO requestParam) {    return DigestUtil.md5Hex(JSON.toJSONBytes(requestParam)); }

3. Jmeter 压力测试

我们可以尝试使用 Jmeter 压测下试试看能不能防止重复提交呢?

注意不要用 Chrome 开多个页面访问,Chrome 访问是单线程的,上一个请求不返回,下一个不执行。

因为执行逻辑太快了,可能压力测试没办法顺时触发,我们可以添加睡眠操作。

@Override public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {    // 获取分布式锁标识    String lockKey = String.format("no-duplicate-submit:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(requestParam));    RLock lock = redissonClient.getLock(lockKey); ​    // 尝试获取锁,获取锁失败就意味着已经重复提交,直接抛出异常    if (!lock.tryLock()) {        throw new ClientException("请勿短时间内重复提交优惠券模板");   } ​    try {        // 睡眠 2 秒        Thread.sleep(2000);   } catch (InterruptedException e) {        throw new RuntimeException(e);   } ​    try {        // 执行常规业务代码        // ......   } finally {        lock.unlock();   } }

同时启动 20 个线程执行创建优惠券模板接口。

编辑

通过 Jmeter 压测得知:

编辑

为什么错误的请求前面图标还是绿的?因为 HTTP 返回码是 200,只是在返回体里做了异常标识。

通过这个代码可以解决我们的优惠券防重复提交问题。虽然业务问题解决了,但是遇到了和上一节操作日志记录一样的问题。

  • 当业务变得复杂后,记录操作日志放在业务代码中会导致业务的逻辑比较繁琐。
  • 对于代码的可读性和可维护性来说是一个灾难,因为不止这一处使用,存在大量的冗余代码。

上一节使用 SpringAOP、SpEL 以及现成框架解决的优雅记录操作日志,这次我们自己通过 SpringAOP、注解和分布式锁实现一个通用组件。

自定义组件库

如果常规来说,我们会按照 12306 的组件定义格式,每个功能实现一个组件库,为了避免大家从零到一实现的编码困难度,选择写入 framework 模块中。

编辑

12306 学习地址:https://gitee.com/nageoffer/12306

1. 自定义注解

很多同学没有创建过注解,和平常创建类一直,选择 Annotation 即可。

编辑

自定义 Java 注解,我们取名 @NoDuplicateSubmit 防止重复提交。

package com.nageoffer.onecoupon.framework.idempotent; ​ import java.lang.annotation.ElementType; import java.lang.annotation.Target; ​ /** * 幂等注解,防止用户重复提交表单信息 * <p> * 作者:马丁 * 加项目群:早加入就是优势!500人内部项目群,分享的知识总有你需要的 <a href="https://t.zsxq.com/cw7b9" /> * 开发时间:2024-07-10 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface NoDuplicateSubmit { ​    /**     * 触发幂等失败逻辑时,返回的错误提示信息     */    String message() default "您操作太快,请稍后再试"; }

自定义注解上还有两个注解:

  • @Target(ElementType.METHOD) 意味着只能在方法上使用。
  • @Retention(RetentionPolicy.RUNTIME) 意味着可以通过反射获取注解内的信息。

定义的 message 属性可以让大家在使用的时候自定义错误提示信息,一个小优化。

2. 自定义 SpringAOP 切面

通过 SpringAOP 环绕通知对方法增强,操作流程如下:

编辑

framework 模块 Pom.xml 文件添加 AOP 的依赖配置。

<dependency>    <groupId>org.aspectj</groupId>    <artifactId>aspectjweaver</artifactId> </dependency>

防重复提交 AOP 代码如下所示:

package com.nageoffer.onecoupon.framework.idempotent; ​ import cn.hutool.crypto.digest.DigestUtil; import com.alibaba.fastjson2.JSON; import com.nageoffer.onecoupon.framework.exception.ClientException; import lombok.RequiredArgsConstructor; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; ​ import java.lang.reflect.Method; ​ /** * 防止用户重复提交表单信息切面控制器 * <p> * 作者:马丁 * 加项目群:早加入就是优势!500人内部项目群,分享的知识总有你需要的 <a href="https://t.zsxq.com/cw7b9" /> * 开发时间:2024-07-10 */ @Aspect @RequiredArgsConstructor public final class NoDuplicateSubmitAspect { ​    private final RedissonClient redissonClient; ​    /**     * 增强方法标记 {@link NoDuplicateSubmit} 注解逻辑     */    @Around("@annotation(com.nageoffer.onecoupon.framework.idempotent.NoDuplicateSubmit)")    public Object noDuplicateSubmit(ProceedingJoinPoint joinPoint) throws Throwable {        NoDuplicateSubmit noDuplicateSubmit = getNoDuplicateSubmitAnnotation(joinPoint);        // 获取分布式锁标识        String lockKey = String.format("no-duplicate-submit:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(joinPoint));        RLock lock = redissonClient.getLock(lockKey);        // 尝试获取锁,获取锁失败就意味着已经重复提交,直接抛出异常        if (!lock.tryLock()) {            throw new ClientException(noDuplicateSubmit.message());       }        Object result;        try {            // 执行标记了防重复提交注解的方法原逻辑            result = joinPoint.proceed();       } finally {            lock.unlock();       }        return result;   } ​    /**     * @return 返回自定义防重复提交注解     */    public static NoDuplicateSubmit getNoDuplicateSubmitAnnotation(ProceedingJoinPoint joinPoint) throws NoSuchMethodException {        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();        Method targetMethod = joinPoint.getTarget().getClass().getDeclaredMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());        return targetMethod.getAnnotation(NoDuplicateSubmit.class);   } ​    /**     * @return 获取当前线程上下文 ServletPath     */    private String getServletPath() {        ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();        return sra.getRequest().getServletPath();   } ​    /**     * @return 当前操作用户 ID     */    private String getCurrentUserId() {        // 用户属于非核心功能,这里先通过模拟的形式代替。后续如果需要后管展示,会重构该代码        return "1810518709471555585";   } ​    /**     * @return joinPoint md5     */    private String calcArgsMD5(ProceedingJoinPoint joinPoint) {        return DigestUtil.md5Hex(JSON.toJSONBytes(joinPoint.getArgs()));   } }

3. 幂等自动装配类

之前讲过,Starter 组件库里不能通过类上加注解成为 Bean,我们创建幂等自动装配类。

package com.nageoffer.onecoupon.framework.config; ​ import com.nageoffer.onecoupon.framework.idempotent.NoMQDuplicateConsumeAspect; import com.nageoffer.onecoupon.framework.idempotent.NoDuplicateSubmitAspect; import org.redisson.api.RedissonClient; import org.springframework.context.annotation.Bean; import org.springframework.data.redis.core.StringRedisTemplate; ​ /** * 幂等组件相关配置类 * <p> * 作者:马丁 * 加项目群:早加入就是优势!500人内部项目群,分享的知识总有你需要的 <a href="https://t.zsxq.com/cw7b9" /> * 开发时间:2024-07-10 */ public class IdempotentConfiguration { ​    /**     * 防止用户重复提交表单信息切面控制器     */    @Bean    public NoDuplicateSubmitAspect noDuplicateSubmitAspect(RedissonClient redissonClient) {        return new NoDuplicateSubmitAspect(redissonClient);   } }

4. 配置自动发现

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 追加幂等自动装配全限定路径。

com.nageoffer.onecoupon.framework.config.WebAutoConfiguration com.nageoffer.onecoupon.framework.config.IdempotentConfiguration

5. 控制层引入注解

我们可以把 Service 业务方法里的分布式锁等和业务无关的代码全部删除,仅在 Controller 上添加 @NoDuplicateSubmit 注解即可完成防重复提交功能。

@NoDuplicateSubmit @Operation(summary = "商家创建优惠券模板") @PostMapping("/api/merchant-admin/coupon-template/create") public Result<Void> createCouponTemplate(@RequestBody CouponTemplateSaveReqDTO requestParam) {    couponTemplateService.createCouponTemplate(requestParam);    return Results.success(); }

并发单元测试

我们通过并发编程单元测试模拟用户防重复提交流程,代码如下:

package com.nageoffer.onecoupon.merchant.admin.template.lock; ​ import com.alibaba.fastjson2.JSON; import com.nageoffer.onecoupon.merchant.admin.controller.CouponTemplateController; import com.nageoffer.onecoupon.merchant.admin.dto.req.CouponTemplateSaveReqDTO; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; ​ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; ​ /** * 优惠券模板防重复提交测试 */ @Slf4j @SpringBootTest public class CouponTemplateCreateDuplicateSubmitTests { ​    @Autowired    private CouponTemplateController couponTemplateController; ​    @SneakyThrows    @Test    public void testDuplicateSubmit() {        ExecutorService executorService = Executors.newFixedThreadPool(10);        String paramJSONStr = """               {                  "name": "用户下单满10减3特大优惠",                  "source": 0,                  "target": 1,                  "goods": "",                  "type": 0,                  "validStartTime": "2024-07-08 12:00:00",                  "validEndTime": "2024-08-17 12:00:00",                  "stock": 20990,                  "receiveRule": "{\\"limitPerPerson\\":10,\\"usageInstructions\\":\\"3\\"}",                  "consumeRule": "{\\"termsOfUse\\":10,\\"maximumDiscountAmount\\":3,\\"explanationOfUnmetConditions\\":\\"3\\",\\"validityPeriod\\":\\"48\\"}"               }                """; ​        MockHttpServletRequest request = new MockHttpServletRequest();        ServletRequestAttributes attributes = new ServletRequestAttributes(request); ​        for (int i = 0; i < 10; i++) {            executorService.execute(() -> {                RequestContextHolder.setRequestAttributes(attributes);  // 将 ServletRequestAttributes 绑定到当前线程                try {                    couponTemplateController.createCouponTemplate(JSON.parseObject(paramJSONStr, CouponTemplateSaveReqDTO.class));               } catch (Exception ex) {                    log.error("新增优惠券模板异常", ex);               } finally {                    RequestContextHolder.resetRequestAttributes(); // 确保当前线程中的 RequestAttributes 被清除               }           });       }        executorService.shutdown();        while (!executorService.isTerminated()) {            Thread.sleep(1000);       }   } }

注意:如果使用了星球公有云中间件,大家直接启动上面的程序会报错,因为测试用例没办法直接关联云中间件地址,而是要单独把相关 VM 配置再复制一份到 -ea 的后面。

image-20250607110731832.png编辑

不负众望,最终还是报错了,哈哈。

编辑

完结,撒花 🎉。 ​