架构师日记-33个常见编码漏洞大揭秘

一 前言

在软件编码的过程中,经常会遇到各种棘手的问题和挑战:

•高并发、大数据引起的性能问题;

•SQL注入、跨站脚本攻击的安全问题;

•协议、编码的规范设计问题等;

本文就从最常见的性能、安全和设计等几个维度来探讨这些问题。通过分享日常踩过的“坑”以及各种填“坑”经验,普及一些知识点,也助力研发少走一些弯路。

二 性能漏洞

引发性能陷阱的原因有很多,相对也比较隐蔽,一般出现在高并发,大数据的场景,下面就结合实际案例来看看那些容易引发性能风险的操作。

2.1 并行处理

2.1.1不恰当的锁粒度

以下代码的功能是优先从缓存中获取服务的结果,如果缓存失效,则正常的处理请求,并将结果写进缓存,以便后面的请求可以重复使用。

/**
 * 方法调用结果缓存
 *
 * @author: liuhuiqing
 * @date: 2023/9/2
 */
public final class CachedAround extends AbstractCacheAspectSupport implements Around {
    private final transient ConcurrentMap tunnels = new ConcurrentHashMap(0); // 请求队列,防业务并发

    public CachedAround(boolean allowNullValues, final Cache cache) {
        super(allowNullValues, cache);
    }

    /**
     * 调用服务方法或直接冲缓存中获取数据
     * 不要修改方法签名,否则可能返回值可能不兼容
     *
     * @param point Joint point
     * @return The result of call
     * @throws Throwable
     */
    @Override
    public Object doAround(ProceedingJoinPoint point) throws Throwable {
        final Object key = generateKey(point);
        // 优先读取缓存结果数据
        Object obj = readCache(key);
        if (obj != null) {
            return fromStoreValue(obj);
        }
        Tunnel tunnel;
        synchronized (this.tunnels) {
            tunnel = this.tunnels.get(key);
            if (tunnel == null) {
                tunnel = new Tunnel(point);
                this.tunnels.put(key, tunnel);
            }
        }
        // 执行业务处理逻辑
        obj = tunnel.proceed();
        // 将处理结果进行写入缓存
        writeCache(point, key, obj);
        return obj;
    }

    /**
     * 写缓存
     *
     * @param point
     * @param key
     * @param obj
     */
    private void writeCache(final ProceedingJoinPoint point, final Object key, Object obj) {
        try {
            final Method method = MethodSignature.class.cast(point.getSignature()).getMethod();
            final Cached cached = method.getAnnotation(Cached.class);
            if (!isUseCache(cached.successMethod(), obj)) {
                return;
            }

            int lifeTime = getLifeTime(cached.forever(), cached.lifetime(), cached.unit(), cached.step());
            putCache(key, obj, lifeTime, cached.sync());
        } catch (Exception e) {
            LOGGER.warn(String.format("缓存中存值key=[%s]出现异常", key), e);
        }
    }

    /**
     * 清除防并发缓存对象
     */
    @Override
    protected void clean() {
        synchronized (this.tunnels) {
            for (final Map.Entry entry : this.tunnels.entrySet()) {
                if (entry.getValue().executed) {
                    this.tunnels.remove(entry.getKey());
                }
            }
        }
    }

    /**
     * 业务逻辑执行
     */
    private static final class Tunnel implements Serializable {
        private final transient ProceedingJoinPoint point;
        private volatile boolean executed;
        private Object value;

        Tunnel(final ProceedingJoinPoint pnt) {
            this.point = pnt;
        }

        public synchronized Object proceed() throws Throwable {
            if (!this.executed) {
                value = point.proceed();
                this.executed = true;
            }
            return value;
        }
    }

    /**
     * 缓存key
     */
    protected Object generateKey(ProceedingJoinPoint point) {
        return keyGenerator.generate(point.getTarget(), MethodSignature.class
                .cast(point.getSignature()).getMethod(), point.getArgs());
    }
}

上面代码在访问量小的情况下,应该是问题不大的,但当并发量大的时候,性能瓶颈将会变得非常明显。

