亲而有间,密而有疏;和而不同,美美与共

记一次性能优化--利差

背景

利差查询的性能一直是个问题,利用插件如arthas或code request分析耗时,发现耗时都在取数上。

因为利差的过滤条件有很多,要先过滤得到债券的每日利差,再按发行人汇总中位数或平均值得到发行人的单日利差,再汇总发行人的利差得到单日利差。一般都是一次获取一年--三年的利差。

原有方案

所以老的方案是在数据库层面做中位数或平均值计算得到主体利差,再在代码层面做主体利差汇总。

慢就慢在数据库层面,如果不在数据库层面做主体利差计算,则数据量又太大。按筛选条件过滤后的债券一般数量级在50-40000。按2w算,2w x 250 = 500w数据。试了很多优化方案,索引、分表。效果都不好。

接口耗时在十几秒到几分钟之间,还忽快忽慢,这个跟数据库本身有关。有时转圈会一直转,所以体验非常不好。

优化方案

定位到性能瓶颈处,查询了大数据量下的处理方案,比较redis、es等,最终结合产品现状(已在使用redis),选择redis进行缓存中间层。

以【利差类型:价格集:基准曲线:日期】作为hash值,数据结构选择hash,债券code作为字段key,利差,剩余期限作为字段value。

优化后,在取1年的场景下,债券量2w的场景,6-7秒,4w的场景 14秒,本地测试。服务器上理论上会快点。

几百个债的场景,基本都是秒出。

--一般利差  
COUPON:EVAL:2142:2025-07-22    
COUPON:QUOTE:2142:2025-07-22 
  
--超额列差  
EXCESS:EVAL:2025-07-22

核心代码

有一点注意,一次交互跟多次取数交互,耗时会存在数量级的区别。

Pipeline pipe = jedis.pipelined();
 ...
// 真正向 Redis 发送请求
pipe.sync();
package com.xquant.xcrms.utils;

import com.xquant.xcrms.common.utils.RedisUtils;
import com.xquant.xcrms.concentrate.constant.SpreadMethodConstant;
import com.xquant.xcrms.coupon.model.dto.CouponSingleResultDto;
import com.xquant.xcrms.spread.constant.CreditPricingConstant;
import com.xquant.xcrms.spread.dto.common.BondPeriodDto;
import com.xquant.xcrms.utils.dao.SpreadRedisDao;
import com.xquant.xcrms.utils.model.BondCorpMapDto;
import com.xquant.xcrms.utils.model.BondSpreadDto;
import com.xquant.xcrms.utils.model.SpreadWithCorpCodeDto;
import org.apache.commons.collections.CollectionUtils;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.Response;

import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 利差缓存操作类
 * Description: <br> 
 * @date 2025/7/22
 */
@Component
public class SpreadRedisUtil {

    private RedisUtils redisUtils;
    private SpreadRedisDao spreadRedisDao;

    public SpreadRedisUtil(RedisUtils redisUtils, SpreadRedisDao spreadRedisDao) {
        this.redisUtils = redisUtils;
        this.spreadRedisDao = spreadRedisDao;
    }

    /**
     * 测试用
     * 缓存一年的国开债一般利差
     *
     * @param date
     */
    public void cacheCouponEval2142InYear(String year) {
        String tablename = "TCREDIT_BOND_EVAL_2142_" + year;
        List<String> dates = spreadRedisDao.querySpreadRateDate(tablename);
        for (int i = 0; i < dates.size(); i++) {
            String date = dates.get(i);
            cacheCouponEval2142(date);
        }
    }


    /**
     * 测试用
     * 缓存单个日期的国开债一般利差
     *
     * @param date
     */
    public void cacheCouponEval2142(String date) {
        List<BondSpreadDto> list = spreadRedisDao.queryBondSpreadByDate("TCREDIT_BOND_EVAL_2142_2024", date);
        Map<String, String> spreadMap = new LinkedHashMap<>();
        for (BondSpreadDto dto : list) {
            spreadMap.put(dto.getoCode(), dto.getRateSpread() + "," + dto.getRemainY());
        }
        cacheSpread(date, spreadMap, CreditPricingConstant.COUPON, CreditPricingConstant.EVAL, "2142");
    }


    /**
     * 测试用
     * 取数
     *
     * @param year
     * @return
     */
    public List<CouponSingleResultDto> loadCouponEval2142(String year) {
        String tablename = "TCREDIT_BOND_EVAL_2142_" + year;
        //List<String> dates = Arrays.asList("2024-11-01");
        List<String> dates = spreadRedisDao.querySpreadRateDate(tablename);
        List<BondCorpMapDto> bondList = spreadRedisDao.queryBondCode();
        List<String> ocodeList = bondList.stream().map(c -> c.getoCode()).collect(Collectors.toList());
        Map<String, String> codeMap = bondList.stream().collect(Collectors.toMap(
                item -> item.getoCode(), // 键:code
                item -> item.getIssuerCode(), // 值:对象本身
                (existing, replacement) -> existing // 合并函数(code唯一时可简单保留第一个)
        ));

        List<CouponSingleResultDto> res = loadSpread(ocodeList, codeMap, dates, CreditPricingConstant.COUPON, CreditPricingConstant.EVAL, "2142", SpreadMethodConstant.MEDIUM, null);
        return res;
    }


    /**
     * 缓存利差
     *
     * @param date
     * @param spreadMap     KEY: OCODE(债券代码) VALUE: RATE_SPREAD,REMAIN_Y
     * @param rateType      一般利差:CreditPricingConstant.COUPON (默认)
     *                      超额利差:CreditPricingConstant.EXCESS
     * @param priceType     CreditPricingConstant.EVAL	  中债行权估值 (默认)
     *                      CreditPricingConstant.QUOTE	  中介报价
     *                      CreditPricingConstant.TRADE	  中介成交价格
     * @param baseCurveCode 基准曲线: 2142   国开债收益率曲线 1232   国债收益率曲线 1102   AAA估值收益率曲线
     */
    public void cacheSpread(String date,
                            Map<String, String> spreadMap,
                            String rateType,
                            String priceType,
                            String baseCurveCode) {
        if (spreadMap == null || spreadMap.isEmpty())
            return;

        //一般利差 COUPON:EVAL:2142:2025-07-22
        //超额列差 EXCESS:EVAL:2142:2025-07-22
        String key = rateType + ":" + priceType + ":" + baseCurveCode + ":" + date;
        Jedis jedis = redisUtils.getJedisDB0();
        try {
            // 1) pipeline 批量写入
            Pipeline pipe = jedis.pipelined();

            // 每 5 000 条一个 pipeline 包,防止请求过大
            Map<String, String> batch = new HashMap<>(5000);
            for (Map.Entry<String, String> e : spreadMap.entrySet()) {
                batch.put(e.getKey(), e.getValue().toString());
                if (batch.size() == 5000) {
                    // 直接覆盖
                    pipe.hset(key, batch);
                    batch.clear();
                }
            }

            if (!batch.isEmpty()) {
                pipe.hset(key, batch);
            }

//            // 2) 设置过期 无需过期
//            if (ttlSeconds > 0) {
//                pipe.expire(key, ttlSeconds);
//            }
            // 3) 一次网络往返
            pipe.sync();
        } finally {
            jedis.close();
        }
    }


