2024年9月

介绍

最近上了一个新项目,考虑到一个问题,在高并发场景下,我们无法控制前端的请求频率和次数,这就可能导致服务器压力过大,响应速度变慢,甚至引发系统崩溃等严重问题。为了解决这些问题,我们需要在后端实现一些机制,如接口限流、防重复提交和接口防抖,而这些是保证接口安全、稳定提供服务,以及防止错误数据 和 脏数据产生的重要手段。

而AOP适合在在不改变业务代码的情况下,灵活地添加各种横切关注点,实现一些通用公共的业务场景,例如日志记录、事务管理、安全检查、性能监控、缓存管理、限流、防重复提交等功能。这样不仅提高了代码的可维护性,还使得业务逻辑更加清晰专注,关于AOP不理解的可以看
这篇文章

接口限流

接口限流是一种控制访问频率的技术,通过限制在一定时间内允许的最大请求数来保护系统免受过载。限流可以在应用的多个层面实现,比如在网关层、应用层甚至数据库层。常用的限流算法有
漏桶算法(Leaky Bucket)、令牌桶算法(Token Bucket)
等。限流不仅可以防止系统过载,还可以防止恶意用户的请求攻击。

限流框架大概有

  1. spring cloud gateway集成redis限流,但属于网关层限流
  2. 阿里Sentinel,功能强大、带监控平台
  3. srping cloud hystrix,属于接口层限流,提供线程池与信号量两种方式
  4. 其他:redission、redis手撸代码

本文主要是通过 Redission 的分布式计数来实现的
固定窗口
模式的限流,也可以通过 Redission 分布式限流方案(令牌桶)的的方式RRateLimiter。

在高并发场景下,合理地实施接口限流对于保障系统的稳定性和可用性至关重要。

  • 自定义接口限流注解类
    @AccessLimit
/**
 * 接口限流
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {

    /**
     * 限制时间窗口间隔长度,默认10秒
     */
    int times() default 10;

    /**
     * 时间单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 上述时间窗口内允许的最大请求数量,默认为5次
     */
    int maxCount() default 5;

    /**
     * redis key 的前缀
     */
    String preKey();

    /**
     * 提示语
     */
    String msg() default "服务请求达到最大限制,请求被拒绝!";
}

  • 利用
    AOP
    实现接口限流
/**
 * 通过AOP实现接口限流
 */
@Component
@Aspect
@Slf4j
@RequiredArgsConstructor
public class AccessLimitAspect {

    private static final String ACCESS_LIMIT_LOCK_KEY = "ACCESS_LIMIT_LOCK_KEY";

    private final RedissonClient redissonClient;

    @Around("@annotation(accessLimit)")
    public Object around(ProceedingJoinPoint point, AccessLimit accessLimit) throws Throwable {

        String prefix = accessLimit.preKey();
        String key = generateRedisKey(point, prefix);

        //限制窗口时间
        int time = accessLimit.times();
        //获取注解中的令牌数
        int maxCount = accessLimit.maxCount();
        //获取注解中的时间单位
        TimeUnit timeUnit = accessLimit.timeUnit();

        //分布式计数器
        RAtomicLong atomicLong = redissonClient.getAtomicLong(key);

        if (!atomicLong.isExists() || atomicLong.remainTimeToLive() <= 0) {
            atomicLong.expire(time, timeUnit);
        }

        long count = atomicLong.incrementAndGet();
        ;
        if (count > maxCount) {
            throw new LimitException(accessLimit.msg());
        }

        // 继续执行目标方法
        return point.proceed();
    }

    public String generateRedisKey(ProceedingJoinPoint point, String prefix) {
        //获取方法签名
        MethodSignature methodSignature = (MethodSignature) point.getSignature();
        //获取方法
        Method method = methodSignature.getMethod();
        //获取全类名
        String className = method.getDeclaringClass().getName();

        // 构建Redis中的key,加入类名、方法名以区分不同接口的限制
        return String.format("%s:%s:%s", ACCESS_LIMIT_LOCK_KEY, prefix, DigestUtil.md5Hex(String.format("%s-%s", className, method)));
    }
}
  • 调用示例实现
@GetMapping("/getUser")
@AccessLimit(times = 10, timeUnit = TimeUnit.SECONDS, maxCount = 5, preKey = "getUser", msg = "服务请求达到最大限制,请求被拒绝!")
public Result getUser() {
    return Result.success("成功访问");
}

防重复提交

在一些业务场景中,重复提交同一个请求可能会导致数据的不一致,甚至严重影响业务逻辑的正确性。例如,在提交订单的场景中,重复提交可能会导致用户被多次扣款。为了避免这种情况,可以使用防重复提交技术,这对于保护数据一致性、避免资源浪费非常重要

  • 自定义接口防重注解类
    @RepeatSubmit
/**
* 自定义接口防重注解类
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {
    /**
     * 定义了两种防止重复提交的方式,PARAM 表示基于方法参数来防止重复,TOKEN 则可能涉及生成和验证token的机制
     */
    enum Type { PARAM, TOKEN }
    /**
     * 设置默认的防重提交方式为基于方法参数。开发者可以不指定此参数,使用默认值。
     * @return Type
     */
    Type limitType() default Type.PARAM;
 
    /**
     * 允许设置加锁的过期时间,默认为5秒。这意味着在第一次请求之后的5秒内,相同的请求将被视为重复并被阻止
     */
    long lockTime() default 5;
    
    //提供了一个可选的服务ID参数,通过token时用作KEY计算
    String serviceId() default ""; 
    
    /**
     * 提示语
     */
    String msg() default "请求重复提交!";
}
  • 利用
    AOP
    实现接口防重处理
/**
 * 利用AOP实现接口防重处理
 */
@Aspect
@Slf4j
@RequiredArgsConstructor
@Component
public class RepeatSubmitAspect {

    private final String REPEAT_SUBMIT_LOCK_KEY_PARAM = "REPEAT_SUBMIT_LOCK_KEY_PARAM";

    private final String REPEAT_SUBMIT_LOCK_KEY_TOKEN = "REPEAT_SUBMIT_LOCK_KEY_TOKEN";

    private final RedissonClient redissonClient;

    private final RedisRepository redisRepository;

    @Pointcut("@annotation(repeatSubmit)")
    public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {

    }

    /**
     * 环绕通知, 围绕着方法执行
     * 两种方式
     * 方式一:加锁 固定时间内不能重复提交
     * 方式二:先请求获取token,再删除token,删除成功则是第一次提交
     */
    @Around("pointCutNoRepeatSubmit(repeatSubmit)")
    public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        //用于记录成功或者失败
        boolean res = false;

        //获取防重提交类型
        String type = repeatSubmit.limitType().name();
        if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
            //方式一,参数形式防重提交
            //通过 redissonClient 获取分布式锁,基于IP地址、类名、方法名生成唯一key
            String ipAddr = IPUtil.getIpAddr(request);
            String preKey = repeatSubmit.preKey();
            String key = generateTokenRedisKey(joinPoint, ipAddr, preKey);

            //获取注解中的锁时间
            long lockTime = repeatSubmit.lockTime();
            //获取注解中的时间单位
            TimeUnit timeUnit = repeatSubmit.timeUnit();

            //使用 tryLock 尝试获取锁,如果无法获取(即锁已被其他请求持有),则认为是重复提交,直接返回null
            RLock lock = redissonClient.getLock(key);
            //锁自动过期时间为 lockTime 秒,确保即使程序异常也不会永久锁定资源,尝试加锁,最多等待0秒,上锁以后 lockTime 秒自动解锁 [lockTime默认为5s, 可以自定义]
            res = lock.tryLock(0, lockTime, timeUnit);

        } else {
            //方式二,令牌形式防重提交
            //从请求头中获取 request-token,如果不存在,则抛出异常
            String requestToken = request.getHeader("request-token");
            if (StringUtils.isBlank(requestToken)) {
                throw new LimitException("请求未包含令牌");
            }
            //使用 request-token 和 serviceId 构造Redis的key,尝试从Redis中删除这个键。如果删除成功,说明是首次提交;否则认为是重复提交
            String key = String.format("%s:%s:%s", REPEAT_SUBMIT_LOCK_KEY_TOKEN, repeatSubmit.serviceId(), requestToken);
            res = redisRepository.del(key);
        }

        if (!res) {
            log.error("请求重复提交");
            throw new LimitException(repeatSubmit.msg());
        }

        return joinPoint.proceed();
    }

    private String generateTokenRedisKey(ProceedingJoinPoint joinPoint, String ipAddr, String preKey) {
        //根据ip地址、用户id、类名方法名、生成唯一的key
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        String className = method.getDeclaringClass().getName();
        String userId = "seven";
        return String.format("%s:%s:%s", REPEAT_SUBMIT_LOCK_KEY_PARAM, preKey, DigestUtil.md5Hex(String.format("%s-%s-%s-%s", ipAddr, className, method, userId)));
    }
}
  • 调用示例
@PostMapping("/saveUser")
@RepeatSubmit(limitType = RepeatSubmit.Type.PARAM,lockTime = 5,timeUnit = TimeUnit.SECONDS,preKey = "saveUser",msg = "请求重复提交")
public Result saveUser() {
    return Result.success("成功保存");
}

接口防抖

接口防抖是一种优化用户操作体验的技术,主要用于减少短时间内高频率触发的操作。例如,当用户快速点击按钮时,我们可以通过防抖机制,只处理最后一次触发的操作,而忽略前面短时间内的多次操作。防抖技术常用于输入框文本变化事件、按钮点击事件等场景,以提高系统的性能和用户体验。

后端接口防抖处理主要是为了避免在短时间内接收到大量相同的请求,特别是由于前端操作(如快速点击按钮)、网络重试或异常情况导致的重复请求。后端接口防抖通常涉及记录最近的请求信息,并在特定时间窗口内拒绝处理相同或相似的请求。

  • 定义自定义注解
    @AntiShake
// 该注解只能用于方法
@Target(ElementType.METHOD) 
// 运行时保留,这样才能在AOP中被检测到
@Retention(RetentionPolicy.RUNTIME) 
public @interface AntiShake {
    // 默认防抖时间1秒,单位秒
    long value() default 1000L; 
}
  • 实现
    AOP
    切面处理防抖
@Aspect // 标记为切面类
@Component // 让Spring管理这个Bean
public class AntiShakeAspect {
 
    private ThreadLocal<Long> lastInvokeTime = new ThreadLocal<>();
 
    @Around("@annotation(antiShake)") // 拦截所有标记了@AntiShake的方法
    public Object aroundAdvice(ProceedingJoinPoint joinPoint, AntiShake antiShake) throws Throwable {
        long currentTime = System.currentTimeMillis();
        long lastTime = lastInvokeTime.get() != null ? lastInvokeTime.get() : 0;
        
        if (currentTime - lastTime < antiShake.value()) {
            // 如果距离上次调用时间小于指定的防抖时间,则直接返回,不执行方法
            return null; // 或者根据业务需要返回特定值
        }
        
        lastInvokeTime.set(currentTime);
        return joinPoint.proceed(); // 执行原方法
    }
}
  • 调用示例代码
@PostMapping("/clickButton")
@AntiShake(value = 1000)
public Result clickButton() {
    return Result.success("成功点击按钮");
}

关于作者

来自一线程序员Seven的探索与实践,持续学习迭代中~

本文已收录于我的个人博客:
https://www.seven97.top

公众号:seven97,欢迎关注~

大家在工作中肯定遇到过接口被人狂刷的经历,就算没有经历过,在接口开发的过程中,我们也需要对那些容易被刷的接口或者和会消耗公司金钱相关的接口增加防盗刷功能。例如,发送短信接口以及发送邮件等接口,我看了国内很多产品的短信登录接口,基本上都是做了防盗刷,如果不做的话,一夜之间,也许公司都赔完了┭┮﹏┭┮。

假设我们正在开发一个发送短信(仅国内)的接口,过程如下

  1. 接口定义为
    /sendSms
  2. 请求参数只有
    phone
  3. 在处理请求时,我们对请求参数phone进行了合法性校验
  4. 如果手机号合法,那么调用腾讯云等服务商的发送短信Api,向目标手机号发送短信
  5. 流程结束