核心原因就是 synchronized (this.tunnels) 这句代码。在大量缓存穿透的场景下,以 this.tunnels成员变量作为同步锁对象,会形成线性的单点阻塞问题。

要想解决这个问题,就必须按照请求类型进行更细粒度的锁替换(比如作为业务请求标记的key),而不是全局使用同一把锁。

2.1.2 谨防请求叠加翻倍

获取某些数据,切记不要在实现层面将请求次数成倍叠加扩散。

public static void main(String[] args) {
    int requestNum=100;
    List personList = new ArrayList<>();
    // 一个请求,扩散成上百的RPC调用请求
    for (int i = 0; i < requestNum; i++) {
        // 从数据库一条一条的查询
        Person person1 = findPersonFromDataBase(i);
        // 从另一个服务一条一条的获取
        Person person2 = findPersonFromRemoteService(i);
        // 从缓存中一条一条的获取
        Person person3 = getPersonFromRedis(i);
    }
    Person person = Person.of("John");
}

以上案例在很少的请求量的场景下,不容易发现,一旦请求量上来了,风险就暴露出来了。可以通过调用批量接口,来规避此类场景的发生。

2.1.3 @Transaction的局限性

@Transaction注解能够用来实现控制数据事务,读写分离等功能,并可以在类或方法上进行修饰,使用起来非常方便。

但也有一些“陷阱”需要格外注意:

1.底层采用的是动态代理的实现方式,被修饰的方法必须是public类型的,否则事务将不会生效;

2.默认只在RuntimeException(也就是运行时异常)异常才会回滚,如果想要所有异常都回滚,需要手动指定@Transactional(rollbackFor=Exception.class);

3.如果A方法(没有事务注解)调用B方法,B方法加上@Transaction注解,如果A、B方法在同一个类里,则方法B的@Transaction注解失效;

4.注解方法里进行了异常捕获,并且没有进一步对向外抛出,那么当前事务是不会进行回滚的;

5.在事务中实现了非事务操作的事情,比如RPC调用,数据库查询操作等,这将导致数据库的吞吐量大大降低;

@Transactional(rollbackFor = Exception.class)
public Boolean cancelOrder(String orderNo) {
    // 1.查询订单状态
    OrderInfo orderInfo = dbMapper.get(orderNo);
    // 2.订单状态幂等设计
    if (orderInfo.getStatus == OrderStatusEnum.CANCEL) {
        return true;
    }
    // 3.订单退费,促销回滚等
    if (orderInfo.getStatus >= OrderStatusEnum.PAY) {
        payService.rollback(orderNo);
    }
    // 4.更新数据库订单状态
    int r = dbMapper.update(orderNo, OrderStatusEnum.CANCEL);
    return r > 0;
}

上面示例代码中,一个订单状态更新事务里,包含订单查询,支付回滚这些与数据库事务无关的操作。这会导致数据库的会话连接变长,小事务演变成了大事务,简单事务变成复杂事务,在高并发的场景下,数据库的吞吐量会严重下降。比较好的做法是将除了【第4步更新数据库状态】之外的其它操作都移到事务之外。

2.1.4 数据库死锁

数据库死锁是指两个或多个事务在执行过程中,因争夺资源而造成的一种相互等待的现象,若无外力作用,这些进程都将无法向前推进。当然,现在的数据库系统通常具有死锁检测和解决机制。(比如强制其中一个事务回滚)

死锁典型场景:



架构师日记-33个常见编码漏洞大揭秘_第1张图片



每个事务执行两条SQL,分别持有了一把锁,然后加另一把锁,事务相互等待对方资源,最后形成环路,造成死锁,即两个(或以上)的Session加锁的顺序不一致。

在单表操作中,同样也有可能造成死锁:





其中,name和pubtime分别建有索引。当两个事物分别从name索引和pubtime索引,对应的聚簇索引加X锁时,就有可能造成死锁。

既然死锁很难杜绝,那么该如何尽量避免死锁情况的发生呢?

下面给出5条建议:

1.以固定的顺序访问表和行。比如对两个job批量更新的情形,简单方法是对id列表先排序,后执行,这样就避免了交叉等待锁的情形。另外,将两个事务的sql顺序调整为一致,也能避免死锁;