    /**
     * 批量取利差
     *
     * @param bonds          债券代码列表(10 万以内)
     * @param codeMap        key: 债券code  value: 主体code
     * @param dates          日期列表(yyyyMMdd,500 天以内)
     * @param rateType       一般利差:CreditPricingConstant.COUPON (默认)
     *                       超额利差:CreditPricingConstant.EXCESS
     * @param priceType      CreditPricingConstant.EVAL	  中债行权估值 (默认)
     *                       CreditPricingConstant.QUOTE	  中介报价
     *                       CreditPricingConstant.TRADE	  中介成交价格
     * @param baseCurveCode  基准曲线: 2142   国开债收益率曲线 1232   国债收益率曲线 1102   AAA估值收益率曲线
     * @param spreadMethod   统计方法
     *                       中位数: SpreadMethodConstant.MEDIUM (默认)
     *                       平均值: SpreadMethodConstant.AVG
     * @param periodDateList 剩余期限过滤限制
     * @return Map<date, spread>
     * @see com.xquant.xcrms.spread.constant.CreditPricingConstant
     * @see com.xquant.xcrms.concentrate.constant.SpreadMethodConstant
     */
    public List<CouponSingleResultDto> loadSpread(List<String> bonds,
                                                  Map<String, String> codeMap,
                                                  List<String> dates,
                                                  String rateType,
                                                  String priceType,
                                                  String baseCurveCode,
                                                  String spreadMethod,
                                                  List<BondPeriodDto> periodDateList
    ) {
        List<CouponSingleResultDto> resMap = new ArrayList<>();
        // 结果容器:外层按日期,内层按债券
        Map<String, Map<String, Double>> result = new LinkedHashMap<>(dates.size());
        for (String d : dates) {
            result.put(d, new HashMap<>(bonds.size()));
        }

        // 每条 pipeline 一次最多拉 5 000 个 field(经验值)
        final int BATCH = 5_000;
        Map<String, List<String>> bondBatches = new LinkedHashMap<>();
        for (int i = 0; i < bonds.size(); i += BATCH) {
            int end = Math.min(i + BATCH, bonds.size());
            List<String> batch = bonds.subList(i, end);
            bondBatches.put(batch.hashCode() + "", batch);
        }

        Boolean filterPeriod = false;
        if (CollectionUtils.isNotEmpty(periodDateList)) {
            filterPeriod = true;
        }

        Jedis jedis = redisUtils.getJedisDB0();
        try {
            Pipeline pipe = jedis.pipelined();

            // 预创建 Response,避免同步等待
            Map<String, Map<String, Response<List<String>>>> resp = new LinkedHashMap<>();
            for (String d : dates) {
                Map<String, Response<List<String>>> dayRes = new LinkedHashMap<>();
                resp.put(d, dayRes);

                for (String hashcodeKey : bondBatches.keySet()) {
                    //一般利差 COUPON:EVAL:2142:2025-07-22
                    //超额列差 EXCESS:EVAL:2142:2025-07-22
                    List<String> bb = bondBatches.get(hashcodeKey);
                    String key = rateType + ":" + priceType + ":" + baseCurveCode + ":" + d;
                    dayRes.put(key + "|" + hashcodeKey,
                            pipe.hmget(key, bb.toArray(new String[0])));
                }
            }
            // 真正向 Redis 发送请求
            pipe.sync();

            // 解析结果 将债券主体Map、中位数or平均值 也传进来 这里直接计算
            for (String d : dates) {
                List<SpreadWithCorpCodeDto> spreadInday = new ArrayList<>();
                Map<String, Response<List<String>>> dayResp = resp.get(d);

                for (Map.Entry<String, Response<List<String>>> e : dayResp.entrySet()) {
                    List<String> batchBonds = bondBatches.get(e.getKey().split("\\|")[1]);
                    List<String> vals = e.getValue().get();
                    for (int i = 0; i < vals.size(); i++) {
                        String v = vals.get(i);
                        if (v != null) {
                            String[] vArr = v.split(",");
                            if (vArr.length < 2) {
                                continue;
                            }
                            //利差值
                            String s1 = vArr[0];
                            //剩余期限(行权期限) 时点下
                            String s2 = vArr[1];
                            if (filterPeriod && handlePeriodJudge(s2, periodDateList)) {
                                SpreadWithCorpCodeDto item = new SpreadWithCorpCodeDto();
                                item.setSpread(Double.parseDouble(s1));
                                item.setCorpCode(codeMap.get(batchBonds.get(i)));
                                spreadInday.add(item);
                            } else {
                                SpreadWithCorpCodeDto item = new SpreadWithCorpCodeDto();
                                item.setSpread(Double.parseDouble(s1));
                                item.setCorpCode(codeMap.get(batchBonds.get(i)));
                                spreadInday.add(item);
                            }
                        }
                    }
                }
                if (spreadInday.size() > 0) {
                    //先按主体分组 取中位数或平均值
                    Map<String, List<SpreadWithCorpCodeDto>> spreadIndayMap = spreadInday.stream().collect(Collectors.groupingBy(c -> c.getCorpCode()));
                    List<Double> vWithCorp = new ArrayList<>(spreadIndayMap.size());
                    for (String corpCode : spreadIndayMap.keySet()) {
                        List<SpreadWithCorpCodeDto> listInCorp = spreadIndayMap.get(corpCode);
                        if (SpreadMethodConstant.AVG.equals(spreadMethod)) {
                            Double v = listInCorp.stream().map(c -> c.getSpread()).mapToDouble(Double::doubleValue)
                                    .average().orElse(Double.NaN);
                            vWithCorp.add(v);
                        } else {
                            //默认中位数
                            List<Double> sorted = listInCorp.stream().map(c -> c.getSpread()).sorted().collect(Collectors.toList());
                            int size = sorted.size();
                            Double v = size % 2 == 1
                                    ? sorted.get(size / 2)
                                    : (sorted.get(size / 2 - 1) + sorted.get(size / 2)) / 2.0;
                            vWithCorp.add(v);
                        }
                    }
                    //将主体的结果汇总成单日的利差
                    if (SpreadMethodConstant.AVG.equals(spreadMethod)) {
                        Double v = vWithCorp.stream().mapToDouble(Double::doubleValue)
                                .average().orElse(Double.NaN);
                        resMap.add(new CouponSingleResultDto() {{
                            setRateDate(d);
                            setRate(new BigDecimal(v * 100));
                        }});
                    } else {
                        //默认中位数
                        vWithCorp = vWithCorp.stream().sorted().collect(Collectors.toList());
                        int size = vWithCorp.size();
                        Double v = size % 2 == 1
                                ? vWithCorp.get(size / 2)
                                : (vWithCorp.get(size / 2 - 1) + vWithCorp.get(size / 2)) / 2.0;
                        resMap.add(new CouponSingleResultDto() {{
                            setRateDate(d);
                            setRate(new BigDecimal(v * 100));
                        }});
                    }
                }
            }
        } finally {
            jedis.close();
        }
        return resMap;
    }

    /**
     * 判断剩余期限是否满足条件
     *
     * @param maturity
     * @param periodDateList
     * @return
     */
    private Boolean handlePeriodJudge(String maturity, List<BondPeriodDto> periodDateList) {
        double v = Double.parseDouble(maturity);

        for (int i = 0; i < periodDateList.size(); i++) {
            BondPeriodDto period = periodDateList.get(i);
            BigDecimal begin = period.getBegin();
            BigDecimal end = period.getEnd();
            Boolean b1 = false;
            Boolean b2 = false;
            if (begin == null) {
                b1 = true;
            } else if (begin != null && v > begin.doubleValue()) {
                b1 = true;
            }
            if (end == null) {
                b2 = true;
            } else if (end != null && v <= end.doubleValue()) {
                b2 = true;
            }
            if (b1 && b2) {
                //有一组条件满足即可
                return true;
            }
        }
        return false;
    }


}

posted @ 2025-07-24 10:53  大兄弟竹子  阅读(8)  评论(0)    收藏  举报