上面便是一个最简单的向手机号发送短信验证码的接口,不考虑其他和业务相关的操作。我们现在来分析一下,该接口存在的问题(刷接口)。

  1. 只对请求参数中的手机号进行合法性校验(11位手机号),并没有对手机号是否为空号进行验证,会导致别人构造大量合法但是是空号的手机号
  2. 没有增加单个手机号,每天最大发送次数
  3. 没有控制每个手机号发送间隔,会导致同一时间,向相同手机号发送大量短信

既然我们知道了发送短信验证码接口存在的缺陷,那我们将这些问题一一解决了,是不是就可以避免接口被盗刷呢?答案是只能在一定程度上防止被盗刷,因为这些恶意请求中,手机号都是通过程序无限生成的,都能通过我们的正则校验,所以对手机号进行发送次数和发送间隔限制,对他们是没有任何效果的。另外,想要避免向空号手机号发送短信的话,还需要额外的引入第三方的空号检验Api,增加了新的资源消耗。

我们现在从发送短信验证码的接口转移到其他的接口来看看,寻找一种能够应用于所有的接口,并能实现限流,幂等,防盗刷功能的方案。

公众号: 后端随笔

个人博客:
https://knowledge.xcye.xyz/

解决接口请求参数容易被构造

我们其实不难发现,导致接口被盗刷的根本原因在于请求参数很容易通过算法构造构造出来,这些通过程序生成的参数,在我们的程序看来,都是合法的。

{
"phone": "11位手机号"
}

通过上面两个对比,我们不难发现,先对于只有一个参数phone的发送短信接口来说,想要构造出淘宝发送短信的参数,难度直接上升了很多个阶梯。

我们从解决接口请求参数容易被构造的角度出发,我目前能想到的只有对请求参数进行加密,使用非对称加密的方式。具体的思路为,客户端在发送请求之前,使用服务端提供的公钥对请求参数进行加密,让请求参数看上去不那么容易被构造出来。服务端获取到请求参数后,使用私钥进行解密,然后再进行后续的一些验证操作。

那么这样可以防止接口被盗刷么?答案是,只能防君子,不能防小人。特别是对于Web端来说,如果发起盗刷的这个人,同样是一个开发者,他直接F12就可以从js文件中找到公钥。对于App来说,获取源码的方式会更难一点,但是最终公钥应该还是能够被找到的。

如果我们解决公钥容易被获取的问题,是不是可以通过这种方式防止接口被盗刷呢?如果能够解决公钥容易被获取的问题,在一定程度上,确实是可以解决接口被盗刷的问题,但是现在又将问题转移到了获取公钥接口上,我们还是需要解决获取公钥接口被盗刷的问题。

而且如果获取到的公钥不能存在时效性,可以被多次使用,那么这些通过加密实现防盗刷的接口,在公钥被泄露的情况下,还是会存在被盗刷的问题。想要解决的话,可以让公钥只能使用一次,或者只能在很短时间内使用,再者只能被多少个请求使用。我最终的解决方案也是类似于这个,让令牌只能使用一次。

而且使用公钥进行加密,通常是防止在请求过程中发生的中间人攻击,是为了解决参数被修改以及泄露的问题。

Ticket机制

我最终并不是通过解决参数容易被构造来防止盗刷的,我是通过对请求进行是否是机器人判断,如果是非法请求,强制必须先通过图形验证码,只有合法的请求,服务端才会进行处理。

我基于Ticket机制,客户端在发送请求之前,必须先向服务端申请一个Ticket。服务端在处理申请Ticket请求时,对请求进行判断,判断包含了是否是恶意请求和是否需要进行限流。当这两步都通过后,服务端会生成一个被加密,存在时效性并且只能使用一次的Ticket,客户端发送真正请求时,需要携带这个Ticket。每个Ticket只能被使用一次,而且客户端每次都携带Ticket,还可以通过Ticket实现请求的幂等性。

这种方案并不和任何的接口耦合,Ticket是携带在请求头上,不会对请求参数造成污染。

申请Ticket

我最终是使用Ticket完成了限流,防盗刷,幂等性这三个功能,为了让这个功能更加的通用,不和任何的接口相耦合。在申请Ticket时,客户端需要传递两个参数,分别是serviceType和primaryKey。serviceType用于控制该接口的类型,而primaryKey会被用于限流。最终结合配置中心,做到了能够轻松的对任何类型的请求进行独立的限流,UserAgent黑名单与白名单,Ip限流等操作。

具体的执行过程为(以发送短信验证码为例):

  1. 客户端调用接口申请Ticket,传递的参数为
    {serviceType: sms, primaryKey: 用户手机号}

  2. 服务端对客户端请求进行验证


    1. UserAgent是否在黑名单中(恶意请求的UserAgent基本上都是同一个),UserAgent还可以有很多的玩法,比如类似于Ip一样,对UserAgent进行限流(会影响一部分正常用户)
    2. 从请求头中对用户身份进行初步识别。可以和客户端协商好,在一些请求头值上做文章,帮助服务端识别请求者身份
    3. 对IP进行识别。很多的恶意请求都来自于不同的Ip,有部分来自同一个网段,我们可以对Ip结合serviceType进行限制。
  3. 如果服务端识别请求是恶意请求,则在响应体中将captchaStatus设置为true,表示需要客户端进行图形验证码验证

  4. 下一步,服务端通过serviceType,从配置中获取限流规则。通过serviceType+primaryKey作为key,看是否能通过指定的限流。

  5. 通过限流后,服务端使用对称加密对{captchaStatus, primaryKey}进行加密,得到Ticket。这一步的目的是为了在最终验证Ticket时,从解密的数据中获取captchaStatus,避免captchaStatus是由客户端传递,从而解决请求绕过图形验证码验证问题,客户端根据captchaStatus判断该Ticket是否需要用户通过图形验证码,才能执行后续操作。

  6. 服务端将Ticket放入Redis,并且设置过期时间,然后将{ticket, captchaStatus}返回给客户端。

服务端返回的Ticket是加密后的密文,存在过期时间,保存在Redis中,并且只能被使用一次,无法被客户端构造出来。尽管加密算法被不小心泄露,服务端也无法从Redis中查询到这个"合法的Ticket",所以这个Ticket是足够安全的。

图形验证码

调用申请Ticket接口后,响应参数中包含两个参数:captchaStatus, ticket。captchaStatus表示该Ticket是否需要客户端通过图形验证码。

当captchaStatus为true时,客户端调用另一个接口加载图形验证码,在调用接口时,需要携带上一步获得的Ticket,服务端最终会将本次的图形验证码和Ticket进行绑定,最终实现在下一步中通过Ticket获取图形验证码的验证结果,具体步骤为:

  1. 客户端携带申请到的Ticket加载图形验证码数据
  2. 服务端从请求头中获取Ticket,从Db中查询该Ticket加载过几次图形验证码,如果超过最大加载次数,那么直接通知客户端重新申请新Ticket,并且删除和旧Ticket相关的数据。
  3. 验证通过后,生成图形验证码数据,得到该图形验证码的key,然后将key和ticket放入Db中存储起来,目的是为了保存图形验证码验证结果
  4. 客户端接收到图形验证码数据并加载

在防盗刷功能中,最有效的还得是验证码功能

服务端验证Ticket

当客户端完成上面两个后,客户端现在才开始调用真正的接口(发送短信)。在调用发送短信验证码时,客户端需要携带申请到的Ticket和图形验证码Key(如果captchaStatus为true)。

服务端接收到请求后,具体的处理步骤如下:

  1. 从请求中获取Ticket,并且对Ticket进行解密,从Redis中查询该Ticket是否存在

    尽管我们的防盗刷逻辑被人知晓,他们也不能随意的构造Ticket

  2. 从解密后的数据中获取captchaStatus字段的值,如果为true,则表示该Ticket需要执行图形验证码验证。服务端从DB中查询和该Ticket最后一次绑定的图形验证码Key的结果,如果没有进行验证或者结果为失败的话,直接结束流程

  3. 对Ticket进行幂等性验证,主要是通过判断该Ticket之前是否被使用过,如果上一个请求已经完成,那么直接从Redis中获取执行结果,并返回

  4. 当上面都没有问题后,现在才开始执行最终的业务逻辑,这里是执行发送短信验证码。因为这个功能并不和任何的接口耦合,如果我们需要更细的防盗刷,还可以在具体的接口里面做文章。

  5. 执行完毕后,需要把Ticket相关的数据都删除。

上面便是我实现接口防盗刷的具体过程,现在我们来验证一下,这个防盗刷是否真的能防(还是以发送短信验证码)?

  1. 构造大量合法但空号的手机号

    每次请求时,都需要先申请Ticket,primaryKey为手机号。因为这些恶意请求的UserAgent是相同的,如果我们预先接收到报警并且将UserAgent放入黑名单中,这些请求会直接被拦截。

    就算UserAgent每被拦截,还有Ip等其他的限流措施。如果都通过,我们还可以直接强制要求每一个请求都进行图形验证码验证,因为图形验证码的破解难度更高,基本上已经劝退很多人了,强制进行图形验证码验证,对于正常用户来说,也只会降低使用体验。

    对于手机号为空号来说,如果这个用户确实通过了上面这些措施,那么基本上可以保证他是一个真实用户,所以手机号是否为空号验证,我觉得是多此一举,除非发送短信的资源真的非常宝贵。

  2. Ticket被泄露,被伪造

    在公司没出内鬼的情况下,Ticket是不可用被伪造出来的,并且就算被伪造出来,这个Ticket也没有保存至Db。如果该Ticket的captchaStatus为false并且被泄露了,他们也只能在指定时间内使用该Ticket,并且只能使用一次。不可能会存在Ticket无限泄露的情况。

在上面的过程中,服务端验证请求是否是机器人,还可以在发送真正请求时进行验证,如果验证失败,客户端根据响应体执行对应的操作,然后携带Ticket重发请求。

上面的逻辑并没有对正常用户的验证结果进行缓存,这会导致,正常用户在调用这些接口时,每调用一次,都需要通过图形验证码。

其他措施

还有其他的措施,也可以增加接口被盗刷的情况。这些措施包括增加防盗刷逻辑被破解难度和防止接口被盗刷。

先说防止接口被盗刷,本质上是防止接口被泄露。对于App来说,某个人想要知道我们接口信息的话,必须对App进行反编译,我对App反编译不太了解,可以试试那些增大反编译的措施,就算不进行反编译,使用Fiddler工具也是可以看到请求信息的。对于Web端来说,用户只需要按F12就可以看到JavaScript代码,以及每个请求的参数,响应体等。我们可以禁用F12以及右键(降低用户体验),以及在生产环境中,添加当用户按F12后,自动进入无限Debug模式。这两个操作可以增加我们接口被暴露的风险,从而在一定程度上起到"防盗刷"目的。

对于增加防盗刷逻辑被破解难度来说,市场上有很多的App的限流等规则都被人攻破了,我个人觉得会被攻破,除了接口设计的原因外,还有一个是接口的响应体中提示了很明显的错误信息。比如我们访问某个增加了防刷功能的接口,该接口提示UserAgent无效,当前Ip已被限流,Ticket无效,未进行图形验证码验证等很明显的信息。这些信息其实已经间接提示了让请求变合法的步骤是什么,这虽然可以帮助开发人员进行调试,但也间接的帮助了那些发送恶意请求的人。所以为了增大防盗刷逻辑被破解的难度,我们不需要返回这些很明显的提示信息,可以无论什么原因,都返回"非法请求",对于公司开发人员来说,他们自己通过code从开发文档中查询每个code所代表的意思。

以上便是我对于防止接口被盗刷的一些见解,可能还有更优的方案,但是我目前确实只能想到这一种。另外,也可以使用已有的服务,比如腾讯云和阿里云等服务商的验证码。我使用的图形验证码是开源的,来自于
dromara
大佬开源的
Java 行文验证码
,使用起来非常的方便,并且支持滑块,旋转,滑动,文字点选,非常感谢大佬。此外,因为每次请求时申请到的Ticket都是加密的,在加密和解密的过程中,性能消耗也是一个可以优化的点,具体得看自己选择的算法是什么。

本文介绍基于
Python
语言,读取
Excel
表格文件数据,并将其中
符合我们特定要求

那一行
加以复制指定的次数,而
不符合要求

那一行
则不复制;并将所得结果保存为新的
Excel
表格文件的方法。

这里需要说明,在我们之前的文章
多次复制Excel符合要求的数据行:Python批量实现
中,也介绍过实现类似需求的另一种
Python
代码,大家如果有需要可以查看上述文章;而上述文章中的代码,由于用到了
DataFrame.append()
这一个在最新版本
pandas
库中取消的方法,因此有的时候可能会出现报错的情况;且本文中的需求较之上述文章有进一步的提升,因此大家主要参考本文即可。

首先,我们来明确一下本文的具体需求。现有一个
Excel
表格文件,在本文中我们就以
.csv
格式的文件为例;其中,如下图所示,这一文件中有一列(也就是
inf_dif
这一列)数据比较关键,我们希望对这一列数据加以处理——对于
每一行
,如果
这一行的这一列数据的值
在指定的范围内,那么就将这一行复制指定的次数(复制的意思相当于就是,新生成一个
和当前行
一摸一样数据的
新行
);而对于
符合我们要求的行
,其具体要
复制的次数
也不是固定的,也要根据
这一行的这一列数据的值
来判断——比如如果这个数据在
某一个值域内
,那么这一行就复制
10
次;而如果在
另一个值域内
,这一行就复制
50
次等。

image

知道了需求,我们就可以开始代码的书写。其中,本文用到的具体代码如下所示。

# -*- coding: utf-8 -*-
"""
Created on Thu Jul  6 22:04:48 2023

@author: fkxxgis
"""

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

original_file_path = "E:/01_Reflectivity/99_Model/02_Extract_Data/26_Train_Model_New/Train_Model_0715.csv"
result_file_path = "E:/01_Reflectivity/99_Model/02_Extract_Data/26_Train_Model_New/Train_Model_0715_Over_NIR_0717_2.csv"

df = pd.read_csv(original_file_path)
duplicated_num_0 = 70
duplicated_num_1 = 35
duplicated_num_2 = 7
duplicated_num_3 = 2

num = [duplicated_num_0 if (value <= -0.12 or value >= 0.12) else duplicated_num_1 if (value <= -0.1 or value >= 0.1) \
else duplicated_num_2 if (value <= -0.07 or value >= 0.07) else duplicated_num_3 if (value <= -0.03 or value >= 0.03) \
else 1 for value in df.inf_dif]
duplicated_df = df.loc[np.repeat(df.index.values, num)]

plt.figure(0)
plt.hist(df["inf_dif"], bins = 50)
plt.figure(1)
plt.hist(duplicated_df["inf_dif"], bins = 50)

duplicated_df.to_csv(result_file_path, index=False)

其中,上述代码的具体含义如下。

首先,我们需要导入所需的库,包括
numpy

pandas

matplotlib.pyplot
等,用于后续的数据处理和绘图操作。接下来,即可开始读取原始数据,我们使用
pd.read_csv()
函数读取文件,并将其存储在一个
DataFrame
对象
df
中;这里的原始文件路径由
original_file_path
变量指定。

随后,我们开始设置重复次数。在这里,我们根据特定的条件,为每个值设定重复的次数。根据
inf_dif
列的值,将相应的重复次数存储在
num
列表中。根据不同的条件,使用条件表达式(
if-else
语句)分别设定了不同的重复次数。

接下来,我们使用
loc
函数和
np.repeat()
函数,将数据按照重复次数复制,并将结果存储在
duplicated_df
中。

最后,为了对比我们数据重复的效果,可以绘制直方图。在这里,我们使用
matplotlib.pyplot
库中的
hist()
函数绘制了两个直方图;其中,第一个直方图是原始数据集
df

inf_dif
列的直方图,第二个直方图是复制后的数据集
duplicated_df

inf_dif
列的直方图。通过指定
bins
参数,将数据分成
50
个区间。

完成上述操作后,我们即可保存数据。将复制后的数据集
duplicated_df
保存为
.csv
格式文件,路径由
result_file_path
变量指定。

执行上述代码,我们将获得如下所示的两个直方图;其中,第一个直方图是原始数据集
df

inf_dif
列的直方图,也就是还未进行数据复制的直方图。

其次,第二个直方图是复制后的数据集
duplicated_df

inf_dif
列的直方图。

可以看到,经过前述代码的处理,我们原始的数据分布情况已经有了很明显的改变。

至此,大功告成。

MySQL中的索引模型

Mysql中的索引使用的数据结构一般为搜索树,这里的搜索树,一般使用B树,这里补一下数据结构中的B树结构;说B树之前,先顺一个前置的知识点,平衡二叉树;

平衡二叉树

二叉树应该都不陌生,大学数据结构的基本入门,
二叉排序树
是基于二叉树上多了个“
有序
”的概念,简单来说,即
左 < 右

右<左

,反正就是,树是按着顺序建立的 。

相比较普通的二叉树,二叉排序树具有“顺序”的特点,但是当极端情况下,即单边顺序排列下去,二叉排序树就成了单链表了,失去了树的意义,于是在二叉排序树的基础上进一步加强,即:
满足二叉排序树特点同时又左右子树高度差小于等于1,就有了平衡二叉树

平衡二叉树的特点如下:

  1. 二叉排序树

  2. 任何一个节点的左子树或者右子树都是「平衡二叉树」(左右高度差小于等于 1)

B树

平衡二叉树已经很好了,其因为顺序、且限制了树的饱和,于是使得平衡二叉树在检索时,具有很高的性能,复杂度才O(logN),此时影响查询性能的瓶颈就演变到节点数量N了;根据树的特点,进行比对计算的次数取决于树的高度,如果节点的数量固定,我们可以通过控制每层的节点数来控制树的高度,不拘泥于“二叉”这一特性,变成多叉的平衡树(B-树)。

B树是一个绝对平衡树,所有的叶子节点在同一高度。在每个节点存储多个元素,在每个节点除了指针节点外,还存储相应的数据;相比二叉平衡查找树,在整个查找过程中,虽然数据的比较次数并没有明显减少,但是磁盘IO次数会大大减少;B树的高度一般2至3层就能满足大部分的应用场景,所以使用B树构建索引可以很好的提升查询的效率。

B树特点

  • 每个节点至多可以拥有m棵子树(m阶)。
  • 根节点,至少有2个节点。
  • 非根非叶的节点至少有的Ceil(m/2)个子树(Ceil表示向上取整,图中5阶B树,每个节点至少有3个子树,也就是至少有3个叉)。
  • 非叶节点中的信息包括[n,A0,K1,A1,K2,A2,…,Kn,An],,其中n表示该节点中保存的关键字个数,K为关键字且Ki<Ki+1,A为指向子树根节点的指针。
  • 从根到叶子的每一条路径都有相同的长度,也就是说,叶子节在相同的层,并且这些节点不带信息,实际上这些节点就表示找不到指定的值,也就是指向这些节点的指针为空。

B+树

尽管B树能较为明显的提升效率,但是它节点携带数据这一特征,随着存储数据的增长,必然导致空间的占用,如果使用到数据库索引时,意味着会树相应就会变高,一个页中可存储的数据量就会变少,磁盘IO次数就会变大。

所以引入了B树的另一增强B+树,相比于B树,B+树的特点在于:

  1. 内部节点只存储键值,不存储数据。数据只存储在叶子节点中,并且叶子节点包含了全部的键值和指向数据的指针。

  2. 叶子节点之间有双向链表链接,这使得范围查询更加高效,因为可以通过链表顺序访问叶子节点。

B+树的最底层叶子节点包含了所有的索引项。从图上可以看到,B+树在查找数据的时候,由于数据都存放在最底层的叶子节点上,所以每次查找都需要检索到叶子节点才能查询到数据。所以在需要查询数据的情况下每次的磁盘的IO跟树高有直接的关系,但是从另一方面来说,由于数据都被放到了叶子节点,所以放索引的磁盘块锁存放的索引数量是会跟这增加的,所以相对于B树来说,B+树的树高理论上情况下是比B树要矮的。也存在索引覆盖查询的情况,在索引中数据满足了当前查询语句所需要的全部数据,此时只需要找到索引即可立刻返回,不需要检索到最底层的叶子节点。

索引分类

逻辑划分

按功能分

主键索引

一张表只能有一个主键索引,不允许重复、不允许为 NULL,使用关键字
PRIMARY KEY
定义;
PRIMARY KEY
的通常基于B树(B-Tree)实现,本质上是特殊的唯一索引。

ALTER TABLE TableName ADD PRIMARY KEY(column_list);

唯一索引

数据列不允许重复,允许为 NULL 值;一张表可有多个唯一索引,索引列的值必须唯一,但允许有空值。如果是组合索引,则列值的组合必须唯一;一般使用
UNIQUE INDEX
定义

CREATE UNIQUE INDEX IndexName ON`TableName`(`字段名`(length));
# 或者
ALTER TABLE TableName ADD UNIQUE (column_list);

普通索引

一张表可以创建多个普通索引,一个普通索引可以包含多个字段,
允许数据重复
,允许 NULL 值插入;

CREATE INDEX IndexName ON`TableName`(`字段名`(length));
# 或者
ALTER TABLE TableName ADD INDEX IndexName(`字段名`(length));

按使用分

按使用划分其实就是单个列进行索引还是多个列组合索引。

  • 单例索引:一个索引只包含一个列,一个表可以有多个单例索引。
  • 组合索引:一个组合索引包含
    两个或两个以上
    的列。查询的时候遵循 mysql 组合索引的 “最左前缀”原则,即使用 where 时条件要按照建立索引的时候字段的排列方式放置索引才会生效。MySQL中可以使用
    唯一索引

    普通索引
    进行组合索引
--多列组合(普通索引)
CREATE INDEX <index_name> ON <table_name >(column1, column2, column3);
ALTER TABLE <table_name> ADD INDEX <index_name>(column1, column2,column3);--多列组合(唯一索引) CREATE UNIQUE INDEX <index_name> ON <table_name>(column1, column2,column3);
ALTER TABLE <table_name> ADD UNIQUE INDEX <index_name> (column1, column2,column3));

关于组合索引使用中的性能及使用原则,可直接跳至组合索引的使用原则

物理划分

之所以说根据物理划分是索引在物理存储原理上的特点。而且主要是针对B+树索引结构来讲

簇的含义:
为了提高某个属性(或属性组)的查询速度,把这个或这些属性(称为聚簇码)上具有相同值的元组集中存放在连续的物理块。

聚簇索引

聚簇索引(clustered index)不是单独的一种索引类型,而是一种数据存储方式。这种存储方式根据表的主键构造一棵B+树,且B+树叶子节点存放的都是表的行记录数据时,该主键索引为聚簇索引。

在聚簇索引中,其数据文件本身就是索引文件。 树的叶节点 data 域保存了完整的数据记录。这个索引的 key 是数据表的主键,进行查询时,根据搜索算法,在树上找到叶子节点后,数据也一并找到。这种情况也就是常说的
覆盖索引

注:每张表最多只能拥有一个聚簇索引

非聚簇索引

非聚簇索引又叫辅助索引或二级索引(在Mysql中primary key之外的均为辅助索引),与簇索引相反,数据和索引是分开的,B+Tree 叶节点的 data 域存放的是主键和该字段的值。在索引检索的时候,首先按照 B+Tree 搜索算法搜索索引,如果指定的内容 存在,则取出其 data 域中的主键,然后再在聚簇索引(Primary Key 索引树上再次搜索)。

假设我们针对user表查询

SELECT sex,age FROM user WHERE name = '陈芳';

此时user表存在基于主键ID创建的聚簇索引,和基于name创建的非聚簇索引,那么查询过程则是这样的:

首先因为使用了name列进行等值查询,此时会先使用Name索引的B+树,进行搜索,当找到name为陈芳的叶子节点时,会拿到其ID,再回到基于ID的聚簇索引B+树上进行基于ID=1的搜索,最终找到其叶子节点,拿到上面的性别和年龄,其中这种需要两段使用索引的过程就是常说的回表查询。

虽然
InnoDB

MyISAM
存储引擎都默认使用B+树结构存储索引,但是只有InnoDB的主键索引才是聚簇索引,
InnoDB中的辅助索引以及MyISAM使用的都是非聚簇索引

索引的使用

创建索引的原则


  • 经常需要搜索的列
    上,可以加快搜索的速度
  • 在作为
    主键
    的列上,强制该列的唯一性和组织表中数据的排列结构

  • 经常用在连接(JOIN)的列
    上,这些列主要是一外键,可以加快连接的速度

  • 经常需要根据范围(<,<=,=,>,>=,BETWEEN,IN)进行搜索的列上创建索引
    ,因为索引已经排序,其指定的范围是连续的

  • 经常需要排序(order by)的列
    上创建索引,因为索引已经排序,这样查询可以利用索引的排序,加快排序查询时间;
  • 在经常使用在WHERE子句中的列上面创建索引,加快条件的判断速度。

数据类型对于索引的不同表现

VARCHAR类型

VARCHAR
类型用于存储可变长度的字符串。

  • 前缀索引
    :由于
    VARCHAR
    类型的列可能很长,为了节省空间和提高效率,可以只为每个值的前几个字符创建索引,称为前缀索引。但是,前缀索引会降低索引的选择性,可能需要扫描更多的行来找到匹配的记录。
  • 区分度
    :在选择前缀长度时,应该考虑列的区分度。如果大多数行的前缀都相同,那么前缀索引的效果可能不佳。
  • 索引效率

    等值查询、
    IN
    子句查询

    等效率较高,
    VARCHAR
    类型的列在排序和比较时是按字典顺序进行的,这可能会影响
    范围查询
    的性能。
    VARCHAR
    类型的索引在没有使用函数或表达式的情况下性能较好,但如果在查询中对字符串列使用了函数(如
    CONCAT

    SUBSTRING
    等),索引可能失效,导致全表扫描。

数字类型

数字类型泛指数字相关类型,例如
INT、FLOAT、Double等

  • 选择性
    :整数类型的列通常具有很高的选择性,这意味着索引中的值分布均匀,可以有效地减少查询中需要检查的行数。
  • 索引效率

    等值查询、范围查询(
    >

    <

    BETWEEN
    等)

    效率较高,数字类型的索引在进行范围查询时查询性能上通常优于字符串类型。

日期类型

DATETIME
类型用于存储日期和时间。

  • 时区影响
    :在处理跨时区的数据时,需要注意时区转换可能对索引的使用和查询结果产生影响。
  • 函数影响
    :如果查询中使用了日期函数(如
    DATE_FORMAT
    ),可能会导致索引失效
  • 索引效率

    DATETIME
    类型的列在进行
    等值查询

    范围查询
    时效率较高,因为日期和时间的比较操作非常快速。

其他

  • 列的NULL值
    :如果列中包含NULL值,索引的行为可能会受到影响。例如,
    VARCHAR
    类型的列如果允许NULL值,可能会在索引中占用额外的空间。
  • 列的默认值
    :默认值可能会影响索引的选择性。例如,如果一个
    INT
    类型的列有一个默认值,那么很多行可能会有相同的默认值,这会降低索引的选择性。
  • 列的更新频率
    :频繁更新的列可能会使索引变得碎片化,从而影响索引的性能。因此,对于经常更新的列,可能需要定期重建索引。

组合索引的使用原则

在MySQL中使用组合索引时,"
最左前缀
"原则决定了索引的使用方式和查询优化的效果。这个原则指的是,
MySQL会按照组合索引中列的顺序,从左到右使用索引中的列
。只有当索引的最左列被包含在查询条件中时,索引才会被有效地使用。如果查询条件中跳过了索引中的某个列,那么该列及其右边的所有列都不会被用于索引查询。

例如:将tb_flow_visit表的
源ip、目的ip、访问时间
三个字段设置为组合索引(注意设置的顺序)

ALTER TABLE tb_flow_visit ADD INDEX commIndies (sip, dip,visit_time);

顺序优先

在组合索引中,MySQL会按照(
源ip、目的ip、访问时间
)这个顺序来匹配条件。如果
WHERE
子句中首先出现的是
sip字段匹配相关
的条件,那么索引才会被使用。

--有效索引查询(全部匹配)
SELECT * FROM tb_flow_visit WHERE sip = '192.168.1.1' AND dip = '192.168.1.2' AND visit_time = '2023-08-01';--有效索引查询(部分匹配)
SELECT * FROM tb_flow_visit WHERE sip = '192.168.1.1' AND dip = '192.168.1.2';

不能跳过

如果你的查询条件首先使用了
dip(或者visit_time)
而没有包含
sip
,那么这个组合索引将不会被使用,因为违反了最左前缀原则。

--无效索引,不能跳过sip
SELECT * FROM tb_flow_visit WHERE  dip = '192.168.1.2' AND visit_time = '2024-08-01';--无效索引,组合索引中,如过不按照索引顺序,即便单独使用其中任何一列也无效
SELECT * FROM tb_flow_visit WHERE dip = '192.168.1.2';
SELECT * FROM tb_flow_visit WHERE visit_time > '2024-08-01';;

部分匹配

如果
WHERE
子句中包含了从索引最左列开始的连续列,那么索引可以被部分使用。例如:使用了最左列
sip
的字段和
visit_time
字段,因此会有效使用
sip
列的索引,但不会使用
dip

visit_time
列的索引

SELECT * FROM tb_flow_visit WHERE sip = '192.168.1.1' AND visit_time > '2024-08-01';

范围查询限制

在MySQL中,最左原则的范围查询限制是指在使用组合索引时,如果查询条件中包含了范围查询(如
>

<

BETWEEN

LIKE
等),那么这个范围查询必须应用于组合索引的最左列或者紧挨着最左列的列(如果最左列是常量条件)。如果范围查询跳过了最左列或者在非最左列上使用,那么索引将不会被有效使用; 例如在最左列(sip)上使用了范围查询(如sip
> '192.168.1.2'
),那么后续的列仍然可以被索引使用,但最左列之后的列必须是等值查询,不能是范围查询。

--无效索引
SELECT * FROM tb_flow_visit WHERE  sip > '192.168.1.2' AND dip > '192.168.2.1';AND  visit_time = '2024-08-01';--有效索引
SELECT * FROM tb_flow_visit WHERE  sip > '192.168.1.2' AND dip = '192.168.2.1' AND visit_time = '2024-08-01';

23.1 什么是快速系统调用

系统调用是操作系统为3特权级任务提供服务的一种手段。在32位操作系统中,我们通过中断实现了系统调用。由于系统调用是一个使用非常频繁的机制,且中断也不是专门为系统调用设计的,因此,64位CPU提供了系统调用的专用机制:快速系统调用。

快速系统调用由专用的
syscall
指令发起,并由专用的
sysret
指令返回。
syscall
必须从3特权级转移到0特权级,
sysret
必须从0特权级返回到3特权级。快速系统调用全程使用寄存器传参,并且系统调用函数的
cs:rip
是预设好的,因此,
syscall/sysret
均不需要参数。

