Redis的实战篇

Redis的实战篇

1.短信的登入注册

可以使用RandomUtil.randomNumbers(6)生成6位随机数

1.1 使用session来存储验证码

题外话:session与Cookie的关系:

  • 当用户登录的时候,服务器在Session中新增一个新记录,并把SessionID返回给客户端(通过HTTP响应的Set-Cookie字段返回)。
  • 客户端后续再给服务器发送请求的时候,需要在请求中带上SessionID(通过HTTP请求中的Cookie字段带上)
  • 服务器收到请求之后,根据SessionID在Session信息中获取到对应的用户信息,再进行后续操作,找不到则重新创建Session,并把SessionID返回。
1.1.1 验证用户的登入状态

使用拦截器(为什么要使用拦截器?)

1.保证用户的登入状态

2.在集群模式下,可以获得用户的信息,存储在ThreadLocal下

拦截器的使用 类 implements HandlerInterceptor(三个方法), 然后需要在Configuraion中配置addInterceptor

1.1.2 需要注意用户的隐藏信息

返回的数据要有限的,可以使用BeanUtils.copyProperties()来复制对象,然后再返回

1.2.3问题

每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题。

1.2 使用redis来存储session

使用UUID.randomUUID().toString()来生成一个唯一的token

在去返回给前端,前端在请求的时候,需要在请求头中带上这个token

在经过拦截器的时候,我们可以通过这个token来获取到用户的信息

1.3 优化

有一些不用登入的接口,我们可以不用拦截器,直接放行

可以设置2个拦截器。(解决刷新登录token令牌的问题)

  • 第一个拦截器,用来拦截token,把用户的信息存放到ThreadLocal中
  • 第二个拦截器,用来判断用户是否登入,如果没有登入,就返回一个错误信息

2.商户查询缓存

缓存(Cache),就是数据交换的缓冲区 ,俗称的缓存就是缓冲区内的数据 ,一般从数据库中获取,存储于本地代码
浏览器缓存:主要是存在于浏览器端的缓存

**应用层缓存:**可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存

**数据库缓存:**在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中

2.1使用redis来缓存商户数据

较为常规,使用redis的String类型来存储数据

2.2 缓存更新策略

  • 内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
  • 超时剔除: 设置一个过期时间,当过期时间到了,就会自动删除
  • 主动更新:当数据发生变化的时候,我们可以主动去更新缓存
  • 问题:数据库与redis出现数据不一致的情况
    • 1.是写少读多的情况,可以使用以把缓存删除,等待再次查询时,将缓存中的数据加载出来
    • 2.删除删除缓存还是更新缓存
    • 3.何保证缓存与数据库的操作的同时成功或失败
  • 需要先操作数据库,再删除缓存,防止高并发时候,出现了脏数据

2.3 缓存穿透

防止黑客通过一些特殊的字符,来绕过缓存,直接访问数据库

  • 1.缓存空对象
  • 就是当数据库中没有这个数据的时候,我们也把这个空对象存放到缓存中,访问时候,先去缓存中查找,如果没有,再去数据库中查找
  • 2.布隆过滤器(可能会出现误判)
    解决方案:
    • 缓存null值
    • 布隆过滤
    • 增强id的复杂度,避免被猜测id规律
    • 做好数据的基础格式校验
    • 加强用户权限校验
    • 做好热点参数的限流

2.4缓存雪崩问题及解决思路

解决方案:

  • 给不同的Key的TTL添加随机值(Random)
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

2.5 缓存击穿问题(热点Key问题)

就是在高并发的情况下,一个热点key过期了,导致大量的请求直接访问数据库,导致数据库压力过大

解决方案:

  • 1.使用互斥锁
  • 2.使用逻辑过期
2.5.1 使用互斥锁
  • 1.在获取缓存的时候,先去获取锁,如果获取不到锁,就等待一段时间,再去获取锁,使用了串行的方式

  • 2.获得锁的去查询数据库,然后再去更新缓存

    问题:1.性能不行 2.可能有死锁的情况

    如何实现:1.s使用redis的setIfabsent 来去设置锁

    private boolean tryLock(String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);

    return BooleanUtil.isTrue(flag);

    }

    private void unlock(String key) {

    stringRedisTemplate.delete(key);

    }

2.5.2 使用逻辑过期

什么是逻辑过期:

就是在存储redis的时候,不去设置过期时间,而是在对象的元素设置一个过期时间

在使用redis的时候,先去判断这个过期时间,如果过期了,开启一个线程去更新缓存,

在这个线程去更新缓存的时候,其他的线程去访问缓存,发现缓存过期了,就去返回的是脏数据。

代码实现:

复制代码
   //使用线程的模式

import java.util.concurrent.ExecutorService;

private static final ExecutorService executorService = Executors.newFixedThreadPool(10);
//核心代码
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
    if (isLock){
        CACHE_REBUILD_EXECUTOR.submit( ()->{

        try{
        //重建缓存
        this.saveShop2Redis(id,20L);
            }catch (Exception e){
        throw new RuntimeException(e);
            }finally {
unlock(lockKey);
            }
                    });
       }
2.5.3 使用泛型来去使用工具类
根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
java 复制代码
    public <R ,ID> R ShopThrough(String keyprefix,Class<R>Type,ID id,Long time,TimeUnit timeUnit,Function<ID,R>dpFallBack){
        String key = keyprefix+id;
        String s = stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isNotBlank(s)){
            return BeanUtil.toBean(s,Type);
        }
        R r = dpFallBack.apply(id);
        if(r==null){
            stringRedisTemplate.opsForValue().set(key,"",time,timeUnit);
            return null;
        }
        this.set(key,r,time,timeUnit);
        return r;
    }
根据指定的key查询缓存,并反序列化为指定类型,利用逻辑过期时间解决缓存击穿问题
java 复制代码
    public <R, ID> R ShopLogicDelete(String keyprefix, Class<R> Type, ID id, Long time, TimeUnit timeUnit, Function<ID, R> dpFollBack) {
        String key = keyprefix + id;
        String s = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(s)) {
            return null;
        }
        RedisData redisData = BeanUtil.copyProperties(s, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        R r = BeanUtil.toBean(data, Type);
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            return r;
        }
        boolean lock = onLock(key);
        if (lock) {
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    R r1 = dpFollBack.apply(id);
                    this.setWithLogicalExpire(key, r1, time, timeUnit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    offLock(key);
                }
            });
        }
        return r;
    }

3.优惠卷秒杀

3.1 全局唯一ID

为什么使用全局ID:

  • 1.id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息
  • 2.mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性
    如何生成Id:时间戳+序列号,是一个64位的数字,前32位是时间戳,后32位是序列号
java 复制代码
@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPrefix) {
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2.生成序列号
        // 2.1.获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 2.2.自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}

3.2 实现秒杀下单

3.2.1 超卖问题

注意的是:由于基础的秒杀的代码不同,如使用MyBatisPlus和MyBatis的代码不同,结果会有不同的结果

如:超卖或少卖问题

超卖问题出现的原因:高并发的问题与原子性问题

3.2.2 解决方案
  • 1.使用悲观锁: 悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
  • 2.使用乐观锁: 乐观锁是一种乐观的思想,他认为数据的读取是不会出现问题的,只有在更新的时候才会出现问题,所以在更新的时候,他会去判断数据是否被修改,如果没有被修改,就会去更新,如果被修改了,就会去重新读取数据,再去更新(cas)
3.2.3 悲观锁

就是加入一个锁,这里就不说了

3.2.4 乐观锁
java 复制代码
boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update().gt("stock",0); 

就是在更新的时候,去判断库存是否大于0,如果大于0,就去更新,如果小于0,就不去更新

3.3 优惠券秒杀-一人一单

还是因为高并发的问题,导致了一个用户可以多次下单,乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作

我们使用悲观锁时要考虑控制锁粒度:

  • 使用了用户的Id来作为锁的粒度,这样就可以保证一个用户只能下一单
  • 但是需要注意的是 Id.toString每一次都是不一样的,所以我们需要使用Id.toString.intern()来去保证是同一个对象

还是有问题的:问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:

但是要用事务生效,需要去代理来生效IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();

  • 在在SpringBoot的启动类上加上@EnableAspectJAutoProxy(exposeProxy = true)来暴露代理对象

3.4 集群环境下的并发问题

在多个tomcat中,使用了集群,每一个集群都会有一个自己jvm,使用syn锁是不行的,因为每一个jvm都会有自己的锁,所以我们需要使用redis来去实现锁

3.4.1 使用分布式锁
  • 获取锁:

    • 互斥:确保只能有一个线程获取锁
    • 非阻塞:尝试一次,成功返回true,失败返回false
    • 使用setnx命令去保证原子性
  • 释放锁:

    • 手动释放
    • 超时释放:获取锁时添加一个超时时间
3.4.2 分布式锁的问题

持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明

解决方案:要去判断锁是否是自己的

  • 但是要去保证原子性的,使用lua的脚本来去实现
lua 复制代码
    -- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
    -- 获取锁中的标示,判断是否与当前线程标示一致
    if (redis.call('GET', KEYS[1]) == ARGV[1]) then
     -- 一致,则删除锁
      return redis.call('DEL', KEYS[1])
    end
    -- 不一致,则直接返回
   return 0 

使用

java 复制代码
 StringRedisTemple stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());`

去执行lua脚本

3.5 Redission去实现分布式锁

3.5.1 使用redis的问题:
  • 1.重入问题
  • 2.不可重试
  • 3.超时释放(就是在获取的时候,时间过长,就会自动释放,就是同时有2个线程获取锁。)
  • 4.主从一致性

Redission 提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现

3.5.2 使用redission的准备

依赖:

xml 复制代码
<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>3.13.6</version>
</dependency>

配置文件:

java 复制代码
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        // 配置
        Config config = new Config();
        config.useSingleServer().setAddress("redis://*:6379")
                .setPassword("123321");
        // 创建RedissonClient对象
        return Redisson.create(config);
    }
}

idea中使用redission:

java 复制代码
Lock lock = redisClient.getLock("lock");
boolean success = lock.getLock();

3.5.3 在redisson的可重入锁与看门狗机制

这一部分需要去看源码,这里就不说了

  • 看门狗机制就是在获取锁的时候,设置一个超时时间,如果业务未完成,就会去延长超时时间

  • 可重入锁就是在获取锁的时候,可以多次获取锁,但是要去释放多次,用一个计数器来去记录

3.5.4 tryLock的一些用法
  • 1.尝试获取锁,如果获取不到,就返回false
java 复制代码
RLock lock = redissonClient.getLock("myLock");
boolean isLocked = lock.tryLock();
  • 2.尝试获取锁,如果获取不到,就等待一段时间
java 复制代码
RLock lock = redissonClient.getLock("myLock");
boolean isLocked = lock.tryLock(100, 10, TimeUnit.SECONDS);
//TODO:100是等待时间,10是超时时间(设置看门狗只有waittime,不要超时时间)

3.6 MutiLock锁

此时我们去写命令,写在主机上, 主机会将数据同步给从机,但是假设 在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。

为了解决这个问题,redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。

3.7 秒杀优化

串行的方式会导致我们的程序执行的很慢,可以使用异步的方式来去执行

  • 1.去判断是否有库存
  • 2.去redis使用lua脚本去减少库存,完成秒杀资格判断
  • 3.异步去生成订单
1、初步使用阻塞队列
java 复制代码
   import jakarta.annotation.PostConstruct;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;

private BlockingQueue<VoucherOrder>orderTasks = new LinkedBlockingQueue<>(1024*1024);
private static final ExecutorService executorService = Executors.newFixedThreadPool(10);

@PostConstruct
public void init() {
    executorService.submit(new VoucherOrderHandle());
    private class VoucherOrderHandle implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    // 1.获取队列中的订单信息
                    VoucherOrder voucherOrder = orderTasks.take();
                    // 2.创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                }
            }
        }
    }
}

存在的问题:

  • 内存限制问题
  • 数据安全问题

3.8 使用消息队列

3.8.1 基于List结构模拟消息队列

优点:

  • 利用Redis存储,不受限于JVM内存上限
  • 基于Redis的持久化机制,数据安全性有保证
  • 可以满足消息有序性

缺点:

  • 无法避免消息丢失
  • 只支持单消费者
3.8.2 基于Stream的消息队列

1.基于Stream的消息队列

  • xadd key * filed value *是自动生成的id
  • xrange count blocktime STREAMS key ID 0是从头开始读取,$是最新的信息 count是读取的数量 blocktime是阻塞时间

STREAM类型消息队列的XREAD命令特点:

  • 消息可回溯
  • 一个消息可以被多个消费者读取
  • 可以阻塞读取
  • 有消息漏读的风险

2.基于Stream的消息队列-消费者

创建消费者组:

  • XGROUP CREATE key groupname 0 MKSTREAM (0是从头开始读取)($是队列的最后的一个信息)
    groupName是消费者组的名字 MKSTREAM是创建一个新的stream

给指定的消费者组添加消费者

  • XGROUP CREATECONSUMER key groupname consumername

从消费者组读取消息

  • XREADGROUP GROUP group consumer COUNT count BLOCK milliseconds NOACK STREAMS key key ... ID ID ...
    ID是 > 表示从最新的开始读取,0表示从头开始读取

从消费者组确认消息:

  • XACK key group ID [ID ...] key是消费人 , group 是消费组 , Id为ID

  • 查询未确认的消息: XPENDING key group [[IDLE min-idle-time] start end count [consumer]]

  • 实验的过程:

  • 1.创建一个线程池来,去执行

  • 2.在主线程中判断是否有库存,如果有库存,就去发送消息,执行lua脚本,去减少库存

  • 3.副线程去消费消息,去生成订单(注意副线程不要使用LocalThread因为是异步的)

4.达人探店

4.1 点赞的实现

使用Zset的数据结构,因为Zset是有序的,可以根据时间来排序,分数就是时间的大小

4.2 好友关注
  • 关注的好友
  • 共同的关注
4.3 关注的人的动态(类型微信的朋友圈),使用feed的流

Feed流的设计:

  • 1.拉模式,:比较节约空间,但是延迟大
  • 2.推模式:比较节约时间,但是空间大
  • 3.混合模式:拉模式+推模式
    使用了推模式,与Feed流的滚动分页,如果不使用滚动分页,就会导致数据量的重复显现
    要注意的是顺序的问题,MyBatiesPlus的分页是根据id来排序的,所以我们要去根据时间来排序,所以要
    加入last("order by field(id," + strIds + ")")来去排序
    需要去注意的是:1.时间的最小值 2.offset 3.分页的大小
java 复制代码
    @Override
    public Result queryBlogOfFollow(long max, Integer offset) {
        //1。先查询关注的人blog
        User user = UserHolder.getUser();
        if(user == null){
            return Result.fail("请先登录");
        }
        String key = FEED_KEY + user.getId();
        //3.查询redis
        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().
                reverseRangeByScoreWithScores(key, 0, max, offset, 2);
        //2.null的判断
        if(typedTuples == null||typedTuples.isEmpty()){
            return Result.ok();
        }
        // 有Blog的id,和score
        List<Long> blogIds = new ArrayList<>(typedTuples.size());  //防止扩容
        long minTime = 0 ; //最小时间
        int oc = 1; //偏移量
        //3.minTime , offset
        for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
            blogIds.add(Long.valueOf(typedTuple.getValue()));  //获取博文id
            Long time = typedTuple.getScore().longValue();
            if(time == minTime) {
                oc++;
            }else {
                minTime = time;
                oc=1;
            }
        }
        String strIds = StrUtil.join(",", blogIds);
        //4.查询数据库的blog的数据
        List<Blog> blogList = query().in("id", blogIds).last("order by field(id," + strIds + ")").list();
        //5.判断是否为空
        if(blogList == null||blogList.isEmpty()){
            return Result.ok();
        }
        //5.要带上用户信息
        for (Blog blog : blogList) {
            isBlogLike(blog);
        }
        ScrollResult scrollResult =new ScrollResult();
        scrollResult.setList(blogList);
        scrollResult.setOffset(oc);
        scrollResult.setMinTime(minTime);
        return Result.ok(scrollResult);
    }

5.使用GEO去处理店铺的位置信息

要注意的是:StringRedisData 最好使用3.2的版本

java 复制代码
    @Override
    public Result queryShopType(Integer typeId, Integer current, Double x, Double y) {
        if(x==null || y==null){
            // 根据类型分页查询
            Page<Shop> page = query()
                    .eq("type_id", typeId)
                    .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
            // 返回数据
            return Result.ok(page.getRecords());
        }

        int start=  (current-1)*SystemConstants.DEFAULT_PAGE_SIZE;
        int end =  current * SystemConstants.DEFAULT_PAGE_SIZE;
        
        // 2.根据redis的geo查询
        String key = SHOP_GEO_KEY + typeId;
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(
                key,
                GeoReference.fromCoordinate(x, y),
                new Distance(5000),
                RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
        );
        //查询出来了shop的Id与distance
        if(results == null){
            return Result.ok(Collections.emptyList());
        }
        //3.获得id
        List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
        if (list.size() <= start) {
            // 没有下一页了,结束
            return Result.ok(Collections.emptyList());
        }
        List<Long>ids= new ArrayList<>();//存放id
        Map<String,Distance>distanceMap=new HashMap<>();

        //跳过start个,搜集List<id>与Map<id,distance>
        list.stream().skip(start).forEach(result->{
            ids.add(Long.valueOf(result.getContent().getName()));
            distanceMap.put(result.getContent().getName(),result.getDistance());
        });

        String StrId = StrUtil.join(",", ids);
        //4.需要根据id查询商铺信息(需要去数据库查询,把distance放到shop对象中)
        List<Shop> shopList = query().in("id", ids).last("Order by field(id," + StrId + ")").list();
        for (Shop shop : shopList) {
            shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
        }
        //5.返回数据
        return Result.ok(shopList);
    }
相关推荐
普通网友6 分钟前
Python FastAPI 异步数据库管理
数据库·fastapi
晓子文集23 分钟前
Tushare接口文档:期货合约信息表(fut_basic)
大数据·数据库·金融·金融数据·量化投资
三言老师1 小时前
CentOS7.9:Redis‑Cluster集群部署结构化实战教程
linux·运维·服务器·数据库
旺仔学长 哈哈1 小时前
Spring Boot 智能停车场管理系统---附源码+数据库文档
数据库·spring boot·后端·智能停车场
ClickHouseDB2 小时前
ClickHouse托管Postgres:OLTP+OLAP,新能力解锁最佳数据平台
java·前端·数据库
数智化管理手记2 小时前
主数据重复、错漏频发?一站式主数据管理平台如何落地?
大数据·运维·数据库·人工智能·数据挖掘
吴声子夜歌2 小时前
MongoDB 4.2——选择片键
数据库·mongodb
天天进步一点点3 小时前
QSqlQuery删除sqlite数据库执行drop数据表报错database table is locked unable to fetch row问题
数据库·oracle·sqlite
音视频工程实战3 小时前
PromptQL 新手入门与实战指南
数据库·sql·算法
清川渡水3 小时前
NL2SQL 的正确打开方式:从「AI 猜 SQL」到「置信度闭环」的工程化落地
数据库·人工智能·sql