【Postopia Dev Log】Week 12

todo:

  • 投票
  • 微服务改造

later:

  • 用户行为跟踪,日志分析
  • 语义搜索
  • 推送
    • AI 喜好分析
  • 性能 optimize
    • search after 改造
    • hgetall pipeline优化

定时回调

在Spring Boot中实现定时执行回调函数并传递参数,可以通过以下几种方式实现,结合不同的场景和需求选择合适的方法:


使用 @Scheduled 注解 + 方法参数传递

适用于简单的静态定时任务,通过 @Scheduled 注解定义延迟时间,并在方法中直接接收参数:

@Component
public class ScheduledTask {
    // 延迟5秒后执行,并传递参数
    @Scheduled(initialDelay = 5000, fixedDelay = Long.MAX_VALUE)
    public void delayedTask(String param) {
        System.out.println("执行任务,参数:" + param);
    }

    // 通过其他方法触发带参数的定时任务
    public void triggerTaskWithParam(String param) {
        delayedTask(param); // 直接调用(需注意线程安全问题)
    }
}

注意:此方式需确保参数在调用时已注入,且需手动触发任务(如通过其他方法调用)。


动态任务注册(SchedulingConfigurer 接口)

适用于需要运行时动态调整参数或延迟时间的场景,通过编程方式注册任务:

@Configuration
@EnableScheduling
public class DynamicSchedulerConfig implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        // 动态添加任务:延迟10秒执行,并传递参数
        registrar.addTriggerTask(
            () -> System.out.println("执行任务,参数:" + customParam),
            triggerContext -> {
                Date nextExecutionTime = new Date(System.currentTimeMillis() + 10000);
                return nextExecutionTime;
            }
        );
    }
}

特点

  • 可结合数据库或配置中心动态加载参数。
  • 支持灵活的时间控制(如根据条件计算延迟时间)。

使用 ThreadPoolTaskScheduler 异步调度

适用于需要异步执行或管理线程池的场景:

@Bean
public ThreadPoolTaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    scheduler.setPoolSize(5);
    return scheduler;
}

// 在业务代码中调度任务
@Autowired
private ThreadPoolTaskScheduler taskScheduler;

public void scheduleTaskWithParam(String param) {
    taskScheduler.schedule(
        () -> System.out.println("异步执行,参数:" + param),
        new Date(System.currentTimeMillis() + 5000) // 延迟5秒
    );
}

优势

  • 支持异步执行,避免阻塞主线程。
  • 可手动取消任务(通过返回的 ScheduledFuture 对象)。

结合 @Async 注解实现异步延迟

若需在异步环境下执行任务并传参:

@Component
public class AsyncTask {
    @Async
    public void executeWithDelay(String param, long delay) {
        try {
            Thread.sleep(delay);
            System.out.println("异步延迟执行,参数:" + param);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

// 调用示例
@Autowired
private AsyncTask asyncTask;

public void triggerAsyncTask() {
    asyncTask.executeWithDelay("test", 5000); // 延迟5秒
}

适用场景:需要与其他异步任务结合,或对线程控制有更高要求时。


参数传递的注意事项

  • 作用域问题:若通过方法参数传递,需确保参数在任务执行时仍有效(避免使用局部变量)。
  • 线程安全:动态任务中若涉及共享参数,需使用线程安全的数据结构(如 ConcurrentHashMap)。
  • 外部化配置:可通过 @Value 注解从配置文件读取参数,或结合数据库动态加载。

最佳实践选择

  • 简单场景:优先使用 @Scheduled(initialDelay) + 方法参数。
  • 动态需求:选择 SchedulingConfigurerThreadPoolTaskScheduler
  • 异步控制:结合 @Async 和线程池管理。

具体实现需根据业务复杂度、参数动态性及执行环境灵活调整。

Vote索引设计

解决方案分析

方案一:将 relatedId 改为 String 并添加前缀

优点

  1. 单字段存储所有关联类型:通过前缀(如 post_123comment_456)直接标识关联类型和ID,简化表结构,无需额外字段。
  2. 查询灵活性:前缀可以快速识别关联类型,避免联合字段查询时的复杂条件判断。

缺点

  1. 索引性能问题:字符串类型(如 post_123)的索引长度和比较效率低于数值类型(Long),可能影响查询速度。
  2. 数据冗余和维护成本:前缀逻辑需要应用层维护,例如解析字符串、生成前缀等,增加代码复杂度。
  3. 扩展性问题:新增类型时需要修改前缀逻辑,可能影响已有数据。

方案二:新增字段区分类型(如 relatedType

优点

  1. 类型与ID分离:通过 relatedType(枚举或字符串)和 relatedId(保持 Long)分离关注点,逻辑更清晰。
  2. 索引优化:可针对 relatedTyperelatedId 创建复合索引,提升查询效率。
  3. 扩展性更强:新增类型无需修改字段结构,只需扩展枚举值。

缺点

  1. 表结构略微复杂:需要维护两个字段,但对数据库设计来说是常规操作。

PostgreSQL 单一索引与复合索引的性能差异

  1. 单一索引

    • 适用场景:仅对单个字段频繁查询(如仅按 relatedIdrelatedType 查询)。
    • 缺点
      • 多条件查询需合并索引:例如同时按 relatedTyperelatedId 查询时,需要扫描多个索引并通过位图合并结果,增加 CPU 和内存开销。
      • 无法覆盖排序:若查询需要对多列排序(如 ORDER BY relatedType, relatedId),单一索引无法优化。
  2. 复合索引

    • 适用场景:高频多条件查询(如 WHERE relatedType = 'post' AND relatedId = 123)。
    • 优点
      • 高效过滤和排序:复合索引直接覆盖多列查询和排序,减少随机 I/O 和回表次数。
      • 索引覆盖查询:若索引包含所有查询字段(如 (relatedType, relatedId)),可避免访问主表数据(Index-Only Scan)。
    • 设计要点:列顺序需优先选择高区分度的字段(如 relatedType 在前,relatedId 在后)。

推荐方案

  1. 数据库设计

    • 建议新增 relatedType 字段,保持 relatedIdLong 类型,逻辑清晰且便于扩展。
    • 创建复合索引CREATE INDEX idx_vote_related ON votes (relatedType, relatedId),以优化高频查询性能。
  2. 查询优化

    • 若需要快速查询特定类型的所有关联记录(如所有 post 类型的投票),复合索引的过滤效率远高于字符串前缀方案。
    • 若查询仅涉及 relatedId(无类型过滤),可额外为 relatedId 创建单一索引,但需权衡存储和维护成本。

总结

  • 字符串前缀方案适合简单场景,但存在性能和维护隐患。
  • 字段分离+复合索引在 PostgreSQL 中更优,兼顾查询效率、扩展性和规范化设计。

Instant 计算

在Java中使用 Instant 类计算从现在起经过一段时间,可以通过以下两种核心方法实现,结合 Duration 类或 ChronoUnit 单位进行灵活操作:


方法一:通过 plus 方法直接添加时间单位

利用 Instantplus(long, TemporalUnit) 方法,指定时间单位和数值,直接计算未来时间点。
示例代码

import java.time.Instant;
import java.time.temporal.ChronoUnit;

public class InstantDemo {
    public static void main(String[] args) {
        Instant now = Instant.now();
        // 添加10分钟
        Instant future1 = now.plus(10, ChronoUnit.MINUTES);
        // 添加3天
        Instant future2 = now.plus(3, ChronoUnit.DAYS);
        
        System.out.println("当前时间:" + now);
        System.out.println("10分钟后:" + future1);
        System.out.println("3天后:" + future2);
    }
}

特点

  • 简洁直接,适合简单时间单位(如秒、分、小时、天)的加减。
  • ChronoUnit 支持多种时间单位,如 SECONDSHOURSDAYS 等。

方法二:通过 Duration 类定义时间间隔

创建 Duration 对象表示具体的时间长度,再通过 plus() 方法添加到当前时间点。
示例代码

import java.time.Duration;
import java.time.Instant;

public class DurationExample {
    public static void main(String[] args) {
        Instant now = Instant.now();
        // 定义2小时的时间间隔
        Duration duration = Duration.ofHours(2);
        // 添加间隔
        Instant future = now.plus(duration);

        System.out.println("当前时间:" + now);
        System.out.println("2小时后:" + future);
    }
}

特点

  • 更灵活,支持以毫秒、秒、分钟等单位定义复杂的时间间隔。
  • 适用于需要动态计算时间段的场景(如从配置或输入中获取时间长度)。

两种方法的对比

场景推荐方法优势
简单单位(秒、分、天等)plus(long, TemporalUnit)代码简洁,无需额外对象创建
复杂或动态时间间隔Duration + plus(Duration)支持更灵活的时间计算(如分钟+秒的组合)

注意事项

  1. 不可变性Instant 对象不可变,所有加减操作均返回新实例,原对象不受影响。
  2. 精度问题DurationChronoUnit 均支持纳秒级精度,但实际应用中需注意系统时钟的精度限制。
  3. 时区问题Instant 始终基于 UTC 时间,若需本地时间转换,需结合 ZonedDateTime 使用。

综合应用示例

import java.time.Instant;
import java.time.Duration;
import java.time.temporal.ChronoUnit;

public class CombinedExample {
    public static void main(String[] args) {
        Instant now = Instant.now();
        // 方法1:直接添加2小时
        Instant future1 = now.plus(2, ChronoUnit.HOURS);
        // 方法2:通过Duration添加30分钟
        Duration duration = Duration.ofMinutes(30);
        Instant future2 = now.plus(duration);

        System.out.println("当前时间:" + now);
        System.out.println("2小时后:" + future1);
        System.out.println("30分钟后:" + future2);
    }
}

通过以上方法,可以根据具体需求灵活处理时间计算问题。如需更复杂的日期操作(如月、年),建议结合 LocalDateTimeZonedDateTime 使用。

变长参数

在 Java 中,构造函数可以完全支持可变长参数(...args)的语法,但需要遵循以下规则和注意事项:


使用规则

  1. 语法形式
    可变长参数的声明方式与普通方法一致:在参数类型后加三个点...,例如:

    public class Example {
        // 构造函数使用可变长参数
        public Example(String... args) {
            // 参数 args 会被编译器转换为 String[]
        }
    }
    
  2. 位置限制
    可变长参数必须是构造函数的最后一个参数,且一个构造函数中只能有一个可变长参数。以下写法会报错:

    public Example(int... nums, String name) {}  // 错误:可变参数不在末尾
    public Example(String... s1, int... s2) {}   // 错误:多个可变参数
    
  3. 底层实现
    可变长参数本质上是语法糖,编译器会将其转换为数组。例如:

    public Example(String... args) { ... }
    // 编译后等同于:
    public Example(String[] args) { ... }
    

使用示例

基本用法

public class Person {
    private String[] hobbies;

    // 构造函数接收可变长参数
    public Person(String... hobbies) {
        this.hobbies = hobbies;
    }
}

// 调用方式
Person p1 = new Person("阅读", "编程"); // 传入多个参数
Person p2 = new Person();            // 不传参数(等效于空数组)

与固定参数重载时的优先级

当存在固定参数和可变长参数的重载构造函数时,固定参数的构造函数会优先被调用

public class MyClass {
    public MyClass(String s) {
        System.out.println("固定参数构造函数");
    }

    public MyClass(String... s) {
        System.out.println("可变长参数构造函数");
    }
}

// 调用示例
MyClass obj = new MyClass("hello");  // 输出:"固定参数构造函数"

注意事项

  1. 避免重载歧义
    如果存在多个可能匹配的可变长参数构造函数,编译器会报错。例如:

    public class Test {
        public Test(String... args) {}
        public Test(String s, String... args) {}  // 错误:调用时可能产生歧义
    }
    
  2. 与数组参数的冲突
    构造函数不能同时定义可变长参数和数组参数,因为两者编译后等价:

    public class Conflict {
        public Conflict(String... args) {}  // 编译报错:重复定义
        public Conflict(String[] args) {}
    }
    
  3. 空值处理
    如果传递 null 给可变长参数,需要显式转型以避免歧义:

    new Example((String[]) null);  // 显式声明参数类型为数组
    

适用场景

  • 灵活初始化:适用于需要支持不同数量初始化参数的场景(如日志记录器、配置类)。
  • 简化代码:替代手动创建数组的操作,例如 new Config("A", "B", "C")new Config(new String[]{"A", "B", "C"}) 更简洁。

总结

Java 构造函数完全支持可变长参数,但需注意语法规则和重载时的潜在问题。其本质是通过数组实现,能够显著提升代码的灵活性和简洁性。

Spring boot 响应 String[] vs. List

在 Spring Boot 的响应中,String[]List<String> 的主要区别体现在 数据结构特性框架处理机制 上,具体如下:


数据结构特性差异

  1. 可变性

    • String[]:数组是定长结构,创建后长度不可变。若需修改元素,需重新创建数组。
    • List<String>:基于接口的集合类型,支持动态扩容(如 ArrayList 实现类),可灵活增删元素。
  2. 类型兼容性

    • String[] 是具体实现类,耦合度高。
    • List<String> 是接口,遵循面向接口编程原则,可替换为 LinkedList 等实现类,提升代码通用性。

Spring Boot 的序列化行为

  1. JSON 转换规则

    • 两者均会被 Spring Boot 默认序列化为 JSON 数组,例如:
      // String[] 返回结果
      ["A", "B", "C"]
      // List<String> 返回结果
      ["A", "B", "C"]
      
    • 在序列化结果上无差异,但 List<String> 更符合集合操作的语义。
  2. 统一响应格式兼容性

    • 若使用统一响应封装类(如 ResultData<T>),两者均可作为 data 字段的值。但 List<String> 更易于扩展其他集合操作(如分页)。
    • 示例:
      // 统一响应类
      public ResultData<List<String>> getList() { ... }
      public ResultData<String[]> getArray() { ... }
      

框架支持与开发实践

  1. 响应类型控制

    • 使用 ResponseEntity<List<String>> 可显式声明响应类型,帮助 Spring 自动设置 Content-Type: application/json,避免客户端解析歧义。
    • String[] 虽能隐式转换,但需确保前端能正确处理数组类型。
  2. 异常处理

    • 若接口可能返回空集合,List<String> 更自然地表示空状态([]),而 String[] 的空数组可能需要额外判断 null
  3. 代码可维护性

    • List<String> 支持 Stream API 和集合工具类(如 Collectors),便于数据转换和过滤。
    • String[] 需借助 Arrays 工具类操作,灵活性较低。

性能与内存影响

  • 内存占用
    String[] 作为连续内存块,随机访问效率高;List<String>(如 ArrayList)底层基于数组,性能接近,但存在动态扩容时的额外开销。
  • 适用场景
    • 数据量固定且无需修改时,优先选 String[]
    • 高频增删或不确定数据量时,选 List<String>

最佳实践建议

  1. 优先选择 List<String>

    • 符合面向接口编程原则,降低代码耦合。
    • 便于未来替换集合实现(如从 ArrayList 改为 LinkedList)。
  2. 需显式返回数组时

    • 在需要兼容旧系统或第三方 API 时,可使用 String[],但建议通过注释说明原因。

总结对比表

特性String[]List<String>
数据结构定长数组动态集合(如 ArrayList
序列化结果JSON 数组JSON 数组
代码扩展性低(依赖具体实现)高(依赖接口)
统一响应格式兼容性支持更灵活
推荐场景固定长度、无需修改动态数据、高频操作

若需进一步优化响应格式,可参考 Spring Boot 统一封装方案。

@RequestPart和@RequestBody

在Spring框架中,@RequestPart和@RequestBody不能同时使用。以下是具体原因及替代方案:

两者不能同时使用的原因

  1. HTTP请求体的处理机制冲突
    • @RequestBody 会尝试将整个请求体(Body)作为一个整体解析,适用于如 application/json 的单体数据格式。

    • @RequestPart 用于处理 multipart/form-data 类型的请求,允许将请求体拆分为多个独立部分(如文件、JSON块等),每个部分可独立解析。

    • 当两者同时使用时,Spring无法同时处理单体请求体和多部分请求体,导致参数绑定失败。

  2. Content-Type的限制
    @RequestBody 要求请求头的 Content-Type 为单体格式(如 application/json),而 @RequestPart 需要 multipart/form-data,二者在同一个请求中无法共存。


替代解决方案 若需要同时传递文件和复杂数据(如JSON对象),可通过以下方式实现:

方案1:统一使用 @RequestPart 将JSON数据作为 multipart/form-data 中的一个独立部分,通过 @RequestPart 接收。
示例:

@PostMapping("/upload")
public String upload(
    @RequestPart("file") MultipartFile file,
    @RequestPart("data") YourDataObject data  // Spring会根据Content-Type自动反序列化JSON
) {
    // 处理文件和对象
}

前端需通过FormData包装数据:

const formData = new FormData();
formData.append("file", file);
formData.append("data", new Blob([JSON.stringify(data)], { type: "application/json" }));

此方法通过 multipart/form-data 同时传递文件和JSON块,后端通过 @RequestPart 分别接收。

方案2:用Map接收参数(灵活性更高) 若参数类型不固定,可通过 @RequestParam Map<String, Object> 接收普通参数,结合 @RequestPart 接收文件:

@PostMapping("/upload")
public String upload(
    @RequestParam Map<String, String> params,
    @RequestPart("file") MultipartFile file
) {
    String jsonData = params.get("data");
    // 手动反序列化JSON
}

总结

场景推荐注解适用场景
单体JSON请求体@RequestBody接收整个JSON对象
多部分数据(含文件+JSON)@RequestPart每个部分独立解析,支持自动反序列化
混合参数(非标准格式)@RequestParam手动处理参数

提示:在 multipart/form-data 请求中,优先使用 @RequestPart,因其支持更灵活的数据类型(如JSON、XML)和文件混合上传。

Postgres 前缀搜索

在 PostgreSQL 中实现字段前缀搜索,可以通过多种方法优化查询性能和准确性。以下是具体实现方式及优化策略:


使用 LIKE 操作符结合 B-tree 索引

  1. 基础语法
    直接使用 LIKE 'prefix%' 进行前缀匹配,例如:

    SELECT * FROM table_name WHERE column_name LIKE 'abc%';
    

    此语法会匹配所有以 abc 开头的字符串。

  2. 索引优化

    • 若字段类型为 textvarchar,可创建 B-tree 索引并指定 varchar_pattern_ops 运算符类:
      CREATE INDEX idx_column_prefix ON table_name (column_name varchar_pattern_ops);
      
    • 该索引会忽略默认的排序规则,直接按字节顺序比较字符串,提升前缀匹配的效率。

表达式索引(函数索引)

若需要动态截取字段前缀或处理复杂条件,可通过表达式索引实现:

  1. 固定长度前缀索引
    使用 LEFTSUBSTRING 函数创建索引,例如截取前 3 个字符:

    CREATE INDEX idx_left_prefix ON table_name (LEFT(column_name, 3));
    

    查询时需使用相同表达式:

    SELECT * FROM table_name WHERE LEFT(column_name, 3) = 'abc';
    
  2. 动态前缀优化
    结合 SUBSTRINGLIKE,通过两步筛选减少扫描范围:

    SELECT * FROM table_name 
    WHERE SUBSTRING(column_name, 1, 10) = 'prefix_part'
      AND column_name LIKE 'prefix%';
    

使用 pg_trgm 扩展加速模糊查询

对于更复杂的前缀匹配(如大小写不敏感或部分模糊场景):

  1. 安装扩展

    CREATE EXTENSION pg_trgm;
    
  2. 创建 GIN/GiST 索引

    CREATE INDEX idx_gin_prefix ON table_name USING GIN (column_name gin_trgm_ops);
    

    此索引支持 LIKE 'abc%'ILIKE(不区分大小写)等操作。


性能优化注意事项

  1. 避免全表扫描

    • LIKE 以通配符开头(如 %abc),索引将失效,需调整查询模式。
    • 优先使用固定前缀(如 abc%)以利用索引。
  2. 大小写敏感问题

    • 默认 LIKE 区分大小写,若需不敏感,改用 ILIKE 或转换为小写:
      CREATE INDEX idx_lower_prefix ON table_name (LOWER(column_name));
      SELECT * FROM table_name WHERE LOWER(column_name) LIKE 'abc%';
      
  3. 数据分布分析
    选择合适的前缀长度(如截取前 5 或 10 字符),确保索引的选择性(即唯一性足够高)。


示例场景

场景:搜索 url 表中以 https://example.com/blog 开头的记录。
优化步骤

  1. 创建表达式索引:
    CREATE INDEX idx_url_prefix ON url (SUBSTRING(url FROM 1 FOR 20));
    
  2. 执行查询:
    SELECT * FROM url 
    WHERE SUBSTRING(url, 1, 20) = 'https://example.com/b'
      AND url LIKE 'https://example.com/blog%';
    

总结

PostgreSQL 实现前缀搜索的核心在于合理利用索引和表达式优化。对于简单前缀匹配,LIKE 'prefix%' 结合 B-tree 索引是首选;复杂场景可借助表达式索引或 pg_trgm 扩展。注意避免全表扫描并分析数据分布,以平衡存储开销与查询效率。

@Entity Long 字段

在JPA中实现@Entity的Long类型字段自动设置为0,可以通过以下方法实现,结合数据库默认值和Java实体类初始化两种策略:


Java实体类初始化

直接初始化字段

在字段定义时直接赋初始值0L,适用于所有未显式赋值的场景:

@Entity
public class Product {
    @Id
    private Long id;

    @Column(name = "stock")
    private Long stock = 0L; // 直接初始化为0
}
  • 说明:此方式在对象实例化时自动设置stock为0。若未显式赋值,持久化时字段值始终为0。

通过构造函数初始化

在实体类的构造函数中显式设置默认值:

@Entity
public class Product {
    @Id
    private Long id;

    @Column(name = "stock")
    private Long stock;

    public Product() {
        this.stock = 0L; // 无参构造函数初始化
    }
}
  • 说明:确保所有新建对象均包含默认值。需注意:若存在其他构造函数,需同步添加初始化逻辑。

数据库默认值配置

使用@ColumncolumnDefinition属性

通过数据库DDL定义默认值,并强制JPA使用该配置:

@Entity
public class Product {
    @Id
    private Long id;

    @Column(name = "stock", columnDefinition = "bigint default 0")
    private Long stock;
}
  • 效果:插入记录时若未提供stock值,数据库自动填充0。此方法独立于Java对象状态,适用于数据库层面约束。

结合@DynamicInsert避免NULL覆盖

当字段允许Java对象为null但需数据库填充默认值时,需添加@DynamicInsert

@Entity
@DynamicInsert // 仅插入非空字段
public class Product {
    @Id
    private Long id;

    @Column(name = "stock", columnDefinition = "bigint default 0")
    private Long stock; // 可为null,但数据库会填充默认值
}
  • 适用场景:当业务允许字段在Java层为null,但需数据库自动补0时使用。注意:若Java对象未初始化且字段为null,需确保数据库默认值生效。

混合策略(推荐)

Java初始化 + 数据库默认值

同时初始化字段和配置数据库默认值,避免层间差异:

@Entity
public class Product {
    @Id
    private Long id;

    @Column(name = "stock", columnDefinition = "bigint default 0")
    private Long stock = 0L; // 双重保障
}
  • 优势:Java对象始终有合理默认值,数据库层进一步约束数据完整性。

注意事项

  • 包装类型与基本类型:若使用基本类型long,默认值为0,但JPA中通常建议用包装类型Long以支持null。需根据业务需求选择。
  • Hibernate配置:确保hibernate.ddl-auto设置为updatevalidate,使columnDefinition生效。
  • 事务一致性:若通过Java初始化,需确保所有新建对象均通过正确途径(如工厂方法)创建,避免遗漏初始化。

总结

推荐优先使用Java直接初始化混合策略,既能保证代码可读性,又能利用数据库约束增强数据健壮性。若需动态控制默认值(如不同环境不同配置),可结合@PrePersist注解在持久化前赋值。

Licensed under CC BY-NC-SA 4.0
Last updated on Jun 20, 2025 20:39 CST
comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy