您当前的位置:首页 > 电脑百科 > 数据库 > 百科

多租户数据隔离方案实践

时间:2023-03-24 14:46:13  来源:  作者:IvanLan

背景:随着业务的发展,我们同一套业务系统需支持提供给多个客户(不同的企业用户)使用,所以需确保在多用户环境下,各用户间数据的隔离。但目前系统在早期设计的时候没有考虑到多租户的情况,业务数据没有做到充分隔离(有些表做了字段区分,有些没有)。

目前数据访问层用的是MyBatis框架,sql语句散布在xml里,dao注解里,量非常大。另外,租户字段(companyId)定义也不是所有的业务实体类都有。

基于现状,一个个修改sql,这样工作量太大了,所以排除掉一个个修改sql的方案。只能考虑怎样可以统一修改sql。而租户字段(companyId)的传递也需要有统一处理的地方。

一、业务表添加数据隔字段

我们先给没有租户字段(companyId)的表加上字段。然后考虑怎样给字段统一添加值的改造。因为业务系统目前是使用Mybatis做持久化,Mybatis有拦截器的功能,是否可以通过自定义Mybatis拦截器拦截下所有的 sql 语句,然后对其进行动态修改,自动添加company_id 字段及其字段值,实现数据隔离呢?答案是肯定的。

二、添加Mybatis拦截器

先看下Mybatis的核心对象:

Mybatis核心对象

解释

SqlSession

作为MyBatis工作的主要顶层API,表示和数据库交互的会话,完成必要数据库增删改查功能。

Executor

MyBatis执行器,是MyBatis 调度的核心,负责SQL语句的生成和查询缓存的维护。

StatementHandler

封装了JDBC Statement操作,负责对JDBC statement 的操作,如设置参数、将Statement结果集转换成List集合。

ParameterHandler

负责对用户传递的参数转换成JDBC Statement 所需要的参数。

ResultSetHandler

负责将JDBC返回的ResultSet结果集对象转换成List类型的集合。

TypeHandler

负责JAVA数据类型和jdbc数据类型之间的映射和转换。

MAppedStatement

MappedStatement维护了一条mapper.xml文件里面 select 、update、delete、insert节点的封装。

SqlSource

负责根据用户传递的parameterObject,动态地生成SQL语句,将信息封装到BoundSql对象中。

BoundSql

表示动态生成的SQL语句以及相应的参数信息。

Configuration

MyBatis所有的配置信息都维持在Configuration对象。

Mybatis拦截器可以拦截Executor、ParameterHandler、StatementHandler、ResultSetHandler四个对象里面的方法。Executor是Mybatis的核心接口。Mybatis中所有的Mapper语句的执行都是通过Executor进行的。其中增删改语句是通过Executor接口的update方法,查询语句是通过query方法。所以我们可以拦截Executor,拦载所有的select 、insert、update、delete语句进行改造,添加company_id字段及字段值。

创建一个自定义的拦截器:

/**
 * Mybatis - 通用拦截器。用于拦截sql并自动补充公共字段。包括query、insert、update、delete语句
 */
@Slf4j
@Intercepts(
        {
                @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
                @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
                @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
        }
)
public class AutoFillParamInterceptor implements Interceptor {

    private static final String LAST_INSERT_ID_SQL = "LAST_INSERT_ID()";
    private static final String COMPANY_ID = "company_id";

    /**
     * 拦截主要的逻辑
     * @param invocation
     * @return
     * @throws Throwable
     */
    @Override
    public Object intercept(Invocation invocation) throws Throwable {

        final Object[] args = invocation.getArgs();
        final MappedStatement ms = (MappedStatement) args[0];
        final Object paramObj = args[1];

        //  1.通过注解判断是否需要处理此SQL
        String namespace = ms.getId();
        String className = namespace.substring(0, namespace.lastIndexOf("."));
        //selectByExample
        String methodName = StringUtils.substringAfterLast(namespace, ".");
        Class<?> classType = Class.forName(className);
        if (classType.isAnnotationPresent(IgnoreAutoFill.class)) {
            //注解在类上
            String userType = classType.getAnnotation(IgnoreAutoFill.class).userType();
            if (StringUtils.isNotBlank(userType)) {
                //ignore特定的用户类型,其他均拦截
                if (userType.equals(getCurrentUserType())) {
                    return invocation.proceed();
                }
            } else {
                return invocation.proceed();
            }
        } else {
            //注解在方法上
            for (Method method : classType.getMethods()) {
                if (!methodName.equals(method.getName())) {
                    continue;
                } else {
                    if (method.isAnnotationPresent(IgnoreAutoFill.class)) {
                        String userType = method.getAnnotation(IgnoreAutoFill.class).userType();
                        if (StringUtils.isNotBlank(userType)) {
                            //ignore特定的用户类型,其他均拦截
                            if (userType.equals(getCurrentUserType())) {
                                return invocation.proceed();
                            }
                        } else {
                            return invocation.proceed();
                        }
                    }
                    break;
                }
            }
        }

        //  2.获取SQL语句
        BoundSql boundSql = ms.getBoundSql(paramObj);
        // 原始sql
        String originalSql = boundSql.getSql();
        log.debug("originalSql:{}", originalSql);
        //  3.根据语句类型改造SQL语句
        switch (ms.getSqlCommandType()) {
            case INSERT: {
                originalSql = convertInsertSQL(originalSql);
                args[0] = newMappedStatement(ms, boundSql, originalSql, paramObj);
                break;
            }
            case UPDATE:
            case DELETE: {
                originalSql = SQLUtils.addCondition(originalSql, COMPANY_ID + "='" + getCompanyId() +"'", null);
                args[0] = newMappedStatement(ms, boundSql, originalSql, paramObj);
                break;
            }
            case SELECT: {
                if (!StringUtils.contAInsIgnoreCase(originalSql, LAST_INSERT_ID_SQL)) {
                    //where 条件拼接 companyId
                    MySQLStatementParser parser = new MySqlStatementParser(originalSql);
                    SQLStatement statement = parser.parseStatement();
                    SQLSelectStatement selectStatement = (SQLSelectStatement) statement;
                    SQLSelect sqlSelect = selectStatement.getSelect();
                    SQLSelectQuery query = sqlSelect.getQuery();
                    addSelectCondition(query, COMPANY_ID + "='" + getCompanyId() + "'");
                    originalSql = SQLUtils.toSQLString(selectStatement, JdbcConstants.MYSQL);
                    // 将新生成的MappedStatement对象替换到参数列表中
                    args[0] = newMappedStatement(ms, boundSql, originalSql, paramObj);
                }
                break;
            }
        }
        log.debug("modifiedSql:{}", originalSql);
        //  4.应用修改后的SQL语句

        return invocation.proceed();
    }

    private void addSelectCondition(SQLSelectQuery query, String condition){
        if (query instanceof SQLUnionQuery) {
            SQLUnionQuery sqlUnionQuery = (SQLUnionQuery) query;
            addSelectCondition(sqlUnionQuery.getLeft(), condition);
            addSelectCondition(sqlUnionQuery.getRight(), condition);
        } else if (query instanceof SQLSelectQueryBlock) {
            SQLSelectQueryBlock selectQueryBlock = (SQLSelectQueryBlock) query;
            SQLTableSource tableSource = selectQueryBlock.getFrom();
            String conditionTmp = condition;
            String alias = getLeftAlias(tableSource);
            if (StringUtils.isNotBlank(alias)) {
                //拼接别名
                conditionTmp = alias + "." + condition;
            }
            SQLExpr conditionExpr = SQLUtils.toMySqlExpr(conditionTmp);
            selectQueryBlock.addCondition(conditionExpr);
        }
    }

    private String getLeftAlias(SQLTableSource tableSource) {
        if (tableSource != null) {
            if (tableSource instanceof SQLExprTableSource) {
                if (StringUtils.isNotBlank(tableSource.getAlias())) {
                    return tableSource.getAlias();
                }
            } else if (tableSource instanceof SQLJoinTableSource) {
                SQLJoinTableSource join = (SQLJoinTableSource) tableSource;
                return getLeftAlias(join.getLeft());
            }
        }
        return null;
    }


    /**
     * 用于封装目标对象的,通过该方法我们可以返回目标对象本身,也可以返回一个它的代理
     * @param target
     * @return
     */
    @Override
    public Object plugin(Object target) {
        //只拦截Executor对象,减少目标被代理的次数
        if (target instanceof Executor) {
            return Plugin.wrap(target, this);
        }
        return target;
    }

    /**
     * 注册当前拦截器的时候可以设置一些属性
     */
    @Override
    public void setProperties(Properties properties) {

    }

    private String convertInsertSQL(String originalSql) {
        MySqlStatementParser parser = new MySqlStatementParser(originalSql);
        SQLStatement statement = parser.parseStatement();
        MySqlSchemaStatVisitor visitor = new MySqlSchemaStatVisitor();
        statement.accept(visitor);
        MySqlInsertStatement myStatement = (MySqlInsertStatement) statement;
        String tableName = myStatement.getTableName().getSimpleName();
        List<SQLExpr> columns = myStatement.getColumns();
        List<SQLInsertStatement.ValuesClause> vcl = myStatement.getValuesList();
        if (columns == null || columns.size() <= 0 || myStatement.getQuery() != null) {
            return originalSql;
        }

        if (!visitor.containsColumn(tableName, COMPANY_ID)) {
            SQLExpr columnExpr = SQLUtils.toMySqlExpr(COMPANY_ID);
            columns.add(columnExpr);
            SQLExpr valuesExpr = SQLUtils.toMySqlExpr("'" + getCompanyId() + "'");
            vcl.stream().forEach(v -> v.addValue(valuesExpr));
        }
        return SQLUtils.toSQLString(myStatement, JdbcConstants.MYSQL);
    }

    private MappedStatement newMappedStatement(MappedStatement ms, BoundSql boundSql,
                                           String sql, Object parameter){
        BoundSql newBoundSql = new BoundSql(ms.getConfiguration(),sql, new ArrayList(boundSql.getParameterMappings()), parameter);
        for (ParameterMapping mapping : boundSql.getParameterMappings()) {
            String prop = mapping.getProperty();
            if (boundSql.hasAdditionalParameter(prop)) {
                newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop));
            }
        }
        return copyFromOriMappedStatement(ms, new WarpBoundSqlSqlSource(newBoundSql));
    }

    private MappedStatement copyFromOriMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
        MappedStatement.Builder builder = new MappedStatement.Builder(ms.getConfiguration(),ms.getId(),newSqlSource,ms.getSqlCommandType());
        builder.cache(ms.getCache()).databaseId(ms.getDatabaseId())
                .fetchSize(ms.getFetchSize())
                .flushCacheRequired(ms.isFlushCacheRequired())
                .keyColumn(StringUtils.join(ms.getKeyColumns(), ','))
                .keyGenerator(ms.getKeyGenerator())
                .keyProperty(StringUtils.join(ms.getKeyProperties(), ','))
                .lang(ms.getLang()).parameterMap(ms.getParameterMap())
                .resource(ms.getResource()).resultMaps(ms.getResultMaps())
                .resultOrdered(ms.isResultOrdered())
                .resultSets(StringUtils.join(ms.getResultSets(), ','))
                .resultSetType(ms.getResultSetType()).statementType(ms.getStatementType())
                .timeout(ms.getTimeout()).useCache(ms.isUseCache());
        return builder.build();
    }

    static class WarpBoundSqlSqlSource implements SqlSource {

        private final BoundSql boundSql;

        public WarpBoundSqlSqlSource(BoundSql boundSql) {
            this.boundSql = boundSql;
        }

        @Override
        public BoundSql getBoundSql(Object parameterObject) {
            return boundSql;
        }
    }

    public String getCompanyId() {
        //先从authenticationFacade取
        String companyId = CompanyContext.getCompanyId();

        if(StringUtils.isBlank(companyId)){
            log.error("Can not get the companyId! {}", companyId);
            throw new RuntimeException("Can not get the companyId! " + companyId);
        }
        return companyId;
    }

    public String getCurrentUserType() {
        //authenticationFacade取
        AuthenticationFacade authenticationFacade = ApplicationContextProvider.getBean(AuthenticationFacade.class);
        Integer currentUserType = authenticationFacade.getCurrentUserType();
        if (currentUserType == null) {
            log.error("Can not get the currentUserType! {}", currentUserType);
            throw new RuntimeException("Can not get the currentUserType! " + currentUserType);
        }
        UserTypeEnum userTypeEnum = UserTypeEnum.getByCode(currentUserType);
        return userTypeEnum.getUserType();
    }
}

虽然大部分sql都需要做条件过滤,但也有些特殊情况某些sql可能不需要过滤companyId条件,所以增加一个注解,如果不需要拦截的sql可以在Mapper类或方法上添加此注解,这样可以兼容不需要拦截的方法。

添加 IgnoreAutoFill 注解:

/**
 * 用于标注在不需要被拦截器处理的SQL上(Mapper类)
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface IgnoreAutoFill {
    String userType() default "";
}

Mapper示例:

public interface PostRecordDOMapper {
    long countByExample(PostRecordDOExample example);

    int deleteByExample(PostRecordDOExample example);

    int deleteByPrimaryKey(Long id);

    int insert(PostRecordDO record);

    int insertSelective(PostRecordDO record);

    List<PostRecordDO> selectByExample(PostRecordDOExample example);

    @IgnoreAutoFill
    List<PostRecordDO> selectByExampleAllCompany(PostRecordDOExample example);

    PostRecordDO selectByPrimaryKey(Long id);

    int updateByExampleSelective(@Param("record") PostRecordDO record, @Param("example") PostRecordDOExample example);

    int updateByExample(@Param("record") PostRecordDO record, @Param("example") PostRecordDOExample example);

    int updateByPrimaryKeySelective(PostRecordDO record);

    int updateByPrimaryKey(PostRecordDO record);

    void batchInsert(@Param("items") List<PostRecordDO> items);
}

在拦截器中,我们使用阿里的druid做sql解析,修改sql。

加入 druid 依赖:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.1.6</version>
</dependency>

拦截修改sql时,对于select、update、delete语句,我们直接添加company_id条件,对于insert语名,先判断原sql的参数列表里有没有company_id字段,如果有的话不作处理(说明原来就做了字段隔离),没有才自动给它添加company_id字段及值。

 

 

至此,我们解决了统一修改sql的问题,那还有一个重要问题,填充的字段值从哪里取得呢?因为调用持久层Mapper类方法的入参并不一定带有租户字段(companyId)信息过来,有些方法甚至只会传一个id的参数,像 deleteByPrimaryKey(Long id)selectByPrimaryKey(Long id);即使是传对象参数,对象实体类也不一定有租户字段(companyId)。所以如何传递租户字段(companyId)是一个改造难点。

三、多租户字段值的传递

考虑一翻,我们是否可以用 ThreadLocal 来存取呢?答案是肯定的。

要传递多租户字段(companyId)值,得先取得companyId值。因为每一个系统用户都有所属的companyId,所以只要在用户登录系统的时候,从token中拿到用户所属的companyId,然后set进ThreadLocal。后续线程的处理都可以从ThreadLocal中取得companyId。这样Mybatis拦截器也就随时都可以取得companyId的值进行sql参数或者条件的拼接改造了。

多租户上下文信息:

@Slf4j
public class CompanyContext implements AutoCloseable {

    private static final TransmittableThreadLocal<String> COMPANY_ID_CTX = new TransmittableThreadLocal<>();

    public CompanyContext(String companyId) {
        COMPANY_ID_CTX.remove();
        COMPANY_ID_CTX.set(companyId);
    }

    public static String getCompanyId(){
        return COMPANY_ID_CTX.get();
    }

    @Override
    public void close() throws Exception {
        COMPANY_ID_CTX.remove();
    }

    public static void remove(){
        COMPANY_ID_CTX.remove();
    }

}

但是,系统的业务处理不可能只用一个线程从头处理到结束,很多时候为了加快业务的处理,都是需要用到线程池的。

那么,问题又来了,不同线程间如何将这个companyId的ThreadLocal值传递下去呢?

这也是有解决方案的。

Transmittable ThreadLocal

Alibaba 有一个 Transmittable ThreadLocal 库,提供了一个TransmittableThreadLocal,它是 ThreadLocal 的一个扩展,提供了将变量的值从一个线程传递到另一个线程的能力。当一个任务被提交到线程池时,TransmittableThreadLocal 变量的值被捕获并传递给执行任务的工作线程。这确保了正确的值在工作线程中可用,即使它最初在不同的线程中设置。

使用Transmittable ThreadLocal 库,需引入依赖:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.11.5</version>
</dependency>

使用的时候,调用一下TtlExecutors工具提供的getTtlExecutor静态方法,传入一个Executor,即可获取一个支持 TTL (TransmittableThreadLocal)传递的 Executor 实例,此线程池就确保了上下文信息的正确传递,可放心使用了,如下所示:

@Bean(name = "exportDataExecutorPool")
public Executor exportDataExecutorPool() {
	ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
	threadPoolTaskExecutor.setCorePoolSize(CPU_NUM);
	threadPoolTaskExecutor.setMaxPoolSize(CPU_NUM * 2);
	threadPoolTaskExecutor.setKeepAliveSeconds(60);
	threadPoolTaskExecutor.setQueueCapacity(100);
	threadPoolTaskExecutor.setThreadNamePrefix("ExportData Thread-");
	threadPoolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
	threadPoolTaskExecutor.initialize();
	return TtlExecutors.getTtlExecutor(threadPoolTaskExecutor);
}

这样就可以确保线程池的线程随时可以都取到正确的companyId了。

至此,是不是就完成了改造了呢?

还没有。

为什么呢?

如果是同一个JVM确实是没问题了,如果不同的JVM呢?

一般较为复杂的系统都会按业务划分成不同的模块,同一个模块也可能部署多个不同的实例,这些不同的模块或不同的实例间的通信一般是通过远程调用或者消息队列进行数据传递。那么问题就来了,如何在不同的模块或实例间传递这个companyId呢?

目前我们系统的远程调用用的是RestTemplate,消息队列用的Kafka。那就要考虑怎么把companyId统一传递出去了。

远程调用 RestTemplate 的改造

  1. 对于RestTemplate,发送前我们可以通过ClientHttpRequestInterceptor拦截器,统一把companyId放进header。
@Slf4j
public class BearerTokenHeaderInterceptor implements ClientHttpRequestInterceptor {

	public BearerTokenHeaderInterceptor() {

	}

	@Override
	public ClientHttpResponse intercept(HttpRequest request, byte[] body,
			ClientHttpRequestExecution execution) throws IOException {
		
		//通过拦截器统一把companyId放到header
		String companyId = CompanyContext.getCompanyId();
		log.info("companyId={}", companyId);
		if (!StringUtils.isEmpty(companyId)) {
			request.getHeaders().set("companyId", companyId);
		}
		return execution.execute(request, body);
	}

}

注意创建 RestTemplate 时需要把这个拦截器加进去:

@Bean
@LoadBalanced
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {

  final RestTemplate restTemplate = restTemplateBuilder
      .setConnectTimeout(Duration.ofMillis(getConnectTimeout()))
      .setReadTimeout(Duration.ofMillis(getReadTimeout()))
          .requestFactory(()->httpRequestFactory())
      .build();
  List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors();
  if (interceptors == null) {
    interceptors = Collections.emptyList();
  }
  interceptors = new ArrayList<>(interceptors);
  interceptors.removeIf(BearerTokenHeaderInterceptor.class::isInstance);
  interceptors.add(new BearerTokenHeaderInterceptor());
  restTemplate.setInterceptors(interceptors);
  return restTemplate;

}
  1. 接收的地方也通过拦截器从header取得companyId并设置到本地变量:
@Slf4j
public class TokenParseAndLoginFilter extends OncePerRequestFilter {
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String accessToken = null;
        String companyId = null;
        try {
            //从header取得并设置companyId本地变量
            companyId = request.getHeader("companyId");
            new CompanyContext(companyId);
	    filterChain.doFilter(request, response);
        } catch (Exception e) {
            log.error("request error:",e);
            response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
            response.setStatus(500);
            response.getWriter().write(e.getMessage());
            response.getWriter().close();
        }
    }
}

消息队列 kafka 的改造

  1. 发送消息的地方,我们统一把companyId放到kafka message header:
/**
 * 发送消息
 */
public void sendMsg(String topic, Object value, Map<String, String> headers) {
    RecordHeaders kafkaHeaders = new RecordHeaders();
    headers.forEach((k,v)->{
        RecordHeader recordHeader = new RecordHeader(k,v.getBytes());
        kafkaHeaders.add(recordHeader);
    });
    RecordHeader recordHeader = new RecordHeader("companyId", CompanyContext.getCompanyId().getBytes());
    kafkaHeaders.add(recordHeader);
    //kafka默认分区
    ProducerRecord<String, String> producerRecord = new ProducerRecord<String, String>(topic, null, null, JsonUtil.toJson(value), kafkaHeaders);
    kafkaTemplate.send(producerRecord);
}
  1. 消息消费的地方,我们就可以从kafka message header中拿到companyId设置线程本地变量:
/**
 * 获取实例-手动处理ack
 */
@Bean
public KafkaListenerContainerFactory<ConcurrentMessageListenerContainer<String, String>> kafkaManualAckListenerContainerFactory() {
    ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory());
    factory.setConcurrency(concurrency);
    factory.getContainerProperties().setPollTimeout(3000);
    //RetryingAcknowledgingMessageListenerAdapter
    factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
    factory.setRetryTemplate(retryTemplate);
    factory.setRecoveryCallback(recoveryCallback());
    factory.setRecordFilterStrategy(consumerRecord -> {
        String companyId = getHead(consumerRecord, "company_id");
        // 设置companyId本地变量
        new CompanyContext(companyId);
        logger.info("Getting the company from kafka message header : {}", companyId);
        if(needRequestId) {
            String requestId = getHead(consumerRecord, KafkaHeadEnum.REQUEST_ID.getKey());
            new RequestIdContext(requestId);
        }
        return false;
    });
    return factory;
}

 

 

至此,我们就完成了多租户数据隔离的改造。

四、总结一下,改造的地方:

  1. 业务表没有租户字段(companyId)的,统一加上company_id字段。
  2. 利用Mybatis拦截器,拦载所有的select 、insert、update、delete语句进行改造SQL,自动添加company_id字段及字段值。
  3. 利用Transmittable ThreadLocal ,进行companyId值的传递。
  4. 对于http远程调用的,通过拦截器,发送端统一添加companyId字段到header,接收端通过OncePerRequestFilter从header取得统一设到ThreadLocal。
  5. 对消息队列(Kafka),发送端统一处理,添加companyId字段到message header,消费端通过RecordFilter从header取得统一设到ThreadLocal。


Tags:数据   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,不构成投资建议。投资者据此操作,风险自担。如有任何标注错误或版权侵犯请与我们联系,我们将及时更正、删除。
▌相关推荐
聊聊Rust里面的数据类型
嘿,朋友们!今天我们来聊聊Rust里面的数据类型。你知道吗?Rust的数据类型可是很重要的哦,它们帮助我们定义变量和函数可以处理什么样的数据。基本数据类型首先,让我们来看看Rust提...【详细内容】
2024-04-11  Search: 数据  点击:(2)  评论:(0)  加入收藏
“霸王条款”“刷单炒信”“大数据杀熟”怎么破解?这部法规都明确了!
预付式消费、直播带货、“一老一小”、“霸王条款”、“刷单炒信”、“大数据杀熟”、自动续费、强制搭售&hellip;&hellip;这些新领域的侵权你遇到过吗?消费者合法权益怎么保...【详细内容】
2024-04-11  Search: 数据  点击:(2)  评论:(0)  加入收藏
统计局解读3月份CPI和PPI数据:CPI季节性回落 PPI环比降幅收窄
2024年3月份CPI季节性回落 PPI环比降幅收窄&mdash;&mdash;国家统计局城市司首席统计师董莉娟解读2024年3月份CPI和PPI数据国家统计局今天发布了2024年3月份全国CPI(居民消费...【详细内容】
2024-04-11  Search: 数据  点击:(1)  评论:(0)  加入收藏
美国3月CPI数据全线高于预期 6月首降希望基本破灭
财联社4月10日讯(编辑 牛占林)当地时间周三,美国劳工部公布的数据显示,因汽油和住房价格上涨,美国基础通胀率连续第三个月超预期,强化了美联储在降息方面的谨慎态度。有不少分析师...【详细内容】
2024-04-10  Search: 数据  点击:(2)  评论:(0)  加入收藏
暴雪公布国服规则:当服务器开启时,将保证账号数据被完整保留
新浪科技讯 4月10日上午消息,暴雪娱乐、微软游戏与网易今日共同宣布,暴雪娱乐旗下的游戏作品,将根据更新后的游戏发行协议自今年夏季开始陆续重返中国大陆市场。消息公布后,暴雪...【详细内容】
2024-04-10  Search: 数据  点击:(2)  评论:(0)  加入收藏
市场监管总局:禁止“大数据杀熟”规范“自动续费”
本报讯 (记者韩昱) 近日,《中华人民共和国消费者权益保护法实施条例》(以下简称《条例》)正式发布。4月9日,国新办举行国务院政策例行吹风会,市场监管总局副局长柳军介绍《条例》有...【详细内容】
2024-04-10  Search: 数据  点击:(1)  评论:(0)  加入收藏
尾盘:美股继续下滑 市场等待通胀数据
北京时间10日凌晨,美股周二尾盘继续下滑。在3月CPI与PPI等关键通胀数据公布前,市场情绪谨慎。摩根大通等大型银行将在本周公布财报,由此拉开美股一季报的帷幕。道指跌180.19点,...【详细内容】
2024-04-10  Search: 数据  点击:(3)  评论:(0)  加入收藏
为训练AI,OpenAI等科技巨头花式淘数据
[环球时报特约记者 甄翔]《纽约时报》6日披露了科技公司训练人工智能的秘密&mdash;&mdash;利用语音识别工具转录视频网站YouTube上的视频,形成对话文本数据,供其最新的AI学习...【详细内容】
2024-04-08  Search: 数据  点击:(9)  评论:(0)  加入收藏
训出GPT-5短缺20万亿token!OpenAI被曝计划建「数据市场」
全网真的无数据可用了!外媒报道称,OpenAl、Anthropic等公司正在努力寻找足够的信息,来训练下一代人工智能模型。前几天,OpenAI和微软被曝出正在联手打造超算「星际之门」,解决算...【详细内容】
2024-04-08  Search: 数据  点击:(2)  评论:(0)  加入收藏
国家数据局首次召开全国性工作会议 释放哪些信号?
数据工作不仅事关经济社会发展、人们生产生活,也关乎国家发展与安全大局,其重要性不言而喻。我国是数据生产和应用大国,也是世界上首个提出数据要素理论的国家。正因为此,全国数...【详细内容】
2024-04-07  Search: 数据  点击:(5)  评论:(0)  加入收藏
▌简易百科推荐
向量数据库落地实践
本文基于京东内部向量数据库vearch进行实践。Vearch 是对大规模深度学习向量进行高性能相似搜索的弹性分布式系统。详见: https://github.com/vearch/zh_docs/blob/v3.3.X/do...【详细内容】
2024-04-03  京东云开发者    Tags:向量数据库   点击:(5)  评论:(0)  加入收藏
原来 SQL 函数是可以内联的!
介绍在某些情况下,SQL 函数(即指定LANGUAGE SQL)会将其函数体内联到调用它的查询中,而不是直接调用。这可以带来显著的性能提升,因为函数体可以暴露给调用查询的规划器,从而规划器...【详细内容】
2024-04-03  红石PG  微信公众号  Tags:SQL 函数   点击:(4)  评论:(0)  加入收藏
如何正确选择NoSQL数据库
译者 | 陈峻审校 | 重楼Allied Market Research最近发布的一份报告指出,业界对于NoSQL数据库的需求正在持续上升。2022年,全球NoSQL市场的销售额已达73亿美元,预计到2032年将达...【详细内容】
2024-03-28    51CTO  Tags:NoSQL   点击:(14)  评论:(0)  加入收藏
为什么数据库连接池不采用 IO 多路复用?
这是一个非常好的问题。IO多路复用被视为是非常好的性能助力器。但是一般我们在使用DB时,还是经常性采用c3p0,tomcat connection pool等技术来与DB连接,哪怕整个程序已经变成以...【详细内容】
2024-03-27  dbaplus社群    Tags:数据库连接池   点击:(13)  评论:(0)  加入收藏
八个常见的数据可视化错误以及如何避免它们
在当今以数据驱动为主导的世界里,清晰且具有洞察力的数据可视化至关重要。然而,在创建数据可视化时很容易犯错误,这可能导致对数据的错误解读。本文将探讨一些常见的糟糕数据可...【详细内容】
2024-03-26  DeepHub IMBA  微信公众号  Tags:数据可视化   点击:(7)  评论:(0)  加入收藏
到底有没有必要分库分表,如何考量的
关于是否需要进行分库分表,可以根据以下考量因素来决定: 数据量和负载:如果数据量巨大且负载压力较大,单一库单一表可能无法满足性能需求,考虑分库分表。 数据增长:预估数据增长...【详细内容】
2024-03-20  码上遇见你  微信公众号  Tags:分库分表   点击:(15)  评论:(0)  加入收藏
在 SQL 中写了 in 和 not in,技术总监说要炒了我……
WHY?IN 和 NOT IN 是比较常用的关键字,为什么要尽量避免呢?1、效率低项目中遇到这么个情况:t1表 和 t2表 都是150w条数据,600M的样子,都不算大。但是这样一句查询 &darr;select *...【详细内容】
2024-03-18  dbaplus社群    Tags:SQL   点击:(6)  评论:(0)  加入收藏
应对慢SQL的致胜法宝:7大实例剖析+优化原则
大促备战,最大的隐患项之一就是慢SQL,对于服务平稳运行带来的破坏性最大,也是日常工作中经常带来整个应用抖动的最大隐患,在日常开发中如何避免出现慢SQL,出现了慢SQL应该按照什...【详细内容】
2024-03-14  京东云开发者    Tags:慢SQL   点击:(5)  评论:(0)  加入收藏
过去一年,我看到了数据库领域的十大发展趋势
作者 | 朱洁策划 | 李冬梅过去一年,行业信心跌至冰点2022 年中,红衫的一篇《适应与忍耐》的报告,对公司经营提出了预警,让各个公司保持现金流,重整团队,想办法增加盈利。这篇报告...【详细内容】
2024-03-12    InfoQ  Tags:数据库   点击:(27)  评论:(0)  加入收藏
SQL优化的七个方法,你会哪个?
一、插入数据优化 普通插入:在平时我们执行insert语句的时候,可能都是一条一条数据插入进去的,就像下面这样。INSERT INTO `department` VALUES(1, &#39;研发部(RD)&#39;, &#39...【详细内容】
2024-03-07  程序员恰恰  微信公众号  Tags:SQL优化   点击:(20)  评论:(0)  加入收藏
站内最新
站内热门
站内头条