2.大事务拆小。大事务更倾向于死锁,如果业务允许,将大事务拆小;

3.在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁概率;

4.降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁;

5.为表添加合理的索引。如果不走索引将会为表的每一行记录添加上锁,死锁的概率大大增大;

2.2 线程池

线程池一直是初学者容易入坑的一个知识点,下面介绍几个常见的误区。

为了更加直观的呈现知识点,假如在我们定义了一个线程池,如下:

ThreadPoolExecutor executor = new ThreadPoolExecutor(20,50,100L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<>(100));

2.2.1 线程池创建时机的误解

:如果往线程池提交120个任务(假设提交的过程中没有任务执行完成退出的情况),正常情况下会有多少个活跃线程,队列里有多少个任务?

线程池底层是如何工作的,是这个问题的关键。核心线程数,最大线程数与队列之间的运行过程,可参照下图:



架构师日记-33个常见编码漏洞大揭秘_第2张图片



建议:如果是前台流量比较大的业务网关系统,有一个优化小技巧就是让核心线程数与最大线程数相等,避免达到线程扩展的临界值时,频繁创建和销毁线程池,导致服务响应有毛刺,这类似于JVM中要求-Xms和-Xmx的参数配置一上来就相等,是同样的道理。

2.2.2 线程数越多越好吗?

其实线程并不是越多越好,原因如下:

1.线程的创建需要占用系统内存,根据jvm规范,一个线程默认最大栈大小为1M(可以通过启动JVM参数-Xss来指定),线程多,会消耗更多的内存;

2.如果线程的创建时间+销毁时间大于执行任务的时间,就没必要创建线程;

3.操作系统需要频繁的切换线程上下文,影响性能(CPU有效执行时间减少);

那设置多少线程数合适呢?

根据 Little's Law:一个系统请求数等于请求的到达率与平均每个单独请求花费的时间之乘积。

系统平均请求数,估算公式如下:

线程池大小 = ((线程 IO time + 线程 CPU time )/线程 CPU time )* CPU数目

举个例子:服务器CPU核数为8核,一个任务线程cpu耗时为20ms,线程等待(网络IO、磁盘IO)耗时80ms,那最佳线程数目:( 80 + 20 )/20 * 8 = 40。也就是设置 40个线程数最佳。当然这只是理论层面,具体还需要按照实际情况进行测试调整,毕竟一个系统中往往存在多组线程池,它们共同竞争CPU,网络,内存等资源。

2.2.3 线程池队列长度设置多少合适?

线程池队列设置不当,也会引起很严重的后果,轻则任务执行超时,无法获得执行结果,重则内存溢出,导致OOM。

下面给出设置队列长度的几点建议:

1.必须手动指定队列大小,防止队列过大(默认:Integer.MAX_VALUE),导致内存溢出;

2.根据实际使用场景设置队列大小,没有运行时效限制的,可以设置的大一些,但要考虑系统异常重启等情况下的任务丢失问题;

3.面向C端用户的任务,要按照任务执行速度和响应超时的时间来计算队列大小,举例:核心线程数=20,单任务执行时长=500ms,服务响应超时时长=2000ms,队列数=20*((2000/500)-1)=60。队列数少了起不到流量削峰填谷的作用,队列数多了,等任务真正执行完成,请求早已经超时断连了,结果已经失去了意义。

2.2.4 丢弃策略也有坑

1.拒绝策略设置为DiscardPolicy或DiscardOldestPolicy,并且存在被拒绝的任务,Future对象调用get()方法,会出现调用线程一直被阻塞的情况;

2.submit()返回的Future对象没有调用get()方法,任务中的异常信息在线程池外无法感知到,建议任务中进行异常捕获,打印日志,同时要调用get方法并且指定最大超时时长;

2.2.5 谨防多业务的线程池共享!

主要指多条业务线公用同一个线程池,这会导致如下问题:

1.多业务容易顾此失彼,线程池难以优化;

2.一个业务的任务处理质量变差,会导致其它业务的任务执行效率受到影响;

3.很难直接通过线程池的名称来排查定位问题;

所以,提前做好线程池隔离,保证各条业务线的任务互相不影响;

2.2.6 风险之外的风险

1.ThreadLocal与线程池搭配,线程复用,导致存储的信息错乱问题;

2.一条业务线中,父子线程池相互套用,导致流程单点阻塞问题;

3.线程池初始化创建了,确没有地方使用,导致资源浪费问题,这种情况一般出现在某业务下线的场景;

4.线程池没有定义成共享变量,而是在请求执行方法里实时创建的误用问题;

2.3 大数据

2.3.1 深分页查询难题

大部分存储中间件都会存在深分页问题,以数据库为例,假设订单表里已经存了8000万条数据,我们想遍历整个订单表,对今年生产的订单进行归档。如何快速的低成本的实现这个功能呢?

起初,可以使用下面语句进行分页查询:

SELECT * FROM my_order WHERE create_time > '2023-01-01 00:00:00' LIMIT 100000, 20;

随着页面的加深,这条sql语句的执行时间将会越来越长。其原因是在使用普通索引查询时,要想获取其它列数据,mysql需要回表,因为普通索引上存储的是主键,通过主键获取行数据。在查询0~20条的数据时,需要回表20次,查询100000~100020的数据需要回表100020次,性能就会变得极差。

那么该如何减少回表次数呢?

方案一:先通过普通索引定位到第100000条之后的第一条数据的id,因为普通索引存储的就是主键id所以此处不涉及回表,然后在根据主键id查询之后的10条数据,主键索引其节点存储了行数据,直接取即可。

SELECT * FROM my_order WHERE id > (SELECT a.id FROM my_order a WHERE a.create_time > '2023-01-01 00:00:00' LIMIT 100000, 1) LIMIT 10; 

方案二:如果表使用的是数据库表自增主键,也可以通过将当前页的最后一条记录,当做下一页的主键id查询入参,这样可以快速的实现目标数据的定位。

SELECT * FROM my_order WHERE id > lastReturnID LIMIT 10; 

2.3.2 序列化资源消耗陷阱

以下都是导致系统资源占用耗时过大,导致服务整体不稳定,需要考虑化解。

1.java大对象频繁反射,比如对象拷贝;

2.数据对象频繁的JSON序列化转换,比如打印日志,DTO转换;

3.日志打印频繁,比如请求入参,出参无差别打印;

4.内存存在大量的大对象,比如文件和图片上传下载功能;

2.3.3 数据一致性陷阱

随着高并发,大数据的现状,既要满足各个使用场景的性能要求,又要降低频繁的需求迭代对存量功能影响,按照需求场景进行数据异构成了一种很好的解决方案。但这也同样面临数据一致性要求的难题。

关于数据一致性这一个课题,可以参照笔者另一篇文章:数据一致性为什么那么难?

三 安全漏洞

3.1 攻击型

3.1.1 SQL注入危害大

通过SQL注入等方式,把用户输入的数据当做代码执行。

举个简单注入的场景,假如程序里有以下基于订单号查询订单的SQL语句,订单号是用户从页面传递过来的:

"SELECT * FROM my_order WHERE order_no = '" + OrderNo+ "'";

如果用户构造了如下订单号参数:666'; drop table my_order--

那么最终实际执行的SQL语句将会如下:

SELECT * FROM my_order WHERE order_no = '666'; drop table my_order --'

查询订单的语义,就变成了查询完订单后,再执行一个drop表的操作,而这个操作,就是用户构造的恶意攻击命令!

3.1.2 当心,SQL隐式转换

你知道吗?下面的语句是能够被正常执行的:

select * from my_order where order_no = "test"=0; // 相当于:where order_no = 1
update my_order set my_status="WHERE my_status=" = "yyy" ; // 相当于:update my_order set my_status = 0

查询影响的是数据返回结果,而更新操作却是影响了整张表的数据准确性。在进行sql执行前,做正确性检查,十分有必要;

3.1.3 跨站脚本攻击

Cross Site Scripting:跨站脚本攻击,有时也缩写为XSS。

攻击者在页面里插入恶意脚本,当用户浏览该页时,嵌入其中的恶意代码被执行从而达到攻击者的特殊目的。举个HTML页面渲染的例子:

$!orderNo

这段代码就有可能被黑客利用,弹出木马链接,获取本地登录会话信息等:

 
 " <"" $disabledFlag />

XSS的本质就是用户提交的HTML代码未经过滤和转义就直接回显,要解决此类问题,就需要对用户提交的数据进行转化脱敏。

3.1.4 跨站请求伪造

Cross Site Request Forgery:即跨站请求伪造,有时也缩写为XSRF

攻击者在恶意站点上促使用户请求有CSRF漏洞的应用的URL或欺骗性的表单从而修改用户数据,本质上是利用session机制,盗用授信用户身份,对应用做一些恶意的GET/POST提交。

一般实施步骤:

1.黑客在服务器端编写恶意脚本,并构造授信操作的URL,例如评论;

2.恶意用户回复帖子时候贴图,图片地址指向黑客事先编写的恶意脚本;

3.当用户浏览这些帖子时,就会请求该图片,不知觉访问了恶意脚本;

4.恶意脚本利用302重定向,根据帖子不同跳转到对应的评论URL;

本示例只是以引导跳转评论区为例,实际上黑客还可以构造用户登录URL页面套取用户密码,构造支付URL页面套取支付密码等核心机密信息,而使用者却很难觉察,危害巨大。

3.1.5 提示文案也有风险

•很多系统为了增加用户体验,在注册时会判断手机、邮箱是否存在,并将结果提示给用户:“该手机号已经存在!”,这样便提供了可遍历的接口,造成注册用户信息的泄漏;(要限制频率)

•手机号短信注册、手机号短信登陆、找回密码、邮箱验证等功能中,如果交互请求内直接包含验证码(验证凭证),则可以被攻击者截取,从而失去验证意义;(验证逻辑功能应避免验证被绕过)

3.1.6 未经检验和加密的参数漏洞

这是导致水平越权的最典型操作。比如直接使用用户提交上来的手机号进行验证码发送操作,如果没有对应的有效性验证机制,使用批量执行脚本,就很容易制造短信炸弹。

更为稳妥的方式是根据当前登录用户标识,通过后端服务去查询对应的手机号,而不是直接使用前端入参(所有来自前端的数据都是“不可信”的)。

可能有人会有疑惑,获取当前登录用户信息不也是基于前端传的会话态(cookie,sessionid)信息获得吗,这个就值得信任了吗?

原则上也不能100%的保障,通过跨站点脚本 (XSS)进行会话劫持的风险也是存在的。但会话态的实现有经过各种加密算法,时效控制,防改窜等策略设计的,与普通的业务参数相比,这在一定程度上大大的提高了数据的安全性。

3.1.7 安全漏洞的规避策略

把握住传参就能把握住逻辑漏洞的命脉。面对安全漏洞的规避策略有以下几类:

1.白名单校验:接受已知的合法数据集。

2.黑名单校验:拒绝已知的非法数据集;

3.白名单净化:对任何不属于已验证合法字符数据中的字符进行净化,然后再使用净化后的数据,净化的方式包括删除、编码、替换;

4.黑名单净化:剔除或者转换某些字符(例如,删除引号、转换成HTML实体,防止SQL注入);

5.水平越权控制 :“基于数据的访问控制” 设计缺陷引起的漏洞。由于服务器端在接收到请求数据进行操作时没有判断数据的所属人/所属部门而导致的越权数据访问漏洞;

6.垂直越权控制: “基于URL的访问控制” 设计缺陷引起的漏洞,又叫做权限提升攻击。由于后台应用没有做权限控制,或仅仅在菜单、按钮上做了权限控制,导致恶意用户只要猜测其他管理页面的URL或者敏感的参数信息,就可以访问或控制其他角色拥有的数据或页面,达到权限提升的目的。

关于越权控制,拿场景举例:
1.登录用户A时,正常更改或者是查看A的用户信息;
2.然后抓取数据包,将业务传参ID篡改为其他用户的;
3.如果成功查看或者修改了同权限其他用户信息就属于水平越权
4.如果可以获得到更高权限用户(管理员)的操作能力就是垂直越权;

3.2 防御型

3.2.1 资源泄漏风险

方案一:需要手动释放的资源及链接时,要在finally中进行了释放,防止抛出异常,导致资源释放的逻辑没有走到,导致资源泄漏;

try{
    //io操作:InputStream,Socket等
    //out.flush();
}catch(XxxException e){
    //异常处理
    throw e;
}finally{
    if(in != null){
        in.close();
    }
}

方案二:对于实现AutoCloseable接口的类的实例,将其放到try后面,在try结束的时候,会自动将这些资源关闭(调用close方法)。

try (
        InputStream fis = new FileInputStream(source);
        OutputStream fos = new FileOutputStream(target)) {
} catch (Exception e) {
    e.printStackTrace();
}

3.2.2 空指针异常(NPE)问题

在程序抛出的所有异常里面,空指针异常应该是最常见的一类了。具体示例如下:

public static void main(String[] args) {
    // 异常示例1:空值比较
    String nullString = null;

    if (nullString.equals("targetString")) {
        // 这里建议使用:Objects.equals(nullString,"targetString")或者"targetString".equals(nullString)两种方式
    }
    
    // 异常示例2:自动拆包
    Integer nullInt = null;
    // do something
    int num = nullInt;// 自动拆包,导致出现异常

    // 异常示例3:容器规则
    Map map = new ConcurrentHashMap();
    map.put(nullString,"");// 不允许key为null

    // 异常示例4:异常数据
    List haveNullElementList = new ArrayList();
    haveNullElementList.add(1);
    haveNullElementList.add(null);
    for (int i : haveNullElementList) { // 没有空值判断,导致异常
    }
}

任何NPE问题,都由使用者来保证。原因是在开发过程中,很难约束上游严格按照各种约定执行。

3.2.3 死循环、递归调用风险

java中的方法指令运行栈上,栈的大小一般在1-2M左右(取决于操作系统和JVM)。如果方法执行出现了死循环或者递归函数没有出口,不管栈空间多大,栈溢出是必然的。

public class Test {
    private static long loop(int n) {
        return n * loop(n - 1);
    }
    public static void main(String[] args) {
        loop(100000);
    }
}

以上示例代码就会抛出:Exception in thread "main" java.lang.StackOverflowError。递归层数比较多的场景下,建议使用循环替代递归。

3.2.4 数值越界风险

超过当前字段类型的最大值或最大精度场景:

1.对于Integer取值在-128至127范围内的赋值,Integer对象是在 IntegerCache.cache产生,会复用已有对象,这个区间内的Integer值可以直接使用==进行判断,但是这个区间之外的所有数据,都会在堆上产生,并不会复用已有对象,这是一个大坑,推荐使用equals方法进行判断;

2.对于业务主键的生成,一定要做好容量规划,避免出现int类型超过Integer.Max情况的发生;

3.数据库表id字段定义类型bigint unsigned,实际类对象属性为Integer,随着id越来越大,超过Integer的表示范围而溢出成为负数;

4.电商订单价格计算错误出现负数的场景;

3.2.5 数值精度丢失风险

double或float参数类型直接参加了数值计算,导致计算结果时不时的存在精度丢失的问题,其原因在于我们的计算机是二进制的,浮点数没有办法使用二进制进行精确表示。

public class Test {
    public static void main(String[] args) {
        System.out.println(0.06+0.01); // 精度丢失,结果:0.06999999999999999
        System.out.println(303.1/1000); // 精度丢失,结果:0.30310000000000004
    }
}

这在计算订单金额,描述产品重量等关键业务场景是不能够接受的。在大多数的商业计算中,一般采用java.math.BigDecimal 类来进行精确计算。

在使用BigDecimal时,建议使用BigDecimal(String var) 的String参数构造方法来构建对象。不要用BigDecimal(double var)的double参数的构造方法。这是因为0.1无法准确地表示为 double类型。new BigDecimal(0.1)对应的实际构造结果是: 0.1000000000000000055511151231257827021181583404541015625。

3.2.6 对象拷贝导致的风险

在业务实现的过程中,对象的拷贝或转换是很常见的应用场景。对象拷贝实现方式有两种,一种是通过原生支持的Cloneable接口实现,另一种是自定义实现。

对于实现了Cloneable接口的方案,以下几个陷阱:

1.这种方式并不是通常所理解的深拷贝,而是浅拷贝;

2.浅拷贝的一个主要问题是新的对象和原对象共享实例变量的值。原对象对共享实例变量对象的引用被修改了,那么新对象也会受到影响;

对于自定义实现对象的拷贝,需要注意一下几点:

1.序列化(Serialization):将对象序列化到一个字节流中,然后再从字节流中反序列化出新的对象。陷阱是必须有可序列化的接口(Serializable),而且如果对象中包含不可序列化的成员变量,那么在序列化过程中就可能抛出异常;

2.非序列化对象的深拷贝,需要自己实现深拷贝。如果对象之间存在循环引用,那么在进行深拷贝时,可能会出现无限递归的问题;

除此之外,对象拷贝还需要考虑性能问题,深拷贝操作可能会非常消耗性能,特别是在处理大型对象时。因此在需要频繁进行深拷贝的场景下,可能需要使用其他方式进行处理。

3.2.7 编码格式导致的风险

相信吗?编码习惯和风格的不统一往往也会导致bug。最典型的就是逻辑执行语句后面是否可以省略大括号,看下面示例:

public class Test {
    public static void main(String[] args) {
        int random = new Random().nextInt(10);
        if (random > 5)
            random = random - 5;
        random = random * 2;
        // 以上在语句,if作用域里面和外面的计算逻辑,将会大不相同,而这种问题往往不太容易被人察觉是真实的逻辑就这样,还是写出来了bug
    }
}

与示例代码中类似的还有for,switch default语句,都有编码风格导致程序逻辑出现问题的情况。最好都使用大括号进行作用域的圈定。

3.2.8 集合容器并发与性能风险

如果多个线程同时修改同一个集合或映射,可能会导致数据的不一致性,出现数据丢失或者不可预知的行为。所以,多个线程操作同一个Collcetion或Map时务必要保证线程安全!

比如在有并发场景下的使用的对象容器,可以选择实现了线程安全的ConcurrentHashMap,CopyOnWriteArrayList等。

另外,也要关注容器的实现原理,以方便我们进行性能优化,看下面例子。

arrayList.removeAll(set)的速度远高于arrayList.removeAll(list)。

这是因为前者的删除,使用了HashSet的contain方法,复杂度是O(1),后者使用的是循环遍历,复杂度是O(n),在要删除的数据比较多的情况下,性能差距就会变得非常明显。知道了它们之间的差距了,就可以写出更好的实现了,可以将subList封装为HashSet:arrayList.removeAll(new HashSet(subList))。

同样的道理,在进行HashMap的创建时,为什么建议指定initialCapacity大小呢?这是因为我们往容器里添加元素时,数组的大小会自动增长。数组的大小每次增长都导致内存重新分配和复制,那么就会带来性能的开销。预先合理设置HashMap的大小,就可以避免这种开销。

3.2.9 集合容器操作异常风险

•对于Arrays.asList,Collections.EMPTY_LIST等方法,不要在有调整数组对象的场景下使用。举例:

List list = Arrays.asList("a","b","c");
list.add("d");// 错误:原因是Arrays.asList返回的List对象是一个内部类,其实现不支持数组的更新操作。
// 如果非要进行更新操作,可以使用ArrayList包装一下;
list = new ArrayList<>(list);

•不在Collection,Map容器遍历的过程中直接进行新增或删除操作。

public static void main(String[] args) {
    List list = new ArrayList<>();
    list.add("a");
    list.add("b");
    // 错误用例
    for (String t : list) {
        if ("b".equals(t)) {
            list.remove(t);// 错误(ConcurrentModificationException):不能在遍历的过程中对数组进行新增或删除操作
        }
    }
    // 正常用例
    Iterator iterator = list.iterator();
    while (iterator.hasNext()) {
        String t = iterator.next();
        if ("b".equals(t)) {
            iterator.remove();// 可以使用迭代器方式进行操作
        }
    }
}

3.2.10 ThreadLocal使用风险

1. 内存泄漏风险

ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。

1.使用static的ThreadLocal,延长了ThreadLocal的生命周期,可能导致的内存泄漏。

2.分配使用了ThreadLocal又不再调用get(),set(),remove()方法,那么就会导致内存泄漏。

解决方案:使用ThreadLocal要在接口结束时,通过finally进行remove操作,可以防止内存泄漏;

2. ThreadLocal跨线程传递问题

ThreadLocal的子类InheritableThreadLocal可以实现父子线程之间的数据传递,但它仅适用于 new Thread 手动创建线程的时候!实际上,日常我们使用线程池的场景更多,线程池里面的线程都预创建好的,就没法直接用InheritableThreadLocal了。

如何往线程池内的线程传递 ThreadLocal?

JDK的类库没提供这个功能,可以自己实现或者使用第三方库TransmittableThreadLocal实现跨线程参数传递。

四 设计漏洞

提到设计,可能首先联想到的是架构设计,系统设计,数据结构设计,算法设计等比较宏观的层面,但微观层面同样需要好的设计。一个数据类型的选择,一个请求协议的约定,一个数据对象的定义等。

订单号的生产与应用在电商系统中是一个最基础的属性元素。别小看这一个属性的设计,实现的不合理,可能就会造成深远的影响。下面就列举一下与订单号有关的生产事故。

4.1 后端数据类型越界

问题现象:订单中心订单无法生产,主要表现为:1)用户支付,但app显示待支付,导致用户取消;2)订单已取消,但订单却正常配送了;3)订单无法接单拣货,无法抛单等;

问题定位:根据异常报警信息,初步怀疑是数据超数据库长度,排查日志,发现订单的操作流水表自增id已经超出了integer类型的最大值,溢出;



架构师日记-33个常见编码漏洞大揭秘_第3张图片



应对策略:制定紧急方案,采取创建新表+数据迁移的方案执行;

归因分析

1.设计之初,没有做好容量规划;

2.标准规范没有达成共识,部分非核心表游离在标准之外;

3.分支流程没有与核心流程做到风险隔离;

4.2 订单号生成重复

问题现象:提单有小几率失败的现象,通过日志排查发现订单主表有订单号数据库唯一性约束异常;

问题定位:订单号的生产使用了类似雪花算法的机制,出现相同号码的问题原因是workerNo配置重复导致的。在汰换部分订单号生成服务机器时,对应机器上的配置出现了相同的情形;

应对策略:调整人工绑定workerNo的机制,使用自动生成的方式;

归因分析:需要人工干预的设计,往往是最容易出现问题的。

4.3 前端数据类型越界

问题现象:运营反馈订单运营操作不成功;

问题定位:通过网络抓包发现,服务端下发的订单号和前端传回来的订单号不一致,原因是前端JS支持的最大数字是:9007199254740991,也就是说订单号为数字类型时,不能超过这个值,否则就会出现精度丢失的问题。

应对策略:一种是服务端将订单号的数字类型,调整为字符串类型;另一种是前端使用json-bigint第三方包来临时处理数字超长问题;

归因分析:订单号在视图层,最好使用字符串类型。

4.4 小结

从订单号经历的数次事故来看,我们不难发现,哪怕一个最简单的属性,如果没有好的设计和规划,只要使用的地方足够多,持续的时间足够长,那些微不足道的小问题,在墨菲定律的作用下,总会显现。与订单号类似的场景还有很多,比如:

1.价格的单位使用“元”,还是使用“分”,有没有做到全局单位的统一?

2.商品编号使用Long类型,还是使用String类型?

3.库存是在提单时扣减,还是在结算后扣减?

五 总结

本文介绍了开发过程中常见的30多种编码漏洞,主要包括以下几类:

•并发,线程池,大数据等设计不当,影响性能的场景;

•SQL注入,XSS,XSRF等恶意攻击的场景;

•不恰当设计和编码导致的各种异常的场景;

为了方便理解,各种场景,都给出了示例和说明,希望本文对你有所启发和帮助。

你可能感兴趣的:(java,spring,开发语言)