综上,快速系统调用的整套机制都是非常固定的,这就带来了高效率。

23.2 快速系统调用的安装

在使用快速系统调用之前,需要先安装好快速系统调用所需的组件,这涉及到4个MSR。

23.2.1
IA32_EFER

快速系统调用这个功能在初始状态下是关闭的,其开关位于
IA32_EFER
的第0位。这个MSR我们已经见过了,它的编号为
0xc0000080

23.2.2
IA32_STAR

这个MSR的低32位是保留位;第32~47位用于设定
syscall
使用的0特权级段选择子;第48~63位用于设定
sysret
使用的3特权级段选择子。

注意,这里没有说设定的是"代码段选择子",而仅仅是"段选择子",这是因为选择子的设定有一套比较奇怪的定义:

  • 对于第32~47位,其数值本身会被视为0特权级代码段选择子;这个数值加8得到的数值会被视为0特权级数据段选择子
  • 对于第48~63位,其数值本身会被视为3特权级兼容模式代码段选择子;这个数值加8得到的数值会被视为3特权级数据段选择子;这个数值加16得到的数值会被视为3特权级IA32-e模式代码段选择子。那么,当执行
    sysret
    时,其到底选择哪个代码段呢?这个问题将在下文中讨论

段选择子是描述符索引值左移3位得到的,因此加8即为GDT中的下一个描述符。也就是说,第32~47位设定的是两个连续的段描述符中的第一个;第48~63位设定的是三个连续的段描述符中的第一个。不过,由于我们的操作系统从不使用兼容模式代码段,因此在GDT中并没有定义这个描述符。

这个MSR的编号为
0xc0000081

23.2.3
IA32_LSTAR

这个MSR用于设定系统调用函数的地址,其编号为
0xc0000082

23.2.4
IA32_FMASK

这个MSR用于设定RFLAGS屏蔽掩码。具体来说,当执行
syscall
时,
rflags
会变成这样:
rflags &= ~IA32_FMASK
。在我们的操作系统中,这个MSR用于屏蔽IF位,屏蔽掩码为
0x200

这个MSR的编号为
0xc0000084

23.3
syscall
的执行细节

当执行
syscall
时,CPU会执行以下操作:

  • rcx = rip
  • r11 = rflags
  • cs = IA32_STAR[32:47]
  • rip = IA32_LSTAR
  • rflags &= ~IA32_FMASK

也就是说,
rcx

r11
会被
syscall
使用,它们不能用于传参。此外,
syscall
不会对
rsp
做任何处理,这是一个很重要的问题,我们将在下文中讨论。

23.4
sysret
的执行细节

当执行
sysret
时,CPU会执行以下操作:

  • rip = rcx
  • rflags = r11
  • 如果
    sysret
    没有64位前缀,则:
    cs = IA32_STAR[48:63]
    ;否则:
    cs = IA32_STAR[48:63] + 16

也就是说:

  1. 操作系统需要保护
    rcx

    r11
  2. sysret
    需要具有64位前缀

上述第1点将在下文中讨论;第2点在nasm中可使用
o64 sysret
实现。

23.5 系统调用的实现

请看本章代码
23/Syscall.h

第3行,声明了
syscallInit
函数。这个函数是用汇编语言实现的。

接下来,请看本章代码
23/Syscall.s

第15~18行,将
IA32_EFER
的第0位置1,打开快速系统调用功能。

第20~23行,设定
IA32_STAR
。在GDT中,3号描述符是0特权级代码段,4号描述符是0特权级数据段,这两个段描述符对应于
IA32_STAR
的第32~47位;5号描述符是3特权级数据段,6号描述符是3特权级代码段,没有兼容模式代码段,因此,这里应强行将4号描述符安装到
IA32_STAR
的第48~63位,使得5号和6号描述符处于正确的位置。

第25~29行,将系统调用函数
syscallHandle
的地址安装到
IA32_LSTAR

第31~34行,将屏蔽掩码
0x200
安装到
IA32_FMASK

至此,快速系统调用准备完毕。

syscallHandle
函数为系统调用函数。在32位操作系统中,系统调用由中断实现,中断发生时,CPU会自动切换到0特权级栈,由于0特权级栈是操作系统提供的,所以能够保证它的安全。那么,什么叫"安全的栈"?如果不切换栈,到底有什么问题?请看下例:

void test()
{
    char s[] = "666";
    __asm__ __volatile__("syscall");
}

将这段代码翻译成汇编语言,可以是:

test:
	mov dword [rsp - 4], '666'
	syscall
	ret

可以发现:这个函数的
rsp
是没有也不需要实际减去4的,但如果将这样的
rsp
提供给系统调用函数使用,就是错误的,因为系统调用函数不知道栈到底应该怎么用。这就是不安全栈带来的问题,因此,在系统调用时,切换到一个安全的栈是有必要的。

然而,
syscall
不会自动切换栈,我们需要手动完成这个操作。0特权级栈在TSS中,TSS的地址是
0xffff800000092000
,但想要使用这个地址,就必须先用一个寄存器周转64位立即数。用哪个寄存器呢?无关乎ABI,似乎用哪个都不完美。此时,我们之前设定的
IA32_GS_BASE
派上了用场,使用
gs
就可以直接操作TSS了。不仅如此,我们的操作系统的TSS是延长到128字节的,104字节以后的一小段内存可用于在换栈前备份当前的
rsp
。至此,换栈问题就完美解决了。

第44行,将
rsp
备份到
[TSS + 104]

第45行,切换到0特权级栈。

第47~48行,保护
rcx

r11
。现在的栈是安全的,可以放心使用。

第50~51行,调用
rax
指定的函数。

第53~54行,恢复
rcx

r11

第56行,恢复3特权级栈。

第58行,从快速系统调用返回。

第60~63行,定义了系统调用表。1号系统调用保留给后续章节使用。

接下来,请看本章代码
23/Start.s

_start
函数是3特权级任务的真正入口,其用于使任务在结束后自动退出。

23.6 编译与测试

本章代码
23/Makefile
增加了
Syscall.s

Start.s
的编译与链接命令。

本章代码
23/Kernel.c

23/Test.c
测试了0与2号系统调用。